From 209190f62dc3aa37cd349d6b91eca99ca0e53213 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 17 Nov 2025 21:41:57 +0800 Subject: [PATCH 001/691] wallet: revert rename of `confirmed` and `confirms` This will make the rebase process easier for the `interface-wallet`. --- wallet/createtx.go | 4 ++-- wallet/utxos.go | 2 +- wallet/wallet.go | 30 +++++++++++++++--------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/wallet/createtx.go b/wallet/createtx.go index c81c60b917..6b75d7be48 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -374,12 +374,12 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, // Only include this output if it meets the required number of // confirmations. Coinbase transactions must have reached // maturity before their outputs may be spent. - if !hasMinConfs(minconf, output.Height, bs.Height) { + if !confirmed(minconf, output.Height, bs.Height) { continue } if output.FromCoinBase { target := int32(w.chainParams.CoinbaseMaturity) - if !hasMinConfs(target, output.Height, bs.Height) { + if !confirmed(target, output.Height, bs.Height) { continue } } diff --git a/wallet/utxos.go b/wallet/utxos.go index 41699d2a23..27bd563352 100644 --- a/wallet/utxos.go +++ b/wallet/utxos.go @@ -35,7 +35,7 @@ type OutputSelectionPolicy struct { func (p *OutputSelectionPolicy) meetsRequiredConfs(txHeight, curHeight int32) bool { - return hasMinConfs(p.RequiredConfirmations, txHeight, curHeight) + return confirmed(p.RequiredConfirmations, txHeight, curHeight) } // UnspentOutputs fetches all unspent outputs from the wallet that match rules diff --git a/wallet/wallet.go b/wallet/wallet.go index a30958a93b..548ced05e0 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1716,13 +1716,13 @@ func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balan } bals.Total += output.Amount - if output.FromCoinBase && !hasMinConfs( + if output.FromCoinBase && !confirmed( int32(w.chainParams.CoinbaseMaturity), output.Height, syncBlock.Height, ) { bals.ImmatureReward += output.Amount - } else if hasMinConfs( + } else if confirmed( confirms, output.Height, syncBlock.Height, ) { @@ -2126,7 +2126,7 @@ func (c CreditCategory) String() string { // this package at a later time. func RecvCategory(details *wtxmgr.TxDetails, syncHeight int32, net *chaincfg.Params) CreditCategory { if blockchain.IsCoinBaseTx(&details.MsgTx) { - if hasMinConfs( + if confirmed( int32(net.CoinbaseMaturity), details.Block.Height, syncHeight, ) { @@ -2156,7 +2156,7 @@ func listTransactions(tx walletdb.ReadTx, details *wtxmgr.TxDetails, addrMgr *wa blockHashStr = details.Block.Hash.String() blockTime = details.Block.Time.Unix() confirmations = int64( - calcConf(details.Block.Height, syncHeight), + confirms(details.Block.Height, syncHeight), ) } @@ -2622,7 +2622,7 @@ func (w *Wallet) GetTransaction(txHash chainhash.Hash) (*GetTransactionResult, bestBlock := w.SyncedTo() blockHeight := txDetail.Block.Height - res.Confirmations = calcConf( + res.Confirmations = confirms( blockHeight, bestBlock.Height, ) } @@ -2770,14 +2770,14 @@ func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope, } for i := range unspentOutputs { output := &unspentOutputs[i] - if !hasMinConfs( + if !confirmed( requiredConfs, output.Height, syncBlock.Height, ) { continue } - if output.FromCoinBase && !hasMinConfs( + if output.FromCoinBase && !confirmed( int32(w.ChainParams().CoinbaseMaturity), output.Height, syncBlock.Height, ) { @@ -2874,7 +2874,7 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32, // Outputs with fewer confirmations than the minimum or // more confs than the maximum are excluded. - confs := calcConf(output.Height, syncBlock.Height) + confs := confirms(output.Height, syncBlock.Height) if confs < minconf || confs > maxconf { continue } @@ -2882,7 +2882,7 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32, // Only mature coinbase outputs are included. if output.FromCoinBase { target := int32(w.ChainParams().CoinbaseMaturity) - if !hasMinConfs( + if !confirmed( target, output.Height, syncBlock.Height, ) { @@ -3360,16 +3360,16 @@ func (w *Wallet) newChangeAddress(addrmgrNs walletdb.ReadWriteBucket, return addrs[0].Address(), nil } -// hasMinConfs returns whether a transaction has met at least minConf +// confirmed returns whether a transaction has met at least minConf // confirmations at the current block height. -func hasMinConfs(minConf, txHeight, curHeight int32) bool { - return calcConf(txHeight, curHeight) >= minConf +func confirmed(minConf, txHeight, curHeight int32) bool { + return confirms(txHeight, curHeight) >= minConf } -// calcConf returns the number of confirmations for a transaction given its +// confirms returns the number of confirmations for a transaction given its // containing block height and the current best block height. Unconfirmed // transactions have a height of -1 and are considered to have 0 confirmations. -func calcConf(txHeight, curHeight int32) int32 { +func confirms(txHeight, curHeight int32) int32 { switch { // Unconfirmed transactions have 0 confirmations. case txHeight == -1: @@ -3453,7 +3453,7 @@ func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, res := &results[acctIndex] res.TotalReceived += cred.Amount - confs := calcConf( + confs := confirms( detail.Block.Height, syncBlock.Height, ) From 53b07dc3b25b6f7f5a66f824f4ee1f6fafed1eb1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 9 Sep 2025 21:08:59 +0800 Subject: [PATCH 002/691] wallet: introduce `AccountManager` interface --- wallet/account_manager.go | 92 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 wallet/account_manager.go diff --git a/wallet/account_manager.go b/wallet/account_manager.go new file mode 100644 index 0000000000..9055b0e323 --- /dev/null +++ b/wallet/account_manager.go @@ -0,0 +1,92 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "context" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcwallet/waddrmgr" +) + +// AccountManager provides a high-level interface for managing wallet +// accounts. +// +// # Account Derivation +// +// The wallet uses a hierarchical deterministic (HD) key generation scheme based +// on BIP-44. Addresses are derived from a path with the following structure: +// +// m / purpose' / coin_type' / account' / change / address_index +// +// The AccountManager abstracts this complexity by mapping a human-readable +// name to the cryptographic `account'` index within a given KeyScope. +// +// # Key Scopes +// +// The `purpose'` and `coin_type'` fields of the derivation path are defined by +// a waddrmgr.KeyScope. This allows the wallet to manage different kinds of +// accounts (and address types) simultaneously. The wallet initializes a set of +// default scopes upon creation: +// - KeyScopeBIP0044: For legacy P2PKH addresses. +// - KeyScopeBIP0049Plus: For P2WPKH addresses nested in P2SH (NP2WKH). +// - KeyScopeBIP0084: For native SegWit v0 P2WPKH addresses. +// - KeyScopeBIP0086: For native Taproot v1 P2TR addresses. +// +// # Account Names and Reserved Accounts +// +// An account name is a human-readable identifier that is unique *within its +// KeyScope*. The wallet initializes two special, reserved accounts: +// - "default": The first user-created account (account number 0). This +// account is created for each of the default key scopes and CAN be renamed. +// - "imported": A special account that holds all individually imported keys. +// This account is global and CANNOT be renamed. +type AccountManager interface { + // NewAccount creates a new account for a given key scope and name. The + // provided name must be unique within that key scope. + NewAccount(ctx context.Context, scope waddrmgr.KeyScope, name string) ( + *waddrmgr.AccountProperties, error) + + // ListAccounts returns a list of all accounts managed by the wallet. + ListAccounts(ctx context.Context) (*AccountsResult, error) + + // ListAccountsByScope returns a list of all accounts for a given key + // scope. + ListAccountsByScope(ctx context.Context, scope waddrmgr.KeyScope) ( + *AccountsResult, error) + + // ListAccountsByName searches for accounts with the given name across + // all key scopes. Because names are not globally unique, this may + // return multiple results. + ListAccountsByName(ctx context.Context, name string) ( + *AccountsResult, error) + + // GetAccount returns the properties for a specific account, looked up + // by its key scope and unique name within that scope. + GetAccount(ctx context.Context, scope waddrmgr.KeyScope, name string) ( + *AccountResult, error) + + // RenameAccount renames an existing account. To uniquely identify the + // account, the key scope must be provided. The new name must be unique + // within that same key scope. The reserved "imported" account cannot + // be renamed. + RenameAccount(ctx context.Context, scope waddrmgr.KeyScope, + oldName string, newName string) error + + // Balance returns the balance for a specific account, identified by its + // scope and name, for a given number of required confirmations. + Balance(ctx context.Context, conf int32, scope waddrmgr.KeyScope, + accountName string) (btcutil.Amount, error) + + // ImportAccount imports an account from an extended public or private + // key. The key scope is derived from the version bytes of the + // extended key. The account name must be unique within the derived + // scope. If dryRun is true, the import is validated but not persisted. + ImportAccount(ctx context.Context, name string, + accountKey *hdkeychain.ExtendedKey, + masterKeyFingerprint uint32, addrType waddrmgr.AddressType, + dryRun bool) (*waddrmgr.AccountProperties, error) +} From 5485e58e8e966a04ab33aace2e7c6248b01bfa70 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 26 Aug 2025 01:46:52 +0800 Subject: [PATCH 003/691] wallet: rename `w.Manager` to `w.addrStore` To make it clear that it's accessing the addr store and we also make it private so it cannot be accessed outside the package. --- rpc/legacyrpc/methods.go | 12 +-- wallet/chainntfns.go | 22 +++--- wallet/createtx.go | 10 +-- wallet/example_test.go | 2 +- wallet/import.go | 18 ++--- wallet/multisig.go | 4 +- wallet/notifications.go | 6 +- wallet/psbt.go | 4 +- wallet/rescan.go | 2 +- wallet/utxos.go | 4 +- wallet/wallet.go | 157 ++++++++++++++++++++------------------- wallet/wallet_test.go | 2 +- walletsetup.go | 2 +- 13 files changed, 123 insertions(+), 122 deletions(-) diff --git a/rpc/legacyrpc/methods.go b/rpc/legacyrpc/methods.go index 9747fe6854..461b09230f 100644 --- a/rpc/legacyrpc/methods.go +++ b/rpc/legacyrpc/methods.go @@ -450,7 +450,7 @@ func getBalance(icmd interface{}, w *wallet.Wallet) (interface{}, error) { // getBestBlock handles a getbestblock request by returning a JSON object // with the height and hash of the most recently processed block. func getBestBlock(icmd interface{}, w *wallet.Wallet) (interface{}, error) { - blk := w.Manager.SyncedTo() + blk := w.AddrManager().SyncedTo() result := &btcjson.GetBestBlockResult{ Hash: blk.Hash.String(), Height: blk.Height, @@ -461,14 +461,14 @@ func getBestBlock(icmd interface{}, w *wallet.Wallet) (interface{}, error) { // getBestBlockHash handles a getbestblockhash request by returning the hash // of the most recently processed block. func getBestBlockHash(icmd interface{}, w *wallet.Wallet) (interface{}, error) { - blk := w.Manager.SyncedTo() + blk := w.AddrManager().SyncedTo() return blk.Hash.String(), nil } // getBlockCount handles a getblockcount request by returning the chain height // of the most recently processed block. func getBlockCount(icmd interface{}, w *wallet.Wallet) (interface{}, error) { - blk := w.Manager.SyncedTo() + blk := w.AddrManager().SyncedTo() return blk.Height, nil } @@ -811,7 +811,7 @@ func getTransaction(icmd interface{}, w *wallet.Wallet) (interface{}, error) { return nil, &ErrNoTransactionInfo } - syncBlock := w.Manager.SyncedTo() + syncBlock := w.AddrManager().SyncedTo() // TODO: The serialized transaction is already in the DB, so // reserializing can be avoided here. @@ -1134,7 +1134,7 @@ func listReceivedByAddress(icmd interface{}, w *wallet.Wallet) (interface{}, err account string } - syncBlock := w.Manager.SyncedTo() + syncBlock := w.AddrManager().SyncedTo() // Intermediate data for all addresses. allAddrData := make(map[string]AddrData) @@ -1213,7 +1213,7 @@ func listReceivedByAddress(icmd interface{}, w *wallet.Wallet) (interface{}, err func listSinceBlock(icmd interface{}, w *wallet.Wallet, chainClient *chain.RPCClient) (interface{}, error) { cmd := icmd.(*btcjson.ListSinceBlockCmd) - syncBlock := w.Manager.SyncedTo() + syncBlock := w.AddrManager().SyncedTo() targetConf := int64(*cmd.TargetConfirmations) // For the result we need the block hash for the last block counted diff --git a/wallet/chainntfns.go b/wallet/chainntfns.go index d9647e6118..a8fbb4284f 100644 --- a/wallet/chainntfns.go +++ b/wallet/chainntfns.go @@ -50,7 +50,7 @@ func (w *Wallet) handleChainNotifications() { err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - startBlock := w.Manager.SyncedTo() + startBlock := w.addrStore.SyncedTo() for i := startBlock.Height + 1; i <= height; i++ { hash, err := client.GetBlockHash(int64(i)) @@ -67,7 +67,7 @@ func (w *Wallet) handleChainNotifications() { Hash: *hash, Timestamp: header.Timestamp, } - err = w.Manager.SetSyncedTo(ns, &bs) + err = w.addrStore.SetSyncedTo(ns, &bs) if err != nil { return err } @@ -136,7 +136,7 @@ func (w *Wallet) handleChainNotifications() { // missing relevant events. birthdayStore := &walletBirthdayStore{ db: w.db, - manager: w.Manager, + manager: w.addrStore, } birthdayBlock, err := birthdaySanityCheck( chainClient, birthdayStore, @@ -248,7 +248,7 @@ func (w *Wallet) connectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) err Hash: b.Hash, Timestamp: b.Time, } - err := w.Manager.SetSyncedTo(addrmgrNs, &bs) + err := w.addrStore.SetSyncedTo(addrmgrNs, &bs) if err != nil { return err } @@ -273,8 +273,8 @@ func (w *Wallet) disconnectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) // Disconnect the removed block and all blocks after it if we know about // the disconnected block. Otherwise, the block is in the future. - if b.Height <= w.Manager.SyncedTo().Height { - hash, err := w.Manager.BlockHash(addrmgrNs, b.Height) + if b.Height <= w.addrStore.SyncedTo().Height { + hash, err := w.addrStore.BlockHash(addrmgrNs, b.Height) if err != nil { return err } @@ -282,7 +282,7 @@ func (w *Wallet) disconnectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) bs := waddrmgr.BlockStamp{ Height: b.Height - 1, } - hash, err = w.Manager.BlockHash(addrmgrNs, bs.Height) + hash, err = w.addrStore.BlockHash(addrmgrNs, bs.Height) if err != nil { return err } @@ -295,7 +295,7 @@ func (w *Wallet) disconnectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) } bs.Timestamp = header.Timestamp - err = w.Manager.SetSyncedTo(addrmgrNs, &bs) + err = w.addrStore.SetSyncedTo(addrmgrNs, &bs) if err != nil { return err } @@ -350,7 +350,7 @@ func (w *Wallet) addRelevantTx(dbtx walletdb.ReadWriteTx, rec *wtxmgr.TxRecord, } for _, addr := range addrs { - ma, err := w.Manager.Address(addrmgrNs, addr) + ma, err := w.addrStore.Address(addrmgrNs, addr) switch { // Missing addresses are skipped. @@ -367,7 +367,7 @@ func (w *Wallet) addRelevantTx(dbtx walletdb.ReadWriteTx, rec *wtxmgr.TxRecord, // non-default scopes in other places either, so // detecting them here would mean we'd also not properly // detect them as spent later. - scopedManager, _, err := w.Manager.AddrAccount( + scopedManager, _, err := w.addrStore.AddrAccount( addrmgrNs, addr, ) if err != nil { @@ -389,7 +389,7 @@ func (w *Wallet) addRelevantTx(dbtx walletdb.ReadWriteTx, rec *wtxmgr.TxRecord, if err != nil { return err } - err = w.Manager.MarkUsed(addrmgrNs, addr) + err = w.addrStore.MarkUsed(addrmgrNs, addr) if err != nil { return err } diff --git a/wallet/createtx.go b/wallet/createtx.go index 6b75d7be48..4a903ce135 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -281,11 +281,11 @@ func (w *Wallet) txToOutputs(outputs []*wire.TxOut, // was performed from the default wallet accounts // (NP2WKH, P2WKH, P2TR), so any key scope provided // doesn't impact the result of this call. - watchOnly, err = w.Manager.IsWatchOnlyAccount( + watchOnly, err = w.addrStore.IsWatchOnlyAccount( addrmgrNs, waddrmgr.KeyScopeBIP0086, account, ) } else { - watchOnly, err = w.Manager.IsWatchOnlyAccount( + watchOnly, err = w.addrStore.IsWatchOnlyAccount( addrmgrNs, *coinSelectKeyScope, account, ) } @@ -294,7 +294,7 @@ func (w *Wallet) txToOutputs(outputs []*wire.TxOut, } if !watchOnly { err = tx.AddAllInputScripts( - secretSource{w.Manager, addrmgrNs}, + secretSource{w.addrStore, addrmgrNs}, ) if err != nil { return err @@ -399,7 +399,7 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, if err != nil || len(addrs) != 1 { continue } - scopedMgr, addrAcct, err := w.Manager.AddrAccount(addrmgrNs, addrs[0]) + scopedMgr, addrAcct, err := w.addrStore.AddrAccount(addrmgrNs, addrs[0]) if err != nil { continue } @@ -447,7 +447,7 @@ func (w *Wallet) addrMgrWithChangeSource(dbtx walletdb.ReadWriteTx, // It's possible for the account to have an address schema override, so // prefer that if it exists. addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) - scopeMgr, err := w.Manager.FetchScopedKeyManager(*changeKeyScope) + scopeMgr, err := w.addrStore.FetchScopedKeyManager(*changeKeyScope) if err != nil { return nil, nil, err } diff --git a/wallet/example_test.go b/wallet/example_test.go index ca5255060e..905793789a 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -64,7 +64,7 @@ func testWalletWatchingOnly(t *testing.T) (*Wallet, func()) { err = walletdb.Update(w.Database(), func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) for scope, schema := range waddrmgr.ScopeAddrMap { - _, err := w.Manager.NewScopedKeyManager( + _, err := w.addrStore.NewScopedKeyManager( ns, scope, schema, ) if err != nil { diff --git a/wallet/import.go b/wallet/import.go index b5783bf8af..5aa338240a 100644 --- a/wallet/import.go +++ b/wallet/import.go @@ -278,9 +278,9 @@ func (w *Wallet) importAccountScope(ns walletdb.ReadWriteBucket, name string, keyScope waddrmgr.KeyScope, addrSchema *waddrmgr.ScopeAddrSchema) ( *waddrmgr.AccountProperties, error) { - scopedMgr, err := w.Manager.FetchScopedKeyManager(keyScope) + scopedMgr, err := w.addrStore.FetchScopedKeyManager(keyScope) if err != nil { - scopedMgr, err = w.Manager.NewScopedKeyManager( + scopedMgr, err = w.addrStore.NewScopedKeyManager( ns, keyScope, *addrSchema, ) if err != nil { @@ -341,7 +341,7 @@ func (w *Wallet) ImportAccountDryRun(name string, // we go through the ScopedKeyManager instead to ensure // addresses will be derived as expected from the wallet's // point-of-view. - manager, err := w.Manager.FetchScopedKeyManager( + manager, err := w.addrStore.FetchScopedKeyManager( accountProps.KeyScope, ) if err != nil { @@ -411,7 +411,7 @@ func (w *Wallet) ImportPublicKey(pubKey *btcec.PublicKey, return fmt.Errorf("address type %v is not supported", addrType) } - scopedKeyManager, err := w.Manager.FetchScopedKeyManager(keyScope) + scopedKeyManager, err := w.addrStore.FetchScopedKeyManager(keyScope) if err != nil { return err } @@ -445,7 +445,7 @@ func (w *Wallet) ImportTaprootScript(scope waddrmgr.KeyScope, witnessVersion byte, isSecretScript bool) (waddrmgr.ManagedAddress, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err } @@ -499,7 +499,7 @@ func (w *Wallet) ImportTaprootScript(scope waddrmgr.KeyScope, func (w *Wallet) ImportPrivateKey(scope waddrmgr.KeyScope, wif *btcutil.WIF, bs *waddrmgr.BlockStamp, rescan bool) (string, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return "", err } @@ -542,7 +542,7 @@ func (w *Wallet) ImportPrivateKey(scope waddrmgr.KeyScope, wif *btcutil.WIF, // before our current one. Otherwise, if we do, we can // potentially miss detecting relevant chain events that // occurred between them while rescanning. - birthdayBlock, _, err := w.Manager.BirthdayBlock(addrmgrNs) + birthdayBlock, _, err := w.addrStore.BirthdayBlock(addrmgrNs) if err != nil { return err } @@ -550,7 +550,7 @@ func (w *Wallet) ImportPrivateKey(scope waddrmgr.KeyScope, wif *btcutil.WIF, return nil } - err = w.Manager.SetBirthday(addrmgrNs, bs.Timestamp) + err = w.addrStore.SetBirthday(addrmgrNs, bs.Timestamp) if err != nil { return err } @@ -558,7 +558,7 @@ func (w *Wallet) ImportPrivateKey(scope waddrmgr.KeyScope, wif *btcutil.WIF, // To ensure this birthday block is correct, we'll mark it as // unverified to prompt a sanity check at the next restart to // ensure it is correct as it was provided by the caller. - return w.Manager.SetBirthdayBlock(addrmgrNs, *bs, false) + return w.addrStore.SetBirthdayBlock(addrmgrNs, *bs, false) }) if err != nil { return "", err diff --git a/wallet/multisig.go b/wallet/multisig.go index b1a7932ade..7891f5ee9d 100644 --- a/wallet/multisig.go +++ b/wallet/multisig.go @@ -52,7 +52,7 @@ func (w *Wallet) MakeMultiSigScript(addrs []btcutil.Address, nRequired int) ([]b } addrmgrNs = dbtx.ReadBucket(waddrmgrNamespaceKey) } - addrInfo, err := w.Manager.Address(addrmgrNs, addr) + addrInfo, err := w.addrStore.Address(addrmgrNs, addr) if err != nil { return nil, err } @@ -85,7 +85,7 @@ func (w *Wallet) ImportP2SHRedeemScript(script []byte) (*btcutil.AddressScriptHa // As this is a regular P2SH script, we'll import this into the // BIP0044 scope. - bip44Mgr, err := w.Manager.FetchScopedKeyManager( + bip44Mgr, err := w.addrStore.FetchScopedKeyManager( waddrmgr.KeyScopeBIP0084, ) if err != nil { diff --git a/wallet/notifications.go b/wallet/notifications.go index 1bd64326bc..d44109e422 100644 --- a/wallet/notifications.go +++ b/wallet/notifications.go @@ -67,7 +67,7 @@ func lookupInputAccount(dbtx walletdb.ReadTx, w *Wallet, details *wtxmgr.TxDetai _, addrs, _, err := txscript.ExtractPkScriptAddrs(prevOut.PkScript, w.chainParams) var inputAcct uint32 if err == nil && len(addrs) > 0 { - _, inputAcct, err = w.Manager.AddrAccount(addrmgrNs, addrs[0]) + _, inputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) } if err != nil { log.Errorf("Cannot fetch account for previous output %v: %v", prevOP, err) @@ -85,7 +85,7 @@ func lookupOutputChain(dbtx walletdb.ReadTx, w *Wallet, details *wtxmgr.TxDetail _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, w.chainParams) var ma waddrmgr.ManagedAddress if err == nil && len(addrs) > 0 { - ma, err = w.Manager.Address(addrmgrNs, addrs[0]) + ma, err = w.addrStore.Address(addrmgrNs, addrs[0]) } if err != nil { log.Errorf("Cannot fetch account for wallet output: %v", err) @@ -165,7 +165,7 @@ func totalBalances(dbtx walletdb.ReadTx, w *Wallet, m map[uint32]btcutil.Amount) _, addrs, _, err := txscript.ExtractPkScriptAddrs( output.PkScript, w.chainParams) if err == nil && len(addrs) > 0 { - _, outputAcct, err = w.Manager.AddrAccount(addrmgrNs, addrs[0]) + _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) } if err == nil { _, ok := m[outputAcct] diff --git a/wallet/psbt.go b/wallet/psbt.go index 2151858954..cb342dfe3f 100644 --- a/wallet/psbt.go +++ b/wallet/psbt.go @@ -501,11 +501,11 @@ func (w *Wallet) FinalizePsbt(keyScope *waddrmgr.KeyScope, account uint32, // wallet accounts (NP2WKH, P2WKH, P2TR), so any // key scope provided doesn't impact the result // of this call. - watchOnly, err = w.Manager.IsWatchOnlyAccount( + watchOnly, err = w.addrStore.IsWatchOnlyAccount( ns, waddrmgr.KeyScopeBIP0084, account, ) } else { - watchOnly, err = w.Manager.IsWatchOnlyAccount( + watchOnly, err = w.addrStore.IsWatchOnlyAccount( ns, *keyScope, account, ) } diff --git a/wallet/rescan.go b/wallet/rescan.go index 8da33d246b..a070d6e11d 100644 --- a/wallet/rescan.go +++ b/wallet/rescan.go @@ -301,7 +301,7 @@ func (w *Wallet) rescanWithTarget(addrs []btcutil.Address, // starting point for the rescan. if startStamp == nil { startStamp = &waddrmgr.BlockStamp{} - *startStamp = w.Manager.SyncedTo() + *startStamp = w.addrStore.SyncedTo() } job := &RescanJob{ diff --git a/wallet/utxos.go b/wallet/utxos.go index 27bd563352..610aaae9cd 100644 --- a/wallet/utxos.go +++ b/wallet/utxos.go @@ -46,7 +46,7 @@ func (w *Wallet) UnspentOutputs(policy OutputSelectionPolicy) ([]*TransactionOut addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() // TODO: actually stream outputs from the db instead of fetching // all of them at once. @@ -72,7 +72,7 @@ func (w *Wallet) UnspentOutputs(policy OutputSelectionPolicy) ([]*TransactionOut // per output. continue } - _, outputAcct, err := w.Manager.AddrAccount(addrmgrNs, addrs[0]) + _, outputAcct, err := w.addrStore.AddrAccount(addrmgrNs, addrs[0]) if err != nil { return err } diff --git a/wallet/wallet.go b/wallet/wallet.go index 548ced05e0..ef86070009 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -124,8 +124,8 @@ type Wallet struct { publicPassphrase []byte // Data stores - db walletdb.DB - Manager *waddrmgr.Manager + db walletdb.DB + addrStore *waddrmgr.Manager TxStore *wtxmgr.Store chainClient chain.Interface @@ -227,9 +227,9 @@ func (w *Wallet) SynchronizeRPC(chainClient chain.Interface) { // we don't download all the filters as we go. switch cc := chainClient.(type) { case *chain.NeutrinoClient: - cc.SetStartTime(w.Manager.Birthday()) + cc.SetStartTime(w.addrStore.Birthday()) case *chain.BitcoindClient: - cc.SetBirthday(w.Manager.Birthday()) + cc.SetBirthday(w.addrStore.Birthday()) } w.chainClientLock.Unlock() @@ -363,7 +363,7 @@ func (w *Wallet) activeData(dbtx walletdb.ReadWriteTx) ([]btcutil.Address, []wtx txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) var addrs []btcutil.Address - err := w.Manager.ForEachRelevantActiveAddress( + err := w.addrStore.ForEachRelevantActiveAddress( addrmgrNs, func(addr btcutil.Address) error { addrs = append(addrs, addr) return nil @@ -422,7 +422,7 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { if birthdayStamp == nil { var err error birthdayStamp, err = locateBirthdayBlock( - chainClient, w.Manager.Birthday(), + chainClient, w.addrStore.Birthday(), ) if err != nil { return fmt.Errorf("unable to locate birthday block: %w", @@ -449,7 +449,7 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - err := w.Manager.SetSyncedTo(ns, &waddrmgr.BlockStamp{ + err := w.addrStore.SetSyncedTo(ns, &waddrmgr.BlockStamp{ Hash: *startHash, Height: startHeight, Timestamp: startHeader.Timestamp, @@ -457,7 +457,8 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { if err != nil { return err } - return w.Manager.SetBirthdayBlock(ns, *birthdayStamp, true) + + return w.addrStore.SetBirthdayBlock(ns, *birthdayStamp, true) }) if err != nil { return fmt.Errorf("unable to persist initial sync "+ @@ -478,13 +479,13 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { // these blocks no longer exist, rollback all of the missing blocks // before catching up with the rescan. rollback := false - rollbackStamp := w.Manager.SyncedTo() + rollbackStamp := w.addrStore.SyncedTo() err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) for height := rollbackStamp.Height; true; height-- { - hash, err := w.Manager.BlockHash(addrmgrNs, height) + hash, err := w.addrStore.BlockHash(addrmgrNs, height) if err != nil { return err } @@ -513,7 +514,7 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { } // Otherwise, we'll mark this as our new synced height. - err := w.Manager.SetSyncedTo(addrmgrNs, &rollbackStamp) + err := w.addrStore.SetSyncedTo(addrmgrNs, &rollbackStamp) if err != nil { return err } @@ -524,7 +525,7 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { if rollbackStamp.Height <= birthdayStamp.Height && rollbackStamp.Hash != birthdayStamp.Hash { - err := w.Manager.SetBirthdayBlock( + err := w.addrStore.SetBirthdayBlock( addrmgrNs, rollbackStamp, true, ) if err != nil { @@ -720,7 +721,7 @@ func (w *Wallet) recovery(chainClient chain.Interface, // bug in which the wallet would create change addresses outside of the // default scopes, it's necessary to attempt all registered key scopes. scopedMgrs := make(map[waddrmgr.KeyScope]*waddrmgr.ScopedKeyManager) - for _, scopedMgr := range w.Manager.ActiveScopedKeyManagers() { + for _, scopedMgr := range w.addrStore.ActiveScopedKeyManagers() { scopedMgrs[scopedMgr.Scope()] = scopedMgr } err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { @@ -753,7 +754,7 @@ func (w *Wallet) recovery(chainClient chain.Interface, // that a wallet rescan will be performed from the wallet's tip, which // will be of bestHeight after completing the recovery process. var blocks []*waddrmgr.BlockStamp - startHeight := w.Manager.SyncedTo().Height + 1 + startHeight := w.addrStore.SyncedTo().Height + 1 for height := startHeight; height <= bestHeight; height++ { if atomic.LoadUint32(&syncer.quit) == 1 { return errors.New("recovery: forced shutdown") @@ -802,7 +803,7 @@ func (w *Wallet) recovery(chainClient chain.Interface, // point to become desyncronized. Refactor so // that this cannot happen. for _, block := range blocks { - err := w.Manager.SetSyncedTo(ns, block) + err := w.addrStore.SetSyncedTo(ns, block) if err != nil { return err } @@ -1241,7 +1242,7 @@ out: // private key material, we need to prevent it from // doing so while we are assembling the transaction. release := func() {} - if !w.Manager.WatchOnly() { + if !w.addrStore.WatchOnly() { heldUnlock, err := w.holdUnlock() if err != nil { txr.resp <- createTxResponse{nil, err} @@ -1419,7 +1420,7 @@ out: case req := <-w.unlockRequests: err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - return w.Manager.Unlock(addrmgrNs, req.passphrase) + return w.addrStore.Unlock(addrmgrNs, req.passphrase) }) if err != nil { req.err <- err @@ -1437,7 +1438,7 @@ out: case req := <-w.changePassphrase: err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - return w.Manager.ChangePassphrase( + return w.addrStore.ChangePassphrase( addrmgrNs, req.old, req.new, req.private, &waddrmgr.DefaultScryptOptions, ) @@ -1448,7 +1449,7 @@ out: case req := <-w.changePassphrases: err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - err := w.Manager.ChangePassphrase( + err := w.addrStore.ChangePassphrase( addrmgrNs, req.publicOld, req.publicNew, false, &waddrmgr.DefaultScryptOptions, ) @@ -1456,7 +1457,7 @@ out: return err } - return w.Manager.ChangePassphrase( + return w.addrStore.ChangePassphrase( addrmgrNs, req.privateOld, req.privateNew, true, &waddrmgr.DefaultScryptOptions, ) @@ -1465,7 +1466,7 @@ out: continue case req := <-w.holdUnlockRequests: - if w.Manager.IsLocked() { + if w.addrStore.IsLocked() { close(req) continue } @@ -1485,7 +1486,7 @@ out: continue } - case w.lockState <- w.Manager.IsLocked(): + case w.lockState <- w.addrStore.IsLocked(): continue case <-quit: @@ -1503,7 +1504,7 @@ out: <-w.endRecovery() timeout = nil - err := w.Manager.Lock() + err := w.addrStore.Lock() if err != nil && !waddrmgr.IsError(err, waddrmgr.ErrLocked) { log.Errorf("Could not lock wallet: %v", err) } else { @@ -1614,7 +1615,7 @@ func (w *Wallet) ChangePassphrases(publicOld, publicNew, privateOld, func (w *Wallet) AccountAddresses(account uint32) (addrs []btcutil.Address, err error) { err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - return w.Manager.ForEachAccountAddress(addrmgrNs, account, func(maddr waddrmgr.ManagedAddress) error { + return w.addrStore.ForEachAccountAddress(addrmgrNs, account, func(maddr waddrmgr.ManagedAddress) error { addrs = append(addrs, maddr.Address()) return nil }) @@ -1627,7 +1628,7 @@ func (w *Wallet) AccountAddresses(account uint32) (addrs []btcutil.Address, err func (w *Wallet) AccountManagedAddresses(scope waddrmgr.KeyScope, accountNum uint32) ([]waddrmgr.ManagedAddress, error) { - scopedMgr, err := w.Manager.FetchScopedKeyManager(scope) + scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err } @@ -1667,7 +1668,7 @@ func (w *Wallet) CalculateBalance(confirms int32) (btcutil.Amount, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) var err error - blk := w.Manager.SyncedTo() + blk := w.addrStore.SyncedTo() balance, err = w.TxStore.Balance(txmgrNs, confirms, blk.Height) return err }) @@ -1696,7 +1697,7 @@ func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balan // Get current block. The block height used for calculating // the number of tx confirmations. - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() unspent, err := w.TxStore.UnspentOutputs(txmgrNs) if err != nil { @@ -1709,7 +1710,7 @@ func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balan _, addrs, _, err := txscript.ExtractPkScriptAddrs( output.PkScript, w.chainParams) if err == nil && len(addrs) > 0 { - _, outputAcct, err = w.Manager.AddrAccount(addrmgrNs, addrs[0]) + _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) } if err != nil || outputAcct != account { continue @@ -1744,7 +1745,7 @@ func (w *Wallet) CurrentAddress(account uint32, scope waddrmgr.KeyScope) (btcuti return nil, err } - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err } @@ -1811,7 +1812,7 @@ func (w *Wallet) PubKeyForAddress(a btcutil.Address) (*btcec.PublicKey, error) { var pubKey *btcec.PublicKey err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - managedAddr, err := w.Manager.Address(addrmgrNs, a) + managedAddr, err := w.addrStore.Address(addrmgrNs, a) if err != nil { return err } @@ -1883,7 +1884,7 @@ func (w *Wallet) PrivKeyForAddress(a btcutil.Address) (*btcec.PrivateKey, error) var privKey *btcec.PrivateKey err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - managedAddr, err := w.Manager.Address(addrmgrNs, a) + managedAddr, err := w.addrStore.Address(addrmgrNs, a) if err != nil { return err } @@ -1901,7 +1902,7 @@ func (w *Wallet) PrivKeyForAddress(a btcutil.Address) (*btcec.PrivateKey, error) func (w *Wallet) HaveAddress(a btcutil.Address) (bool, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - _, err := w.Manager.Address(addrmgrNs, a) + _, err := w.addrStore.Address(addrmgrNs, a) return err }) if err == nil { @@ -1919,7 +1920,7 @@ func (w *Wallet) AccountOfAddress(a btcutil.Address) (uint32, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) var err error - _, account, err = w.Manager.AddrAccount(addrmgrNs, a) + _, account, err = w.addrStore.AddrAccount(addrmgrNs, a) return err }) return account, err @@ -1931,7 +1932,7 @@ func (w *Wallet) AddressInfo(a btcutil.Address) (waddrmgr.ManagedAddress, error) err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) var err error - managedAddress, err = w.Manager.Address(addrmgrNs, a) + managedAddress, err = w.addrStore.Address(addrmgrNs, a) return err }) return managedAddress, err @@ -1940,7 +1941,7 @@ func (w *Wallet) AddressInfo(a btcutil.Address) (waddrmgr.ManagedAddress, error) // AccountNumber returns the account number for an account name under a // particular key scope. func (w *Wallet) AccountNumber(scope waddrmgr.KeyScope, accountName string) (uint32, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return 0, err } @@ -1957,7 +1958,7 @@ func (w *Wallet) AccountNumber(scope waddrmgr.KeyScope, accountName string) (uin // AccountName returns the name of an account. func (w *Wallet) AccountName(scope waddrmgr.KeyScope, accountNumber uint32) (string, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return "", err } @@ -1976,7 +1977,7 @@ func (w *Wallet) AccountName(scope waddrmgr.KeyScope, accountNumber uint32) (str // indexes and name. It first fetches the desynced information from the address // manager, then updates the indexes based on the address pools. func (w *Wallet) AccountProperties(scope waddrmgr.KeyScope, acct uint32) (*waddrmgr.AccountProperties, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err } @@ -1997,7 +1998,7 @@ func (w *Wallet) AccountProperties(scope waddrmgr.KeyScope, acct uint32) (*waddr func (w *Wallet) AccountPropertiesByName(scope waddrmgr.KeyScope, name string) (*waddrmgr.AccountProperties, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err } @@ -2025,7 +2026,7 @@ func (w *Wallet) LookupAccount(name string) (waddrmgr.KeyScope, uint32, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(waddrmgrNamespaceKey) var err error - keyScope, account, err = w.Manager.LookupAccount(ns, name) + keyScope, account, err = w.addrStore.LookupAccount(ns, name) return err }) return keyScope, account, err @@ -2033,7 +2034,7 @@ func (w *Wallet) LookupAccount(name string) (waddrmgr.KeyScope, uint32, error) { // RenameAccount sets the name for an account number to newName. func (w *Wallet) RenameAccount(scope waddrmgr.KeyScope, account uint32, newName string) error { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return err } @@ -2060,7 +2061,7 @@ func (w *Wallet) RenameAccount(scope waddrmgr.KeyScope, account uint32, newName // accounts have no transaction history (this is a deviation from the BIP0044 // spec, which allows no unused account gaps). func (w *Wallet) NextAccount(scope waddrmgr.KeyScope, name string) (uint32, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return 0, err } @@ -2282,7 +2283,7 @@ func (w *Wallet) ListSinceBlock(start, end, syncHeight int32) ([]btcjson.ListTra detail := detail jsonResults := listTransactions( - tx, &detail, w.Manager, syncHeight, + tx, &detail, w.addrStore, syncHeight, w.chainParams, ) txList = append(txList, jsonResults...) @@ -2306,7 +2307,7 @@ func (w *Wallet) ListTransactions(from, count int) ([]btcjson.ListTransactionsRe // Get current block. The block height used for calculating // the number of tx confirmations. - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() // Need to skip the first from transactions, and after those, only // include the next count transactions. @@ -2330,7 +2331,7 @@ func (w *Wallet) ListTransactions(from, count int) ([]btcjson.ListTransactionsRe } jsonResults := listTransactions(tx, &details[i], - w.Manager, syncBlock.Height, w.chainParams) + w.addrStore, syncBlock.Height, w.chainParams) txList = append(txList, jsonResults...) if len(jsonResults) > 0 { @@ -2358,7 +2359,7 @@ func (w *Wallet) ListAddressTransactions(pkHashes map[string]struct{}) ([]btcjso // Get current block. The block height used for calculating // the number of tx confirmations. - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { loopDetails: for i := range details { @@ -2381,7 +2382,7 @@ func (w *Wallet) ListAddressTransactions(pkHashes map[string]struct{}) ([]btcjso } jsonResults := listTransactions(tx, detail, - w.Manager, syncBlock.Height, w.chainParams) + w.addrStore, syncBlock.Height, w.chainParams) txList = append(txList, jsonResults...) continue loopDetails } @@ -2404,7 +2405,7 @@ func (w *Wallet) ListAllTransactions() ([]btcjson.ListTransactionsResult, error) // Get current block. The block height used for calculating // the number of tx confirmations. - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { // Iterate over transactions at this height in reverse order. @@ -2412,7 +2413,7 @@ func (w *Wallet) ListAllTransactions() ([]btcjson.ListTransactionsResult, error) // unsorted, but it will process mined transactions in the // reverse order they were marked mined. for i := len(details) - 1; i >= 0; i-- { - jsonResults := listTransactions(tx, &details[i], w.Manager, + jsonResults := listTransactions(tx, &details[i], w.addrStore, syncBlock.Height, w.chainParams) txList = append(txList, jsonResults...) } @@ -2656,7 +2657,7 @@ type AccountsResult struct { // TODO(jrick): Is the chain tip really needed, since only the total balances // are included? func (w *Wallet) Accounts(scope waddrmgr.KeyScope) (*AccountsResult, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err } @@ -2670,7 +2671,7 @@ func (w *Wallet) Accounts(scope waddrmgr.KeyScope) (*AccountsResult, error) { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() syncBlockHash = &syncBlock.Hash syncBlockHeight = syncBlock.Height unspent, err := w.TxStore.UnspentOutputs(txmgrNs) @@ -2701,7 +2702,7 @@ func (w *Wallet) Accounts(scope waddrmgr.KeyScope) (*AccountsResult, error) { var outputAcct uint32 _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, w.chainParams) if err == nil && len(addrs) > 0 { - _, outputAcct, err = w.Manager.AddrAccount(addrmgrNs, addrs[0]) + _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) } if err == nil { amt, ok := m[outputAcct] @@ -2732,7 +2733,7 @@ type AccountBalanceResult struct { func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope, requiredConfs int32) ([]AccountBalanceResult, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err } @@ -2742,7 +2743,7 @@ func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope, addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() // Fill out all account info except for the balances. lastAcct, err := manager.LastAccount(addrmgrNs) @@ -2857,7 +2858,7 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32, addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() filter := accountName != "" unspent, err := w.TxStore.UnspentOutputs(txmgrNs) @@ -2908,7 +2909,7 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32, continue } if len(addrs) > 0 { - smgr, acct, err := w.Manager.AddrAccount(addrmgrNs, addrs[0]) + smgr, acct, err := w.addrStore.AddrAccount(addrmgrNs, addrs[0]) if err == nil { s, err := smgr.AccountName(addrmgrNs, acct) if err == nil { @@ -2945,7 +2946,7 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32, spendable = true case txscript.MultiSigTy: for _, a := range addrs { - _, err := w.Manager.Address(addrmgrNs, a) + _, err := w.addrStore.Address(addrmgrNs, a) if err == nil { continue } @@ -3037,8 +3038,8 @@ func (w *Wallet) DumpPrivKeys() ([]string, error) { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // Iterate over each active address, appending the private key to // privkeys. - return w.Manager.ForEachActiveAddress(addrmgrNs, func(addr btcutil.Address) error { - ma, err := w.Manager.Address(addrmgrNs, addr) + return w.addrStore.ForEachActiveAddress(addrmgrNs, func(addr btcutil.Address) error { + ma, err := w.addrStore.Address(addrmgrNs, addr) if err != nil { return err } @@ -3071,7 +3072,7 @@ func (w *Wallet) DumpWIFPrivateKey(addr btcutil.Address) (string, error) { waddrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // Get private key from wallet if it exists. var err error - maddr, err = w.Manager.Address(waddrmgrNs, addr) + maddr, err = w.addrStore.Address(waddrmgrNs, addr) return err }) if err != nil { @@ -3220,7 +3221,7 @@ func (w *Wallet) SortedActivePaymentAddresses() ([]string, error) { var addrStrs []string err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - return w.Manager.ForEachActiveAddress(addrmgrNs, func(addr btcutil.Address) error { + return w.addrStore.ForEachActiveAddress(addrmgrNs, func(addr btcutil.Address) error { addrStrs = append(addrStrs, addr.EncodeAddress()) return nil }) @@ -3279,7 +3280,7 @@ func (w *Wallet) NewAddress(account uint32, func (w *Wallet) newAddress(addrmgrNs walletdb.ReadWriteBucket, account uint32, scope waddrmgr.KeyScope) (btcutil.Address, *waddrmgr.AccountProperties, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, nil, err } @@ -3346,7 +3347,7 @@ func (w *Wallet) NewChangeAddress(account uint32, func (w *Wallet) newChangeAddress(addrmgrNs walletdb.ReadWriteBucket, account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err } @@ -3400,7 +3401,7 @@ type AccountTotalReceivedResult struct { func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, minConf int32) ([]AccountTotalReceivedResult, error) { - manager, err := w.Manager.FetchScopedKeyManager(scope) + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err } @@ -3410,7 +3411,7 @@ func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() err := manager.ForEachAccount(addrmgrNs, func(account uint32) error { accountName, err := manager.AccountName(addrmgrNs, account) @@ -3443,7 +3444,7 @@ func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, var outputAcct uint32 _, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, w.chainParams) if err == nil && len(addrs) > 0 { - _, outputAcct, err = w.Manager.AddrAccount(addrmgrNs, addrs[0]) + _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) } if err == nil { acctIndex := int(outputAcct) @@ -3476,7 +3477,7 @@ func (w *Wallet) TotalReceivedForAddr(addr btcutil.Address, minConf int32) (btcu err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - syncBlock := w.Manager.SyncedTo() + syncBlock := w.addrStore.SyncedTo() var ( addrStr = addr.EncodeAddress() @@ -3580,7 +3581,7 @@ func (w *Wallet) sendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, // If our wallet is read-only, we'll get a transaction with coins // selected but no witness data. In such a case we need to inform our // caller that they'll actually need to go ahead and sign the TX. - if w.Manager.WatchOnly() { + if w.addrStore.WatchOnly() { return createdTx.Tx, ErrTxUnsigned } @@ -3656,7 +3657,7 @@ func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, } return wif.PrivKey, wif.CompressPubKey, nil } - address, err := w.Manager.Address(addrmgrNs, addr) + address, err := w.addrStore.Address(addrmgrNs, addr) if err != nil { return nil, false, err } @@ -3685,7 +3686,7 @@ func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, } return script, nil } - address, err := w.Manager.Address(addrmgrNs, addr) + address, err := w.addrStore.Address(addrmgrNs, addr) if err != nil { return nil, err } @@ -3867,7 +3868,7 @@ func (w *Wallet) reliablyPublishTransaction(tx *wire.MsgTx, for _, addr := range addrs { // Skip any addresses which are not relevant to // us. - _, err := w.Manager.Address(addrmgrNs, addr) + _, err := w.addrStore.Address(addrmgrNs, addr) if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { continue } @@ -4028,7 +4029,7 @@ func (w *Wallet) BirthdayBlock() (*waddrmgr.BlockStamp, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - bb, _, err := w.Manager.BirthdayBlock(addrmgrNs) + bb, _, err := w.addrStore.BirthdayBlock(addrmgrNs) birthdayBlock = bb return err @@ -4050,7 +4051,7 @@ func (w *Wallet) AddScopeManager(scope waddrmgr.KeyScope, err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - manager, err := w.Manager.NewScopedKeyManager( + manager, err := w.addrStore.NewScopedKeyManager( addrmgrNs, scope, addrSchema, ) scopedManager = manager @@ -4101,7 +4102,7 @@ func (w *Wallet) InitAccounts(scope *waddrmgr.ScopedKeyManager, ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - return w.Manager.ConvertToWatchingOnly(ns) + return w.addrStore.ConvertToWatchingOnly(ns) } return nil @@ -4112,7 +4113,7 @@ func (w *Wallet) InitAccounts(scope *waddrmgr.ScopedKeyManager, func (w *Wallet) DeriveFromKeyPath(scope waddrmgr.KeyScope, path waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { - scopedMgr, err := w.Manager.FetchScopedKeyManager(scope) + scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, fmt.Errorf("error fetching manager for scope %v: "+ "%w", scope, err) @@ -4157,7 +4158,7 @@ func (w *Wallet) DeriveFromKeyPath(scope waddrmgr.KeyScope, func (w *Wallet) DeriveFromKeyPathAddAccount(scope waddrmgr.KeyScope, path waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { - scopedMgr, err := w.Manager.FetchScopedKeyManager(scope) + scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, fmt.Errorf("error fetching manager for scope %v: "+ "%w", scope, err) @@ -4224,14 +4225,14 @@ func (w *Wallet) DeriveFromKeyPathAddAccount(scope waddrmgr.KeyScope, // SyncedTo calls the `SyncedTo` method on the wallet's manager. func (w *Wallet) SyncedTo() waddrmgr.BlockStamp { - return w.Manager.SyncedTo() + return w.addrStore.SyncedTo() } // AddrManager returns the internal address manager. // // TODO(yy): Refactor it in lnd and remove the method. func (w *Wallet) AddrManager() *waddrmgr.Manager { - return w.Manager + return w.addrStore } // NotificationServer returns the internal NotificationServer. @@ -4411,7 +4412,7 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, w := &Wallet{ publicPassphrase: pubPass, db: db, - Manager: addrMgr, + addrStore: addrMgr, TxStore: txMgr, lockedOutpoints: map[wire.OutPoint]struct{}{}, recoveryWindow: recoveryWindow, diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 527cd83ad2..8d733118cd 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -409,7 +409,7 @@ func TestGetTransactionConfirmations(t *testing.T) { Hash: chainhash.Hash{}, } - return w.Manager.SetSyncedTo( + return w.addrStore.SetSyncedTo( addrmgrNs, bs, ) }, diff --git a/walletsetup.go b/walletsetup.go index f43119413e..d5316f7d17 100644 --- a/walletsetup.go +++ b/walletsetup.go @@ -193,7 +193,7 @@ func createWallet(cfg *config) error { return err } - w.Manager.Close() + w.AddrManager().Close() fmt.Println("The wallet has been created successfully.") return nil } From 9c27a0b101790e4bbdc95fe01a9e695b60c6a35c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 26 Aug 2025 01:54:05 +0800 Subject: [PATCH 004/691] wallet: rename `w.TxStore` to `w.txStore` We make this tx store private to forbidden access outside of the package. --- wallet/chainntfns.go | 6 ++-- wallet/createtx.go | 2 +- wallet/createtx_test.go | 8 ++--- wallet/notifications.go | 12 +++---- wallet/unstable.go | 4 +-- wallet/utxos.go | 2 +- wallet/wallet.go | 70 ++++++++++++++++++++++------------------- wallet/wallet_test.go | 6 ++-- 8 files changed, 57 insertions(+), 53 deletions(-) diff --git a/wallet/chainntfns.go b/wallet/chainntfns.go index a8fbb4284f..478b11023a 100644 --- a/wallet/chainntfns.go +++ b/wallet/chainntfns.go @@ -300,7 +300,7 @@ func (w *Wallet) disconnectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) return err } - err = w.TxStore.Rollback(txmgrNs, b.Height) + err = w.txStore.Rollback(txmgrNs, b.Height) if err != nil { return err } @@ -323,7 +323,7 @@ func (w *Wallet) addRelevantTx(dbtx walletdb.ReadWriteTx, rec *wtxmgr.TxRecord, // relevant. This assumption will not hold true when SPV support is // added, but until then, simply insert the transaction because there // should either be one or more relevant inputs or outputs. - exists, err := w.TxStore.InsertTxCheckIfExists(txmgrNs, rec, block) + exists, err := w.txStore.InsertTxCheckIfExists(txmgrNs, rec, block) if err != nil { return err } @@ -383,7 +383,7 @@ func (w *Wallet) addRelevantTx(dbtx walletdb.ReadWriteTx, rec *wtxmgr.TxRecord, // TODO: Credits should be added with the // account they belong to, so wtxmgr is able to // track per-account balances. - err = w.TxStore.AddCredit( + err = w.txStore.AddCredit( txmgrNs, rec, block, uint32(i), ma.Internal(), ) if err != nil { diff --git a/wallet/createtx.go b/wallet/createtx.go index 4a903ce135..3bf0df1dba 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -350,7 +350,7 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) - unspent, err := w.TxStore.UnspentOutputs(txmgrNs) + unspent, err := w.txStore.UnspentOutputs(txmgrNs) if err != nil { return nil, err } diff --git a/wallet/createtx_test.go b/wallet/createtx_test.go index 6e6cbacc7d..0ffdc37e13 100644 --- a/wallet/createtx_test.go +++ b/wallet/createtx_test.go @@ -194,13 +194,13 @@ func addUtxo(t *testing.T, w *Wallet, incomingTx *wire.MsgTx) { if err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) - err = w.TxStore.InsertTx(ns, rec, block) + err = w.txStore.InsertTx(ns, rec, block) if err != nil { return err } // Add all tx outputs as credits. for i := 0; i < len(incomingTx.TxOut); i++ { - err = w.TxStore.AddCredit( + err = w.txStore.AddCredit( ns, rec, block, uint32(i), false, ) if err != nil { @@ -238,13 +238,13 @@ func addTxAndCredit(t *testing.T, w *Wallet, tx *wire.MsgTx, err = walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { ns := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) - err = w.TxStore.InsertTx(ns, rec, block) + err = w.txStore.InsertTx(ns, rec, block) if err != nil { return err } // Add the specified output as credit. - err = w.TxStore.AddCredit(ns, rec, block, creditIndex, false) + err = w.txStore.AddCredit(ns, rec, block, creditIndex, false) if err != nil { return err } diff --git a/wallet/notifications.go b/wallet/notifications.go index d44109e422..45803b192f 100644 --- a/wallet/notifications.go +++ b/wallet/notifications.go @@ -54,7 +54,7 @@ func lookupInputAccount(dbtx walletdb.ReadTx, w *Wallet, details *wtxmgr.TxDetai // TODO: Debits should record which account(s?) they // debit from so this doesn't need to be looked up. prevOP := &details.MsgTx.TxIn[deb.Index].PreviousOutPoint - prev, err := w.TxStore.TxDetails(txmgrNs, &prevOP.Hash) + prev, err := w.txStore.TxDetails(txmgrNs, &prevOP.Hash) if err != nil { log.Errorf("Cannot query previous transaction details for %v: %v", prevOP.Hash, err) return 0 @@ -155,7 +155,7 @@ func makeTxSummary(dbtx walletdb.ReadTx, w *Wallet, details *wtxmgr.TxDetails) T func totalBalances(dbtx walletdb.ReadTx, w *Wallet, m map[uint32]btcutil.Amount) error { addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) - unspent, err := w.TxStore.UnspentOutputs(dbtx.ReadBucket(wtxmgrNamespaceKey)) + unspent, err := w.txStore.UnspentOutputs(dbtx.ReadBucket(wtxmgrNamespaceKey)) if err != nil { return err } @@ -215,7 +215,7 @@ func (s *NotificationServer) notifyUnminedTransaction(dbtx walletdb.ReadTx, // TODO(wilmer): ideally we should find the culprit to why we're // receiving an additional unconfirmed chain.RelevantTx notification // from the chain backend. - details, err := s.wallet.TxStore.UniqueTxDetails(ns, &txHash, nil) + details, err := s.wallet.txStore.UniqueTxDetails(ns, &txHash, nil) if err != nil { log.Errorf("Cannot query transaction details for "+ "notification: %v", err) @@ -231,7 +231,7 @@ func (s *NotificationServer) notifyUnminedTransaction(dbtx walletdb.ReadTx, } unminedTxs := []TransactionSummary{makeTxSummary(dbtx, s.wallet, details)} - unminedHashes, err := s.wallet.TxStore.UnminedTxHashes(dbtx.ReadBucket(wtxmgrNamespaceKey)) + unminedHashes, err := s.wallet.txStore.UnminedTxHashes(dbtx.ReadBucket(wtxmgrNamespaceKey)) if err != nil { log.Errorf("Cannot fetch unmined transaction hashes: %v", err) return @@ -284,7 +284,7 @@ func (s *NotificationServer) notifyMinedTransaction(dbtx walletdb.ReadTx, // We'll only notify the transaction if it was found within the // wallet's set of confirmed transactions. - details, err := s.wallet.TxStore.UniqueTxDetails( + details, err := s.wallet.txStore.UniqueTxDetails( ns, &txHash, &block.Block, ) if err != nil { @@ -353,7 +353,7 @@ func (s *NotificationServer) notifyAttachedBlock(dbtx walletdb.ReadTx, block *wt // a new, previously unseen transaction appearing in unconfirmed. txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) - unminedHashes, err := s.wallet.TxStore.UnminedTxHashes(txmgrNs) + unminedHashes, err := s.wallet.txStore.UnminedTxHashes(txmgrNs) if err != nil { log.Errorf("Cannot fetch unmined transaction hashes: %v", err) return diff --git a/wallet/unstable.go b/wallet/unstable.go index 31f68896d9..2b79c90bda 100644 --- a/wallet/unstable.go +++ b/wallet/unstable.go @@ -28,7 +28,7 @@ func (u unstableAPI) TxDetails(txHash *chainhash.Hash) (*wtxmgr.TxDetails, error err := walletdb.View(u.w.db, func(dbtx walletdb.ReadTx) error { txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) var err error - details, err = u.w.TxStore.TxDetails(txmgrNs, txHash) + details, err = u.w.txStore.TxDetails(txmgrNs, txHash) return err }) return details, err @@ -39,6 +39,6 @@ func (u unstableAPI) TxDetails(txHash *chainhash.Hash) (*wtxmgr.TxDetails, error func (u unstableAPI) RangeTransactions(begin, end int32, f func([]wtxmgr.TxDetails) (bool, error)) error { return walletdb.View(u.w.db, func(dbtx walletdb.ReadTx) error { txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) - return u.w.TxStore.RangeTransactions(txmgrNs, begin, end, f) + return u.w.txStore.RangeTransactions(txmgrNs, begin, end, f) }) } diff --git a/wallet/utxos.go b/wallet/utxos.go index 610aaae9cd..62bfc00a59 100644 --- a/wallet/utxos.go +++ b/wallet/utxos.go @@ -50,7 +50,7 @@ func (w *Wallet) UnspentOutputs(policy OutputSelectionPolicy) ([]*TransactionOut // TODO: actually stream outputs from the db instead of fetching // all of them at once. - outputs, err := w.TxStore.UnspentOutputs(txmgrNs) + outputs, err := w.txStore.UnspentOutputs(txmgrNs) if err != nil { return err } diff --git a/wallet/wallet.go b/wallet/wallet.go index ef86070009..0a72392f43 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -126,7 +126,7 @@ type Wallet struct { // Data stores db walletdb.DB addrStore *waddrmgr.Manager - TxStore *wtxmgr.Store + txStore *wtxmgr.Store chainClient chain.Interface chainClientLock sync.Mutex @@ -375,14 +375,14 @@ func (w *Wallet) activeData(dbtx walletdb.ReadWriteTx) ([]btcutil.Address, []wtx // Before requesting the list of spendable UTXOs, we'll delete any // expired output locks. - err = w.TxStore.DeleteExpiredLockedOutputs( + err = w.txStore.DeleteExpiredLockedOutputs( dbtx.ReadWriteBucket(wtxmgrNamespaceKey), ) if err != nil { return nil, nil, err } - unspent, err := w.TxStore.OutputsToWatch(txmgrNs) + unspent, err := w.txStore.OutputsToWatch(txmgrNs) return addrs, unspent, err } @@ -537,7 +537,7 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { // stale state. `Rollback` unconfirms transactions at and beyond // the passed height, so add one to the new synced-to height to // prevent unconfirming transactions in the synced-to block. - return w.TxStore.Rollback(txmgrNs, rollbackStamp.Height+1) + return w.txStore.Rollback(txmgrNs, rollbackStamp.Height+1) }) if err != nil { return err @@ -726,7 +726,7 @@ func (w *Wallet) recovery(chainClient chain.Interface, } err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { txMgrNS := tx.ReadBucket(wtxmgrNamespaceKey) - credits, err := w.TxStore.UnspentOutputs(txMgrNS) + credits, err := w.txStore.UnspentOutputs(txMgrNS) if err != nil { return err } @@ -1669,7 +1669,7 @@ func (w *Wallet) CalculateBalance(confirms int32) (btcutil.Amount, error) { txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) var err error blk := w.addrStore.SyncedTo() - balance, err = w.TxStore.Balance(txmgrNs, confirms, blk.Height) + balance, err = w.txStore.Balance(txmgrNs, confirms, blk.Height) return err }) return balance, err @@ -1699,7 +1699,7 @@ func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balan // the number of tx confirmations. syncBlock := w.addrStore.SyncedTo() - unspent, err := w.TxStore.UnspentOutputs(txmgrNs) + unspent, err := w.txStore.UnspentOutputs(txmgrNs) if err != nil { return err } @@ -1838,7 +1838,7 @@ func (w *Wallet) LabelTransaction(hash chainhash.Hash, label string, err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - dbTx, err := w.TxStore.TxDetails(txmgrNs, &hash) + dbTx, err := w.txStore.TxDetails(txmgrNs, &hash) if err != nil { return err } @@ -1874,7 +1874,7 @@ func (w *Wallet) LabelTransaction(hash chainhash.Hash, label string, return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) - return w.TxStore.PutTxLabel(txmgrNs, hash, label) + return w.txStore.PutTxLabel(txmgrNs, hash, label) }) } @@ -2291,7 +2291,7 @@ func (w *Wallet) ListSinceBlock(start, end, syncHeight int32) ([]btcjson.ListTra return false, nil } - return w.TxStore.RangeTransactions(txmgrNs, start, end, rangeFn) + return w.txStore.RangeTransactions(txmgrNs, start, end, rangeFn) }) return txList, err } @@ -2344,7 +2344,7 @@ func (w *Wallet) ListTransactions(from, count int) ([]btcjson.ListTransactionsRe // Return newer results first by starting at mempool height and working // down to the genesis block. - return w.TxStore.RangeTransactions(txmgrNs, -1, 0, rangeFn) + return w.txStore.RangeTransactions(txmgrNs, -1, 0, rangeFn) }) return txList, err } @@ -2390,7 +2390,7 @@ func (w *Wallet) ListAddressTransactions(pkHashes map[string]struct{}) ([]btcjso return false, nil } - return w.TxStore.RangeTransactions(txmgrNs, 0, -1, rangeFn) + return w.txStore.RangeTransactions(txmgrNs, 0, -1, rangeFn) }) return txList, err } @@ -2422,7 +2422,7 @@ func (w *Wallet) ListAllTransactions() ([]btcjson.ListTransactionsResult, error) // Return newer results first by starting at mempool height and // working down to the genesis block. - return w.TxStore.RangeTransactions(txmgrNs, -1, 0, rangeFn) + return w.txStore.RangeTransactions(txmgrNs, -1, 0, rangeFn) }) return txList, err } @@ -2572,7 +2572,7 @@ func (w *Wallet) GetTransactions(startBlock, endBlock *BlockIdentifier, } } - return w.TxStore.RangeTransactions(txmgrNs, start, end, rangeFn) + return w.txStore.RangeTransactions(txmgrNs, start, end, rangeFn) }) return &res, err } @@ -2596,7 +2596,7 @@ func (w *Wallet) GetTransaction(txHash chainhash.Hash) (*GetTransactionResult, err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) - txDetail, err := w.TxStore.TxDetails(txmgrNs, &txHash) + txDetail, err := w.txStore.TxDetails(txmgrNs, &txHash) if err != nil { return err } @@ -2674,7 +2674,7 @@ func (w *Wallet) Accounts(scope waddrmgr.KeyScope) (*AccountsResult, error) { syncBlock := w.addrStore.SyncedTo() syncBlockHash = &syncBlock.Hash syncBlockHeight = syncBlock.Height - unspent, err := w.TxStore.UnspentOutputs(txmgrNs) + unspent, err := w.txStore.UnspentOutputs(txmgrNs) if err != nil { return err } @@ -2765,7 +2765,7 @@ func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope, // Fetch all unspent outputs, and iterate over them tallying each // account's balance where the output script pays to an account address // and the required number of confirmations is met. - unspentOutputs, err := w.TxStore.UnspentOutputs(txmgrNs) + unspentOutputs, err := w.txStore.UnspentOutputs(txmgrNs) if err != nil { return err } @@ -2861,7 +2861,7 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32, syncBlock := w.addrStore.SyncedTo() filter := accountName != "" - unspent, err := w.TxStore.UnspentOutputs(txmgrNs) + unspent, err := w.txStore.UnspentOutputs(txmgrNs) if err != nil { return err } @@ -2996,13 +2996,13 @@ func (w *Wallet) ListLeasedOutputs() ([]*ListLeasedOutputResult, error) { var results []*ListLeasedOutputResult err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(wtxmgrNamespaceKey) - outputs, err := w.TxStore.ListLockedOutputs(ns) + outputs, err := w.txStore.ListLockedOutputs(ns) if err != nil { return err } for _, output := range outputs { - details, err := w.TxStore.TxDetails(ns, &output.Outpoint.Hash) + details, err := w.txStore.TxDetails(ns, &output.Outpoint.Hash) if err != nil { return err } @@ -3169,7 +3169,7 @@ func (w *Wallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint, err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) var err error - expiry, err = w.TxStore.LockOutput(ns, id, op, duration) + expiry, err = w.txStore.LockOutput(ns, id, op, duration) return err }) return expiry, err @@ -3181,7 +3181,7 @@ func (w *Wallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint, func (w *Wallet) ReleaseOutput(id wtxmgr.LockID, op wire.OutPoint) error { return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) - return w.TxStore.UnlockOutput(ns, id, op) + return w.txStore.UnlockOutput(ns, id, op) }) } @@ -3193,7 +3193,7 @@ func (w *Wallet) resendUnminedTxs() { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) var err error - txs, err = w.TxStore.UnminedTxs(txmgrNs) + txs, err = w.txStore.UnminedTxs(txmgrNs) return err }) if err != nil { @@ -3464,7 +3464,8 @@ func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, } return false, nil } - return w.TxStore.RangeTransactions(txmgrNs, 0, stopHeight, rangeFn) + + return w.txStore.RangeTransactions(txmgrNs, 0, stopHeight, rangeFn) }) return results, err } @@ -3511,7 +3512,8 @@ func (w *Wallet) TotalReceivedForAddr(addr btcutil.Address, minConf int32) (btcu } return false, nil } - return w.TxStore.RangeTransactions(txmgrNs, 0, stopHeight, rangeFn) + + return w.txStore.RangeTransactions(txmgrNs, 0, stopHeight, rangeFn) }) return amount, err } @@ -3630,7 +3632,7 @@ func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, if !ok { prevHash := &txIn.PreviousOutPoint.Hash prevIndex := txIn.PreviousOutPoint.Index - txDetails, err := w.TxStore.TxDetails(txmgrNs, prevHash) + txDetails, err := w.txStore.TxDetails(txmgrNs, prevHash) if err != nil { return fmt.Errorf("cannot query previous transaction "+ "details for %v: %w", txIn.PreviousOutPoint, err) @@ -3883,7 +3885,7 @@ func (w *Wallet) reliablyPublishTransaction(tx *wire.MsgTx, // and record it in the tx store. if len(label) != 0 { txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) - if err = w.TxStore.PutTxLabel(txmgrNs, tx.TxHash(), label); err != nil { + if err = w.txStore.PutTxLabel(txmgrNs, tx.TxHash(), label); err != nil { return err } } @@ -3934,7 +3936,8 @@ func (w *Wallet) publishTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) { if err != nil { return err } - return w.TxStore.RemoveUnminedTx(txmgrNs, txRec) + + return w.txStore.RemoveUnminedTx(txmgrNs, txRec) }) if dbErr != nil { log.Warnf("Unable to remove confirmed transaction %v "+ @@ -3960,7 +3963,8 @@ func (w *Wallet) publishTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) { if err != nil { return err } - return w.TxStore.RemoveUnminedTx(txmgrNs, txRec) + + return w.txStore.RemoveUnminedTx(txmgrNs, txRec) }) if dbErr != nil { log.Warnf("Unable to remove invalid transaction %v: %v", @@ -4014,7 +4018,7 @@ func (w *Wallet) RemoveDescendants(tx *wire.MsgTx) error { return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { wtxmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) - return w.TxStore.RemoveUnminedTx(wtxmgrNs, txRecord) + return w.txStore.RemoveUnminedTx(wtxmgrNs, txRecord) }) } @@ -4412,8 +4416,8 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, w := &Wallet{ publicPassphrase: pubPass, db: db, - addrStore: addrMgr, - TxStore: txMgr, + addrStore: addrMgr, + txStore: txMgr, lockedOutpoints: map[wire.OutPoint]struct{}{}, recoveryWindow: recoveryWindow, rescanAddJob: make(chan *RescanJob), @@ -4434,7 +4438,7 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, } w.NtfnServer = newNotificationServer(w) - w.TxStore.NotifyUnspent = func(hash *chainhash.Hash, index uint32) { + w.txStore.NotifyUnspent = func(hash *chainhash.Hash, index uint32) { w.NtfnServer.notifyUnspentOutput(0, hash, index) } diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 8d733118cd..3f8ce6c3be 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -191,7 +191,7 @@ func TestLabelTransaction(t *testing.T) { wtxmgrNamespaceKey, ) - return w.TxStore.InsertTx( + return w.txStore.InsertTx( ns, rec, nil, ) }) @@ -293,7 +293,7 @@ func TestGetTransaction(t *testing.T) { err := walletdb.Update(w.db, func(rw walletdb.ReadWriteTx) error { ns := rw.ReadWriteBucket(wtxmgrNamespaceKey) - _, err := test.f(w.TxStore, ns) + _, err := test.f(w.txStore, ns) return err }) require.NoError(t, err) @@ -439,7 +439,7 @@ func TestGetTransactionConfirmations(t *testing.T) { } } - return w.TxStore.InsertTx( + return w.txStore.InsertTx( ns, rec, blockMeta, ) }, From bb438487ad910395249c13cc88be369397783db9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 26 Aug 2025 18:45:24 +0800 Subject: [PATCH 005/691] waddrmgr+wallet: add method `CanAddAccount` We now start to break the db logic from business logic. --- waddrmgr/manager_test.go | 12 ++---------- waddrmgr/scoped_manager.go | 31 +++++++++++++++---------------- wallet/wallet.go | 6 ++++++ 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/waddrmgr/manager_test.go b/waddrmgr/manager_test.go index f0a5d8af93..64e2ee9f95 100644 --- a/waddrmgr/manager_test.go +++ b/waddrmgr/manager_test.go @@ -1402,11 +1402,7 @@ func testChangePassphrase(tc *testContext) bool { func testNewAccount(tc *testContext) bool { if tc.watchingOnly { // Creating new accounts in watching-only mode should return ErrWatchingOnly - err := walletdb.Update(tc.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - _, err := tc.manager.NewAccount(ns, "test") - return err - }) + err := tc.manager.CanAddAccount() if !checkManagerError( tc.t, "Create account in watching-only mode", err, ErrWatchingOnly, @@ -1417,11 +1413,7 @@ func testNewAccount(tc *testContext) bool { return true } // Creating new accounts when wallet is locked should return ErrLocked - err := walletdb.Update(tc.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - _, err := tc.manager.NewAccount(ns, "test") - return err - }) + err := tc.manager.CanAddAccount() if !checkManagerError( tc.t, "Create account when wallet is locked", err, ErrLocked, ) { diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 0f12d1d779..0d9edea449 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -1547,22 +1547,29 @@ func (s *ScopedKeyManager) LastInternalAddress(ns walletdb.ReadBucket, return nil, managerError(ErrAddressNotFound, "no previous internal address", nil) } -// NewRawAccount creates a new account for the scoped manager. This method -// differs from the NewAccount method in that this method takes the account -// number *directly*, rather than taking a string name for the account, then -// mapping that to the next highest account number. -func (s *ScopedKeyManager) NewRawAccount(ns walletdb.ReadWriteBucket, number uint32) error { +// CanAddAccount returns an error if a new account cannot be created. +// This is the case if the manager is watch-only or is locked. A descriptive +// error is returned in these cases. +func (s *ScopedKeyManager) CanAddAccount() error { if s.rootManager.WatchOnly() { return managerError(ErrWatchingOnly, errWatchingOnly, nil) } - s.mtx.Lock() - defer s.mtx.Unlock() - if s.rootManager.IsLocked() { return managerError(ErrLocked, errLocked, nil) } + return nil +} + +// NewRawAccount creates a new account for the scoped manager. This method +// differs from the NewAccount method in that this method takes the account +// number *directly*, rather than taking a string name for the account, then +// mapping that to the next highest account number. +func (s *ScopedKeyManager) NewRawAccount(ns walletdb.ReadWriteBucket, number uint32) error { + s.mtx.Lock() + defer s.mtx.Unlock() + // As this is an ad hoc account that may not follow our normal linear // derivation, we'll create a new name for this account based off of // the account number. @@ -1606,17 +1613,9 @@ func (s *ScopedKeyManager) NewRawAccountWatchingOnly( // access to the cointype keys (from which extended account keys are derived), // it requires the manager to be unlocked. func (s *ScopedKeyManager) NewAccount(ns walletdb.ReadWriteBucket, name string) (uint32, error) { - if s.rootManager.WatchOnly() { - return 0, managerError(ErrWatchingOnly, errWatchingOnly, nil) - } - s.mtx.Lock() defer s.mtx.Unlock() - if s.rootManager.IsLocked() { - return 0, managerError(ErrLocked, errLocked, nil) - } - // Fetch latest account, and create a new account in the same // transaction Fetch the latest account number to generate the next // account number diff --git a/wallet/wallet.go b/wallet/wallet.go index 0a72392f43..c26374b130 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -2066,6 +2066,12 @@ func (w *Wallet) NextAccount(scope waddrmgr.KeyScope, name string) (uint32, erro return 0, err } + // Validate that the scope manager can add this new account. + err = manager.CanAddAccount() + if err != nil { + return 0, err + } + var ( account uint32 props *waddrmgr.AccountProperties From d9790717ffc254a82e1ef002f9abea87c811a63e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 26 Aug 2025 20:34:29 +0800 Subject: [PATCH 006/691] wallet: implement `NewAccount` This method behaves almost the same as `NextAccount`, we also create a new file to track all the deprecated methods. We still need to keep them for now to satisfy the `wallet.Interface` and stay backwards-compatible. --- wallet/account_manager.go | 38 ++++++++++++++++++++++ wallet/account_manager_test.go | 58 ++++++++++++++++++++++++++++++++++ wallet/deprecated.go | 49 ++++++++++++++++++++++++++++ wallet/wallet.go | 40 ----------------------- 4 files changed, 145 insertions(+), 40 deletions(-) create mode 100644 wallet/account_manager_test.go create mode 100644 wallet/deprecated.go diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 9055b0e323..8a1a179417 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" ) // AccountManager provides a high-level interface for managing wallet @@ -90,3 +91,40 @@ type AccountManager interface { masterKeyFingerprint uint32, addrType waddrmgr.AddressType, dryRun bool) (*waddrmgr.AccountProperties, error) } + +// NewAccount creates the next account and returns its account number. The name +// must be unique under the kep scope. In order to support automatic seed +// restoring, new accounts may not be created when all of the previous 100 +// accounts have no transaction history (this is a deviation from the BIP0044 +// spec, which allows no unused account gaps). +func (w *Wallet) NewAccount(_ context.Context, scope waddrmgr.KeyScope, + name string) (*waddrmgr.AccountProperties, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + // Validate that the scope manager can add this new account. + err = manager.CanAddAccount() + if err != nil { + return nil, err + } + + var props *waddrmgr.AccountProperties + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + // Create a new account under the current key scope. + accountID, err := manager.NewAccount(addrmgrNs, name) + if err != nil { + return err + } + + // Get the account's properties. + props, err = manager.AccountProperties(addrmgrNs, accountID) + return err + }) + + return props, err +} diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go new file mode 100644 index 0000000000..d25d5cd819 --- /dev/null +++ b/wallet/account_manager_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "context" + "testing" + + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +const ( + // testAccountName is a constant for the account name used in the tests. + testAccountName = "test" +) + +// TestNewAccount tests that the NewAccount method works as expected. +func TestNewAccount(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll start by creating a new account under the BIP0084 scope. We + // expect this to succeed. + scope := waddrmgr.KeyScopeBIP0084 + account, err := w.NewAccount( + context.Background(), scope, testAccountName, + ) + require.NoError(t, err, "unable to create new account") + + // The new account should be the first account created, so it should have + // an index of 1. + require.Equal(t, uint32(1), account.AccountNumber, "expected account 1") + + // We should be able to retrieve the account by its name. + _, err = w.AccountName(scope, account.AccountNumber) + require.NoError(t, err, "unable to retrieve account") + + // We should not be able to create a new account with the same name. + _, err = w.NewAccount(context.Background(), scope, testAccountName) + require.Error(t, err, "expected error when creating duplicate account") + + // We should not be able to create a new account when the wallet is + // locked. + err = w.addrStore.Lock() + require.NoError(t, err) + + _, err = w.NewAccount(context.Background(), scope, "test2") + require.Error( + t, err, "expected error when creating account while wallet is "+ + "locked", + ) +} diff --git a/wallet/deprecated.go b/wallet/deprecated.go new file mode 100644 index 0000000000..2984c9531e --- /dev/null +++ b/wallet/deprecated.go @@ -0,0 +1,49 @@ +//nolint:ll +package wallet + +import ( + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" +) + +// NextAccount creates the next account and returns its account number. The +// name must be unique to the account. In order to support automatic seed +// restoring, new accounts may not be created when all of the previous 100 +// accounts have no transaction history (this is a deviation from the BIP0044 +// spec, which allows no unused account gaps). +func (w *Wallet) NextAccount(scope waddrmgr.KeyScope, name string) (uint32, error) { + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return 0, err + } + + // Validate that the scope manager can add this new account. + err = manager.CanAddAccount() + if err != nil { + return 0, err + } + + var ( + account uint32 + props *waddrmgr.AccountProperties + ) + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + var err error + account, err = manager.NewAccount(addrmgrNs, name) + if err != nil { + return err + } + props, err = manager.AccountProperties(addrmgrNs, account) + + return err + }) + if err != nil { + log.Errorf("Cannot fetch new account properties for notification "+ + "after account creation: %v", err) + } else { + w.NtfnServer.notifyAccountProperties(props) + } + + return account, err +} diff --git a/wallet/wallet.go b/wallet/wallet.go index c26374b130..57abf35376 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -2055,46 +2055,6 @@ func (w *Wallet) RenameAccount(scope waddrmgr.KeyScope, account uint32, newName return err } -// NextAccount creates the next account and returns its account number. The -// name must be unique to the account. In order to support automatic seed -// restoring, new accounts may not be created when all of the previous 100 -// accounts have no transaction history (this is a deviation from the BIP0044 -// spec, which allows no unused account gaps). -func (w *Wallet) NextAccount(scope waddrmgr.KeyScope, name string) (uint32, error) { - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return 0, err - } - - // Validate that the scope manager can add this new account. - err = manager.CanAddAccount() - if err != nil { - return 0, err - } - - var ( - account uint32 - props *waddrmgr.AccountProperties - ) - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - var err error - account, err = manager.NewAccount(addrmgrNs, name) - if err != nil { - return err - } - props, err = manager.AccountProperties(addrmgrNs, account) - return err - }) - if err != nil { - log.Errorf("Cannot fetch new account properties for notification "+ - "after account creation: %v", err) - } else { - w.NtfnServer.notifyAccountProperties(props) - } - return account, err -} - // CreditCategory describes the type of wallet transaction output. The category // of "sent transactions" (debits) is always "send", and is not expressed by // this type. From 7c36f1406d81681c42465d9ae880cadb466cd9af Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 00:31:44 +0800 Subject: [PATCH 007/691] wallet: implement `ListAccounts` Implement the `ListAccounts` interface method on the wallet, which serves a similar purpose as `Accounts` method, but more efficient. --- wallet/account_manager.go | 177 +++++++++++++++++++++++++++++++++ wallet/account_manager_test.go | 154 ++++++++++++++++++++++++++++ wallet/deprecated.go | 76 ++++++++++++++ wallet/wallet.go | 84 ---------------- 4 files changed, 407 insertions(+), 84 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 8a1a179417..defec0d7d2 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -9,6 +9,8 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" ) @@ -128,3 +130,178 @@ func (w *Wallet) NewAccount(_ context.Context, scope waddrmgr.KeyScope, return props, err } + +// AccountResult is the result of a ListAccounts query. +type AccountResult struct { + // AccountProperties is the account's properties. + waddrmgr.AccountProperties + + // TotalBalance is the total balance of the account. + TotalBalance btcutil.Amount +} + +// AccountsResult is the result of a ListAccounts query. It contains a list of +// accounts and the current block height and hash. +type AccountsResult struct { + // Accounts is a list of accounts. + Accounts []AccountResult + + // CurrentBlockHash is the hash of the current block. + CurrentBlockHash chainhash.Hash + + // CurrentBlockHeight is the height of the current block. + CurrentBlockHeight int32 +} + +// ListAccounts returns a list of all accounts for the wallet, including +// accounts with a zero balance. The current chain tip is included in the +// result for reference. +// +// The implementation is optimized for performance by first building a map of +// balances for all addresses with unspent outputs and then iterating through +// all known accounts to tally up their final balances. +// +// The time complexity of this method is O(U + A), where U is the number of +// UTXOs and A is the number of addresses in the wallet. This is a significant +// improvement over a naive implementation that would have a complexity of +// O(U * A), e.g., the old `Accounts` method. +// +// A potential future improvement would be to index UTXOs by account directly +// in the database, which would reduce the complexity to O(A). +func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { + managers := w.addrStore.ActiveScopedKeyManagers() + + var accounts []AccountResult + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // First, we'll create a map of all addresses to their balances + // by iterating through all unspent outputs. This is more + // efficient than iterating through all addresses and looking + // up their balances individually. + addrToBalance := make(map[string]btcutil.Amount) + utxos, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + + for _, utxo := range utxos { + // Decode the script to find the address. + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + utxo.PkScript, w.chainParams, + ) + if err != nil { + // We'll log the error and skip this UTXO. This + // is to prevent a single un-parsable UTXO from + // failing the entire call, which would be a + // poor user experience. + log.Errorf("Unable to parse pkscript for UTXO "+ + "%v: %v", utxo.OutPoint, err) + continue + } + + // This can happen for scripts that don't resolve to a + // standard address, such as OP_RETURN outputs. We can + // safely ignore these. + if len(addrs) == 0 { + continue + } + + // TODO(yy): For bare multisig outputs, + // ExtractPkScriptAddrs can return more than one + // address. Currently, we are only considering the + // first address, which could lead to incorrect balance + // attribution. However, since bare multisig is rare + // and modern wallets almost exclusively use P2SH or + // P2WSH for multisig (which are correctly handled as a + // single address), this is a low-priority issue. + // + // Add the UTXO's value to the address's balance. + addrStr := addrs[0].String() + addrToBalance[addrStr] += utxo.Amount + } + + // Now, we'll iterate through all the accounts and calculate + // their balances by summing up the balances of all their + // addresses. + for _, scopeMgr := range managers { + results, err := createResultForScope( + scopeMgr, addrmgrNs, addrToBalance, + ) + if err != nil { + return err + } + + accounts = append(accounts, results...) + } + + return nil + }) + if err != nil { + return nil, err + } + + // Get the sync tip to ensure atomicity. + syncBlock := w.addrStore.SyncedTo() + + return &AccountsResult{ + Accounts: accounts, + CurrentBlockHash: syncBlock.Hash, + CurrentBlockHeight: syncBlock.Height, + }, nil +} + +// createResultForScope creates a slice of AccountResult for a given key +// scope. This function will iterate through all accounts in the scope, and +// calculate the balance for each of them. +// +// The addrToBalance map is a map of all addresses to their balances. This is +// used to efficiently calculate the balance for each account. +// +// The function returns a slice of AccountResult, and an error if any occurred. +func createResultForScope(scopeMgr *waddrmgr.ScopedKeyManager, + addrmgrNs walletdb.ReadBucket, + addrToBalance map[string]btcutil.Amount) ([]AccountResult, error) { + + var accounts []AccountResult + + // We'll start by getting the last account in the scope, so we can + // iterate from the first account to the last. + lastAccount, err := scopeMgr.LastAccount(addrmgrNs) + if err != nil { + return accounts, err + } + + for acctNum := uint32(0); acctNum <= lastAccount; acctNum++ { + // Get the account's properties. + props, err := scopeMgr.AccountProperties( + addrmgrNs, acctNum, + ) + if err != nil { + return accounts, err + } + + acctResult := AccountResult{ + AccountProperties: *props, + } + + // Iterate through all addresses of the account + // and sum up their balances. + err = scopeMgr.ForEachAccountAddress( + addrmgrNs, acctNum, + func(addr waddrmgr.ManagedAddress) error { + acctResult.TotalBalance += + addrToBalance[addr.Address().String()] + return nil + }, + ) + if err != nil { + return accounts, err + } + + accounts = append(accounts, acctResult) + } + + return accounts, nil +} diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index d25d5cd819..096a18b9a6 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -8,6 +8,8 @@ import ( "context" "testing" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/stretchr/testify/require" ) @@ -56,3 +58,155 @@ func TestNewAccount(t *testing.T) { "locked", ) } + +// TestListAccounts tests that the ListAccounts method works as expected. +func TestListAccounts(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll start by creating a new account under the BIP0084 scope. + scope := waddrmgr.KeyScopeBIP0084 + _, err := w.NewAccount(context.Background(), scope, testAccountName) + require.NoError(t, err, "unable to create new account") + + // Now, we'll list all accounts and check that we have the default + // account and the new account. + accounts, err := w.ListAccounts(context.Background()) + require.NoError(t, err, "unable to list accounts") + + // We should have five accounts, the four default accounts and the new + // account. + require.Len(t, accounts.Accounts, 5, "expected five accounts") + + // The first account should be the default account. + require.Equal( + t, "default", accounts.Accounts[0].AccountName, + "expected default account", + ) + require.Equal( + t, uint32(0), accounts.Accounts[0].AccountNumber, + "expected default account number", + ) + require.Equal( + t, btcutil.Amount(0), accounts.Accounts[0].TotalBalance, + "expected zero balance for default account", + ) + + // The new account should also be present. + var found bool + for _, acc := range accounts.Accounts { + if acc.AccountName == testAccountName { + found = true + require.Equal( + t, uint32(1), acc.AccountNumber, + "expected new account number", + ) + require.Equal( + t, btcutil.Amount(0), acc.TotalBalance, + "expected zero balance for new account", + ) + } + } + require.True(t, found, "expected to find new account") +} + +// getOrCreateAddress is a helper function to get an address for a given +// account, or create a new one if none exist. +func getOrCreateAddress(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, + account uint32) btcutil.Address { + + var addr btcutil.Address + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return err + } + + var addrs []waddrmgr.ManagedAddress + err = scopedMgr.ForEachAccountAddress( + addrmgrNs, account, + func(addr waddrmgr.ManagedAddress) error { + addrs = append(addrs, addr) + return nil + }, + ) + if err != nil || len(addrs) == 0 { + derivedAddrs, err := scopedMgr.NextExternalAddresses( + addrmgrNs, account, 1, + ) + if err != nil { + return err + } + addr = derivedAddrs[0].Address() + } else { + addr = addrs[0].Address() + } + + return nil + }) + require.NoError(t, err) + + return addr +} + +// TestCreateResultForScope tests that the createResultForScope helper function +// works as expected. +func TestCreateResultForScope(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll create a new account under the BIP0084 scope to have a + // predictable state with more than just the default account. + scope := waddrmgr.KeyScopeBIP0084 + acc1Name := "test account" + _, err := w.NewAccount(context.Background(), scope, acc1Name) + require.NoError(t, err) + + // We'll now create a balance map for a few addresses. We need to + // derive some addresses first to have something to work with. + addrToBalance := make(map[string]btcutil.Amount) + defaultAddr := getOrCreateAddress(t, w, scope, 0) + acc1Addr := getOrCreateAddress(t, w, scope, 1) + + // Assign some balances to our derived addresses. + addrToBalance[defaultAddr.String()] = 100 + addrToBalance[acc1Addr.String()] = 200 + + // Now, we'll call createResultForScope within a read transaction and + // verify the results. + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) + require.NoError(t, err) + + // Call the function under test. + results, err := createResultForScope( + scopedMgr, addrmgrNs, addrToBalance, + ) + require.NoError(t, err) + + // The BIP0084 scope should have two accounts: the default one + // and the one we just created. + require.Len(t, results, 2, "expected two accounts for scope") + + // Check the default account's result. + require.Equal(t, "default", results[0].AccountName) + require.Equal(t, uint32(0), results[0].AccountNumber) + require.Equal(t, btcutil.Amount(100), results[0].TotalBalance) + + // Check the new account's result. + require.Equal(t, acc1Name, results[1].AccountName) + require.Equal(t, uint32(1), results[1].AccountNumber) + require.Equal(t, btcutil.Amount(200), results[1].TotalBalance) + + return nil + }) + require.NoError(t, err) +} \ No newline at end of file diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 2984c9531e..596210fc24 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -2,6 +2,9 @@ package wallet import ( + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" ) @@ -47,3 +50,76 @@ func (w *Wallet) NextAccount(scope waddrmgr.KeyScope, name string) (uint32, erro return account, err } + +// Accounts returns the current names, numbers, and total balances of all +// accounts in the wallet restricted to a particular key scope. The current +// chain tip is included in the result for atomicity reasons. +// +// TODO(jrick): Is the chain tip really needed, since only the total balances +// are included? +func (w *Wallet) Accounts(scope waddrmgr.KeyScope) (*AccountsResult, error) { + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + var ( + accounts []AccountResult + syncBlockHash *chainhash.Hash + syncBlockHeight int32 + ) + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + syncBlock := w.addrStore.SyncedTo() + syncBlockHash = &syncBlock.Hash + syncBlockHeight = syncBlock.Height + unspent, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + err = manager.ForEachAccount(addrmgrNs, func(acct uint32) error { + props, err := manager.AccountProperties(addrmgrNs, acct) + if err != nil { + return err + } + accounts = append(accounts, AccountResult{ + AccountProperties: *props, + // TotalBalance set below + }) + + return nil + }) + if err != nil { + return err + } + m := make(map[uint32]*btcutil.Amount) + for i := range accounts { + a := &accounts[i] + m[a.AccountNumber] = &a.TotalBalance + } + for i := range unspent { + output := unspent[i] + var outputAcct uint32 + _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, w.chainParams) + if err == nil && len(addrs) > 0 { + _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) + } + if err == nil { + amt, ok := m[outputAcct] + if ok { + *amt += output.Amount + } + } + } + return nil + + }) + return &AccountsResult{ + + Accounts: accounts, + CurrentBlockHash: *syncBlockHash, + CurrentBlockHeight: syncBlockHeight, + }, err +} diff --git a/wallet/wallet.go b/wallet/wallet.go index 57abf35376..99c3ee2633 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -2602,90 +2602,6 @@ func (w *Wallet) GetTransaction(txHash chainhash.Hash) (*GetTransactionResult, return &res, nil } -// AccountResult is a single account result for the AccountsResult type. -type AccountResult struct { - waddrmgr.AccountProperties - TotalBalance btcutil.Amount -} - -// AccountsResult is the result of the wallet's Accounts method. See that -// method for more details. -type AccountsResult struct { - Accounts []AccountResult - CurrentBlockHash *chainhash.Hash - CurrentBlockHeight int32 -} - -// Accounts returns the current names, numbers, and total balances of all -// accounts in the wallet restricted to a particular key scope. The current -// chain tip is included in the result for atomicity reasons. -// -// TODO(jrick): Is the chain tip really needed, since only the total balances -// are included? -func (w *Wallet) Accounts(scope waddrmgr.KeyScope) (*AccountsResult, error) { - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, err - } - - var ( - accounts []AccountResult - syncBlockHash *chainhash.Hash - syncBlockHeight int32 - ) - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - syncBlock := w.addrStore.SyncedTo() - syncBlockHash = &syncBlock.Hash - syncBlockHeight = syncBlock.Height - unspent, err := w.txStore.UnspentOutputs(txmgrNs) - if err != nil { - return err - } - err = manager.ForEachAccount(addrmgrNs, func(acct uint32) error { - props, err := manager.AccountProperties(addrmgrNs, acct) - if err != nil { - return err - } - accounts = append(accounts, AccountResult{ - AccountProperties: *props, - // TotalBalance set below - }) - return nil - }) - if err != nil { - return err - } - m := make(map[uint32]*btcutil.Amount) - for i := range accounts { - a := &accounts[i] - m[a.AccountNumber] = &a.TotalBalance - } - for i := range unspent { - output := unspent[i] - var outputAcct uint32 - _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, w.chainParams) - if err == nil && len(addrs) > 0 { - _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) - } - if err == nil { - amt, ok := m[outputAcct] - if ok { - *amt += output.Amount - } - } - } - return nil - }) - return &AccountsResult{ - Accounts: accounts, - CurrentBlockHash: syncBlockHash, - CurrentBlockHeight: syncBlockHeight, - }, err -} - // AccountBalanceResult is a single result for the Wallet.AccountBalances method. type AccountBalanceResult struct { AccountNumber uint32 From 3da857b658971d9d4abaf913ed207d63f4fb0c4e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 00:45:03 +0800 Subject: [PATCH 008/691] wallet: implement `ListAccountsByScope` --- wallet/account_manager.go | 102 +++++++++++++++++++++++++++++++++ wallet/account_manager_test.go | 60 ++++++++++++++++++- 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index defec0d7d2..d391c233d8 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -305,3 +305,105 @@ func createResultForScope(scopeMgr *waddrmgr.ScopedKeyManager, return accounts, nil } + +// ListAccountsByScope returns a list of all accounts for a given scope for the +// wallet, including accounts with a zero balance. The current chain tip is +// included in the result for reference. +// +// The implementation is optimized for performance by first building a map of +// balances for all addresses with unspent outputs and then iterating through +// balances for all addresses with unspent outputs and then iterating +// +// through all known accounts to tally up their final balances. +// UTXOs and A is the number of addresses in the wallet. This is a significant +// UTXOs and A is the number of addresses in the wallet. This is a +// significant improvement over a naive implementation that would have a +// +// complexity of O(U * A), e.g., the old `Accounts` method. +// A potential future improvement would be to index UTXOs by account directly +// in the database, which would reduce the complexity to O(A). +func (w *Wallet) ListAccountsByScope(_ context.Context, + scope waddrmgr.KeyScope) (*AccountsResult, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + var accounts []AccountResult + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // First, we'll create a map of all addresses to their balances + // by iterating through all unspent outputs. This is more + // efficient than iterating through all addresses and looking + // up their balances individually. + addrToBalance := make(map[string]btcutil.Amount) + utxos, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + for _, utxo := range utxos { + // Decode the script to find the address. + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + utxo.PkScript, w.chainParams, + ) + if err != nil { + // We'll log the error and skip this UTXO. This + // is to prevent a single un-parsable UTXO from + // failing the entire call, which would be a + // poor user experience. + log.Errorf("Unable to parse pkscript for UTXO "+ + "%v: %v", utxo.OutPoint, err) + continue + } + + // This can happen for scripts that don't resolve to a + // standard address, such as OP_RETURN outputs. We can + // safely ignore these. + if len(addrs) == 0 { + continue + } + + // TODO(yy): For bare multisig outputs, + // ExtractPkScriptAddrs can return more than one + // address. Currently, we are only considering the + // first address, which could lead to incorrect balance + // attribution. However, since bare multisig is rare + // and modern wallets almost exclusively use P2SH or + // P2WSH for multisig (which are correctly handled as a + // single address), this is a low-priority issue. + // + // Add the UTXO's value to the address's balance. + addrStr := addrs[0].String() + addrToBalance[addrStr] += utxo.Amount + } + + // Now, we'll iterate through all the accounts and calculate + // their balances by summing up the balances of all their + // addresses. + results, err := createResultForScope( + manager, addrmgrNs, addrToBalance, + ) + if err != nil { + return err + } + + accounts = append(accounts, results...) + + return nil + }) + if err != nil { + return nil, err + } + + // Get the sync tip to ensure atomicity. + syncBlock := w.addrStore.SyncedTo() + + return &AccountsResult{ + Accounts: accounts, + CurrentBlockHash: syncBlock.Hash, + CurrentBlockHeight: syncBlock.Height, + }, nil +} diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 096a18b9a6..a878d1770c 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -209,4 +209,62 @@ func TestCreateResultForScope(t *testing.T) { return nil }) require.NoError(t, err) -} \ No newline at end of file +} + +// TestListAccountsByScope tests that the ListAccountsByScope method works as +// expected. +func TestListAccountsByScope(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll create two new accounts, one under the BIP0084 scope and one + // under the BIP0049 scope. + scopeBIP84 := waddrmgr.KeyScopeBIP0084 + accBIP84Name := "test bip84" + _, err := w.NewAccount(context.Background(), scopeBIP84, accBIP84Name) + require.NoError(t, err) + + scopeBIP49 := waddrmgr.KeyScopeBIP0049Plus + accBIP49Name := "test bip49" + _, err = w.NewAccount(context.Background(), scopeBIP49, accBIP49Name) + require.NoError(t, err) + + // Now, we'll list the accounts for the BIP0084 scope and check that + // we only get the default account for that scope and the new account we + // created. + accountsBIP84, err := w.ListAccountsByScope( + context.Background(), scopeBIP84, + ) + require.NoError(t, err) + + // We should have two accounts, the default account and the new account. + require.Len(t, accountsBIP84.Accounts, 2) + + // The first account should be the default account. + require.Equal(t, "default", accountsBIP84.Accounts[0].AccountName) + require.Equal(t, uint32(0), accountsBIP84.Accounts[0].AccountNumber) + + // The second account should be the new account. + require.Equal(t, accBIP84Name, accountsBIP84.Accounts[1].AccountName) + require.Equal(t, uint32(1), accountsBIP84.Accounts[1].AccountNumber) + + // Now, we'll do the same for the BIP0049 scope. + accountsBIP49, err := w.ListAccountsByScope( + context.Background(), scopeBIP49, + ) + require.NoError(t, err) + + // We should have two accounts, the default account and the new account. + require.Len(t, accountsBIP49.Accounts, 2) + + // The first account should be the default account. + require.Equal(t, "default", accountsBIP49.Accounts[0].AccountName) + require.Equal(t, uint32(0), accountsBIP49.Accounts[0].AccountNumber) + + // The second account should be the new account. + require.Equal(t, accBIP49Name, accountsBIP49.Accounts[1].AccountName) + require.Equal(t, uint32(1), accountsBIP49.Accounts[1].AccountNumber) +} From cfba5d634193b29a0abb54b0c3880522d5a76411 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 00:48:26 +0800 Subject: [PATCH 009/691] wallet: implement `ListAccountsByName` --- wallet/account_manager.go | 104 +++++++++++++++++++++++++++++++++ wallet/account_manager_test.go | 58 ++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index d391c233d8..e40ed5e889 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -407,3 +407,107 @@ func (w *Wallet) ListAccountsByScope(_ context.Context, CurrentBlockHeight: syncBlock.Height, }, nil } + +// ListAccountsByName returns a list of all accounts for a given account name +// for the wallet, including accounts with a zero balance. The current chain +// tip is included in the result for reference. +// +// The implementation is optimized for performance by first building a map of +// balances for all addresses with unspent outputs and then iterating +// through all known accounts to tally up their final balances. +// +// The time complexity of this method is O(U + A), where U is the number of +// UTXOs and A is the number of addresses in the wallet. This is a +// significant improvement over a naive implementation that would have a +// complexity of O(U * A), e.g., the old `Accounts` method. +// +// A potential future improvement would be to index UTXOs by account directly +// in the database, which would reduce the complexity to O(A). +func (w *Wallet) ListAccountsByName(_ context.Context, + name string) (*AccountsResult, error) { + + managers := w.addrStore.ActiveScopedKeyManagers() + + var accounts []AccountResult + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // First, we'll create a map of all addresses to their balances + // by iterating through all unspent outputs. This is more + // efficient than iterating through all addresses and looking + // up their balances individually. + addrToBalance := make(map[string]btcutil.Amount) + utxos, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + for _, utxo := range utxos { + // Decode the script to find the address. + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + utxo.PkScript, w.chainParams, + ) + if err != nil { + // We'll log the error and skip this UTXO. This + // is to prevent a single un-parsable UTXO from + // failing the entire call, which would be a + // poor user experience. + log.Errorf("Unable to parse pkscript for UTXO "+ + "%v: %v", utxo.OutPoint, err) + continue + } + + // This can happen for scripts that don't resolve to a + // standard address, such as OP_RETURN outputs. We can + // safely ignore these. + if len(addrs) == 0 { + continue + } + + // TODO(yy): For bare multisig outputs, + // ExtractPkScriptAddrs can return more than one + // address. Currently, we are only considering the + // first address, which could lead to incorrect balance + // attribution. However, since bare multisig is rare + // and modern wallets almost exclusively use P2SH or + // P2WSH for multisig (which are correctly handled as a + // single address), this is a low-priority issue. + // + // Add the UTXO's value to the address's balance. + addrStr := addrs[0].String() + addrToBalance[addrStr] += utxo.Amount + } + + // Now, we'll iterate through all the accounts and calculate + // their balances by summing up the balances of all their + // addresses. + for _, scopeMgr := range managers { + results, err := createResultForScope( + scopeMgr, addrmgrNs, addrToBalance, + ) + if err != nil { + return err + } + + for _, acc := range results { + if acc.AccountName == name { + accounts = append(accounts, acc) + } + } + } + + return nil + }) + if err != nil { + return nil, err + } + + // Get the sync tip to ensure atomicity. + syncBlock := w.addrStore.SyncedTo() + + return &AccountsResult{ + Accounts: accounts, + CurrentBlockHash: syncBlock.Hash, + CurrentBlockHeight: syncBlock.Height, + }, nil +} \ No newline at end of file diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index a878d1770c..2b4a12bcc8 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -268,3 +268,61 @@ func TestListAccountsByScope(t *testing.T) { require.Equal(t, accBIP49Name, accountsBIP49.Accounts[1].AccountName) require.Equal(t, uint32(1), accountsBIP49.Accounts[1].AccountNumber) } + +// TestListAccountsByName tests that the ListAccountsByName method works as +// expected. +func TestListAccountsByName(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll create two new accounts, one under the BIP0084 scope and one + // under the BIP0049 scope. + scopeBIP84 := waddrmgr.KeyScopeBIP0084 + accBIP84Name := "test bip84" + _, err := w.NewAccount(context.Background(), scopeBIP84, accBIP84Name) + require.NoError(t, err) + + scopeBIP49 := waddrmgr.KeyScopeBIP0049Plus + accBIP49Name := "test bip49" + _, err = w.NewAccount(context.Background(), scopeBIP49, accBIP49Name) + require.NoError(t, err) + + // Now, we'll list the accounts for the BIP0084 scope and check that + // we only get the default account for that scope and the new account we + // created. + accountsBIP84, err := w.ListAccountsByName( + context.Background(), accBIP84Name, + ) + require.NoError(t, err) + + // We should have one account. + require.Len(t, accountsBIP84.Accounts, 1) + + // The first account should be the new account. + require.Equal(t, accBIP84Name, accountsBIP84.Accounts[0].AccountName) + require.Equal(t, uint32(1), accountsBIP84.Accounts[0].AccountNumber) + + // Now, we'll do the same for the BIP0049 scope. + accountsBIP49, err := w.ListAccountsByName( + context.Background(), accBIP49Name, + ) + require.NoError(t, err) + + // We should have one account. + require.Len(t, accountsBIP49.Accounts, 1) + + // The first account should be the new account. + require.Equal(t, accBIP49Name, accountsBIP49.Accounts[0].AccountName) + require.Equal(t, uint32(1), accountsBIP49.Accounts[0].AccountNumber) + + // We should get an empty result if we query for a non-existent + // account. + accounts, err := w.ListAccountsByName( + context.Background(), "non-existent", + ) + require.NoError(t, err) + require.Len(t, accounts.Accounts, 0) +} \ No newline at end of file From af12337cba76099ccd2b0834910d31504139013c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 01:07:59 +0800 Subject: [PATCH 010/691] wallet: implement `GetAccount` --- wallet/account_manager.go | 80 +++++++++++++++++++++++++++++++++- wallet/account_manager_test.go | 42 ++++++++++++++++-- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index e40ed5e889..4e7f8e1ac6 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -510,4 +510,82 @@ func (w *Wallet) ListAccountsByName(_ context.Context, CurrentBlockHash: syncBlock.Hash, CurrentBlockHeight: syncBlock.Height, }, nil -} \ No newline at end of file +} + +// GetAccount returns the account for a given account name and scope. +// +// The time complexity of this method is O(U + A_a), where U is the number of +// UTXOs in the wallet and A_a is the number of addresses in the account. +func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, + name string) (*AccountResult, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + var account *AccountResult + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // First, we'll look up the account number for the given name. + accNum, err := manager.LookupAccount(addrmgrNs, name) + if err != nil { + return err + } + + // Get the account's properties. + props, err := manager.AccountProperties(addrmgrNs, accNum) + if err != nil { + return err + } + + account = &AccountResult{ + AccountProperties: *props, + } + + // Now, we'll create a set of all addresses in the account. + // This is more efficient than iterating through all UTXOs and + // looking up the account for each one. + accountAddrs := make(map[string]struct{}) + err = manager.ForEachAccountAddress( + addrmgrNs, accNum, + func(addr waddrmgr.ManagedAddress) error { + addrStr := addr.Address().String() + accountAddrs[addrStr] = struct{}{} + return nil + }, + ) + if err != nil { + return err + } + + // Finally, we'll iterate through all unspent outputs and sum + // up the balances for the addresses in our set. + utxos, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + for _, utxo := range utxos { + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + utxo.PkScript, w.chainParams, + ) + if err != nil || len(addrs) == 0 { + continue + } + + addrStr := addrs[0].String() + if _, ok := accountAddrs[addrStr]; ok { + account.TotalBalance += utxo.Amount + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return account, nil +} diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 2b4a12bcc8..ea7e240a46 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -9,8 +9,8 @@ import ( "testing" "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" "github.com/stretchr/testify/require" ) @@ -324,5 +324,41 @@ func TestListAccountsByName(t *testing.T) { context.Background(), "non-existent", ) require.NoError(t, err) - require.Len(t, accounts.Accounts, 0) -} \ No newline at end of file + require.Empty(t, accounts.Accounts) +} + +// TestGetAccount tests that the GetAccount method works as expected. +func TestGetAccount(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll create a new account under the BIP0084 scope. + scope := waddrmgr.KeyScopeBIP0084 + _, err := w.NewAccount(context.Background(), scope, testAccountName) + require.NoError(t, err) + + // We should be able to get the new account. + account, err := w.GetAccount(context.Background(), scope, testAccountName) + require.NoError(t, err) + require.Equal(t, testAccountName, account.AccountName) + require.Equal(t, uint32(1), account.AccountNumber) + require.Equal(t, btcutil.Amount(0), account.TotalBalance) + + // We should also be able to get the default account. + account, err = w.GetAccount(context.Background(), scope, "default") + require.NoError(t, err) + require.Equal(t, "default", account.AccountName) + require.Equal(t, uint32(0), account.AccountNumber) + require.Equal(t, btcutil.Amount(0), account.TotalBalance) + + // We should get an error when trying to get a non-existent account. + _, err = w.GetAccount(context.Background(), scope, "non-existent") + require.Error(t, err) + require.True( + t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), + "expected ErrAccountNotFound", + ) +} From 32183b899177e53ce0ded3050ef28cde9cf728fd Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 16:11:49 +0800 Subject: [PATCH 011/691] rpc+wallet: deprecated `RenameAccount` We need to free the name `RenameAccount` so AccountManager can use it and wallet can implement it. --- rpc/legacyrpc/methods.go | 2 +- rpc/rpcserver/server.go | 2 +- wallet/deprecated.go | 27 +++++++++++++++++++++++++++ wallet/interface.go | 9 ++++++--- wallet/wallet.go | 23 ----------------------- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/rpc/legacyrpc/methods.go b/rpc/legacyrpc/methods.go index 461b09230f..a41fdd66d1 100644 --- a/rpc/legacyrpc/methods.go +++ b/rpc/legacyrpc/methods.go @@ -670,7 +670,7 @@ func renameAccount(icmd interface{}, w *wallet.Wallet) (interface{}, error) { if err != nil { return nil, err } - return nil, w.RenameAccount(waddrmgr.KeyScopeBIP0044, account, cmd.NewAccount) + return nil, w.RenameAccountDeprecated(waddrmgr.KeyScopeBIP0044, account, cmd.NewAccount) } // getNewAddress handles a getnewaddress request by returning a new diff --git a/rpc/rpcserver/server.go b/rpc/rpcserver/server.go index 3e10db11a9..e6e3eafffa 100644 --- a/rpc/rpcserver/server.go +++ b/rpc/rpcserver/server.go @@ -188,7 +188,7 @@ func (s *walletServer) Accounts(ctx context.Context, req *pb.AccountsRequest) ( func (s *walletServer) RenameAccount(ctx context.Context, req *pb.RenameAccountRequest) ( *pb.RenameAccountResponse, error) { - err := s.wallet.RenameAccount(waddrmgr.KeyScopeBIP0044, req.AccountNumber, req.NewName) + err := s.wallet.RenameAccountDeprecated(waddrmgr.KeyScopeBIP0044, req.AccountNumber, req.NewName) if err != nil { return nil, translateError(err) } diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 596210fc24..1fb535943d 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -123,3 +123,30 @@ func (w *Wallet) Accounts(scope waddrmgr.KeyScope) (*AccountsResult, error) { CurrentBlockHeight: syncBlockHeight, }, err } + +// RenameAccountDeprecated sets the name for an account number to newName. +func (w *Wallet) RenameAccountDeprecated(scope waddrmgr.KeyScope, + account uint32, newName string) error { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return err + } + + var props *waddrmgr.AccountProperties + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + err := manager.RenameAccount(addrmgrNs, account, newName) + if err != nil { + return err + } + props, err = manager.AccountProperties(addrmgrNs, account) + + return err + }) + if err == nil { + w.NtfnServer.notifyAccountProperties(props) + } + + return err +} diff --git a/wallet/interface.go b/wallet/interface.go index 1b1a85646f..30ce9a266f 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -112,9 +112,12 @@ type Interface interface { AccountManagedAddresses(scope waddrmgr.KeyScope, accountNum uint32) ([]waddrmgr.ManagedAddress, error) - // RenameAccount renames an existing account. It is an error to rename - // a reserved account or to choose a name that is already in use. - RenameAccount(scope waddrmgr.KeyScope, account uint32, + // RenameAccountDeprecated renames an existing account. It is an error + // to rename a reserved account or to choose a name that is already in + // use. + // + // Deprecated: Use AccountManager.RenameAccount instead. + RenameAccountDeprecated(scope waddrmgr.KeyScope, account uint32, newName string) error // ImportAccount imports an account backed by an extended public key. diff --git a/wallet/wallet.go b/wallet/wallet.go index 99c3ee2633..4bdc76954d 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -2032,29 +2032,6 @@ func (w *Wallet) LookupAccount(name string) (waddrmgr.KeyScope, uint32, error) { return keyScope, account, err } -// RenameAccount sets the name for an account number to newName. -func (w *Wallet) RenameAccount(scope waddrmgr.KeyScope, account uint32, newName string) error { - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return err - } - - var props *waddrmgr.AccountProperties - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - err := manager.RenameAccount(addrmgrNs, account, newName) - if err != nil { - return err - } - props, err = manager.AccountProperties(addrmgrNs, account) - return err - }) - if err == nil { - w.NtfnServer.notifyAccountProperties(props) - } - return err -} - // CreditCategory describes the type of wallet transaction output. The category // of "sent transactions" (debits) is always "send", and is not expressed by // this type. From 373146733331ce7b1e237884571e8d49330b2bc8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 16:52:54 +0800 Subject: [PATCH 012/691] wallet: implement `RenameAccount` --- wallet/account_manager.go | 25 +++++++++++++++++ wallet/account_manager_test.go | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 4e7f8e1ac6..08762618ff 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -589,3 +589,28 @@ func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, return account, nil } + +// RenameAccount renames an existing account. +// +// The time complexity of this method is dominated by the database lookup for +// the old account name. +func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, + oldName, newName string) error { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return err + } + + return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + // First, we'll look up the account number for the given name. + accNum, err := manager.LookupAccount(addrmgrNs, oldName) + if err != nil { + return err + } + + return manager.RenameAccount(addrmgrNs, accNum, newName) + }) +} diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index ea7e240a46..08218ab9bb 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -362,3 +362,54 @@ func TestGetAccount(t *testing.T) { "expected ErrAccountNotFound", ) } + +// TestRenameAccount tests that the RenameAccount method works as expected. +func TestRenameAccount(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll create a new account under the BIP0084 scope. + scope := waddrmgr.KeyScopeBIP0084 + oldName := "old name" + newName := "new name" + _, err := w.NewAccount(context.Background(), scope, oldName) + require.NoError(t, err) + + // We should be able to rename the account. + err = w.RenameAccount(context.Background(), scope, oldName, newName) + require.NoError(t, err) + + // We should be able to get the account by its new name. + account, err := w.GetAccount(context.Background(), scope, newName) + require.NoError(t, err) + require.Equal(t, newName, account.AccountName) + + // We should not be able to get the account by its old name. + _, err = w.GetAccount(context.Background(), scope, oldName) + require.Error(t, err) + require.True( + t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), + "expected ErrAccountNotFound", + ) + + // We should not be able to rename an account to an existing name. + err = w.RenameAccount(context.Background(), scope, newName, "default") + require.Error(t, err) + require.True( + t, waddrmgr.IsError(err, waddrmgr.ErrDuplicateAccount), + "expected ErrDuplicateAccount", + ) + + // We should not be able to rename a non-existent account. + err = w.RenameAccount( + context.Background(), scope, "non-existent", "new name 2", + ) + require.Error(t, err) + require.True( + t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), + "expected ErrAccountNotFound", + ) +} From 5376b0c57e0ffbd8daf904b56eeaec66fd21e232 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 21:21:35 +0800 Subject: [PATCH 013/691] wallet: implement `Balance` --- wallet/account_manager.go | 79 ++++++++++++++++++++++++++++-- wallet/account_manager_test.go | 89 ++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 08762618ff..3ab6410aba 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -313,13 +313,13 @@ func createResultForScope(scopeMgr *waddrmgr.ScopedKeyManager, // The implementation is optimized for performance by first building a map of // balances for all addresses with unspent outputs and then iterating through // balances for all addresses with unspent outputs and then iterating -// // through all known accounts to tally up their final balances. -// UTXOs and A is the number of addresses in the wallet. This is a significant +// +// The time complexity of this method is O(U + A), where U is the number of // UTXOs and A is the number of addresses in the wallet. This is a // significant improvement over a naive implementation that would have a -// // complexity of O(U * A), e.g., the old `Accounts` method. +// // A potential future improvement would be to index UTXOs by account directly // in the database, which would reduce the complexity to O(A). func (w *Wallet) ListAccountsByScope(_ context.Context, @@ -614,3 +614,76 @@ func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, return manager.RenameAccount(addrmgrNs, accNum, newName) }) } + +// Balance returns the balance for a specific account. +// +// The time complexity of this method is O(U + A_a), where U is the +// number of UTXOs in the wallet and A_a is the number of addresses in the +// account. +func (w *Wallet) Balance(_ context.Context, conf int32, + scope waddrmgr.KeyScope, accountName string) (btcutil.Amount, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return 0, err + } + + var balance btcutil.Amount + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // First, we'll look up the account number for the given name. + accNum, err := manager.LookupAccount(addrmgrNs, accountName) + if err != nil { + return err + } + + // Now, we'll create a set of all addresses in the account. + accountAddrs := make(map[string]struct{}) + err = manager.ForEachAccountAddress( + addrmgrNs, accNum, + func(addr waddrmgr.ManagedAddress) error { + addrStr := addr.Address().String() + accountAddrs[addrStr] = struct{}{} + return nil + }, + ) + if err != nil { + return err + } + + // Finally, we'll iterate through all unspent outputs and sum + // up the balances for the addresses in our set. + syncBlock := w.addrStore.SyncedTo() + utxos, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + for _, utxo := range utxos { + if !confirmed(conf, utxo.Height, syncBlock.Height) { + continue + } + + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + utxo.PkScript, w.chainParams, + ) + if err != nil || len(addrs) == 0 { + continue + } + + addrStr := addrs[0].String() + if _, ok := accountAddrs[addrStr]; ok { + balance += utxo.Amount + } + } + + return nil + }) + if err != nil { + return 0, err + } + + return balance, nil +} + diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 08218ab9bb..441f01da57 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -7,10 +7,14 @@ package wallet import ( "context" "testing" + "time" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/require" ) @@ -413,3 +417,88 @@ func TestRenameAccount(t *testing.T) { "expected ErrAccountNotFound", ) } + +// TestBalance tests that the Balance method works as expected. +func TestBalance(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll create a new account under the BIP0084 scope. + scope := waddrmgr.KeyScopeBIP0084 + _, err := w.NewAccount(context.Background(), scope, testAccountName) + require.NoError(t, err) + + // The balance should be zero initially. + balance, err := w.Balance( + context.Background(), 1, scope, testAccountName, + ) + require.NoError(t, err) + require.Equal(t, btcutil.Amount(0), balance) + + // Now, we'll add a UTXO to the account. + addr, err := w.NewAddress(1, scope) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + rec, err := wtxmgr.NewTxRecordFromMsgTx(&wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, + }, + TxOut: []*wire.TxOut{ + { + Value: 100, + PkScript: pkScript, + }, + }, + }, time.Now()) + require.NoError(t, err) + + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + err := w.txStore.InsertTx(ns, rec, &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: 1, + }, + }) + if err != nil { + return err + } + + return w.txStore.AddCredit(ns, rec, &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: 1, + }, + }, 0, false) + }) + require.NoError(t, err) + + // Now, we'll update the wallet's sync state. + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + return w.addrStore.SetSyncedTo(addrmgrNs, &waddrmgr.BlockStamp{ + Height: 1, + }) + }) + require.NoError(t, err) + + // The balance should now be 100. + balance, err = w.Balance( + context.Background(), 1, scope, testAccountName, + ) + require.NoError(t, err) + require.Equal(t, btcutil.Amount(100), balance) + + // We should get an error when trying to get the balance of a + // non-existent account. + _, err = w.Balance(context.Background(), 1, scope, "non-existent") + require.Error(t, err) + require.True( + t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), + "expected ErrAccountNotFound", + ) +} + + From ad23144b0b9be4538c37aef73d5ad8fe9b27cc43 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 21:28:08 +0800 Subject: [PATCH 014/691] wallet: deprecate `ImportAccount` So we can use this name on the `AccountManager`. --- wallet/import.go | 4 ++-- wallet/import_test.go | 10 +++++----- wallet/interface.go | 6 ++++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/wallet/import.go b/wallet/import.go index 5aa338240a..99e7108b30 100644 --- a/wallet/import.go +++ b/wallet/import.go @@ -189,7 +189,7 @@ func (w *Wallet) validateExtendedPubKey(pubKey *hdkeychain.ExtendedKey, return nil } -// ImportAccount imports an account backed by an account extended public key. +// ImportAccountDeprecated imports an account backed by an account extended public key. // The master key fingerprint denotes the fingerprint of the root key // corresponding to the account public key (also known as the key with // derivation path m/). This may be required by some hardware wallets for proper @@ -208,7 +208,7 @@ func (w *Wallet) validateExtendedPubKey(pubKey *hdkeychain.ExtendedKey, // distinction between the traditional BIP-0049 address schema (nested witness // pubkeys everywhere) and our own BIP-0049Plus address schema (nested // externally, witness internally). -func (w *Wallet) ImportAccount(name string, accountPubKey *hdkeychain.ExtendedKey, +func (w *Wallet) ImportAccountDeprecated(name string, accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, addrType *waddrmgr.AddressType) ( *waddrmgr.AccountProperties, error) { diff --git a/wallet/import_test.go b/wallet/import_test.go index 646af70326..4af5869d1d 100644 --- a/wallet/import_test.go +++ b/wallet/import_test.go @@ -125,9 +125,9 @@ var ( }} ) -// TestImportAccount tests that extended public keys can successfully be -// imported into both watch only and normal wallets. -func TestImportAccount(t *testing.T) { +// TestImportAccountDeprecated tests that extended public keys can successfully +// be imported into both watch only and normal wallets. +func TestImportAccountDeprecated(t *testing.T) { t.Parallel() for _, tc := range testCases { @@ -192,13 +192,13 @@ func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, require.Equal(t, tc.expectedChangeAddr, intAddrs[0].Address().String()) // Import the extended public keys into new accounts. - acct1, err := w.ImportAccount( + acct1, err := w.ImportAccountDeprecated( name+"1", acct1Pub, root.ParentFingerprint(), &tc.addrType, ) require.NoError(t, err) require.Equal(t, tc.expectedScope, acct1.KeyScope) - acct2, err := w.ImportAccount( + acct2, err := w.ImportAccountDeprecated( name+"2", acct2Pub, root.ParentFingerprint(), &tc.addrType, ) require.NoError(t, err) diff --git a/wallet/interface.go b/wallet/interface.go index 30ce9a266f..ef21d5896d 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -120,9 +120,11 @@ type Interface interface { RenameAccountDeprecated(scope waddrmgr.KeyScope, account uint32, newName string) error - // ImportAccount imports an account backed by an extended public key. + // ImportAccountDeprecated imports an account backed by an extended public key. // This creates a watch-only account. - ImportAccount(name string, accountPubKey *hdkeychain.ExtendedKey, + // + // Deprecated: Use AccountManager.ImportAccount instead. + ImportAccountDeprecated(name string, accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, addrType *waddrmgr.AddressType, ) (*waddrmgr.AccountProperties, error) From 591b2f23995d923f888590430bae8285a9f817b9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 21:54:00 +0800 Subject: [PATCH 015/691] waddrmgr+wallet: move `ValidateAccountName` outside of the db tx --- waddrmgr/scoped_manager.go | 5 ----- wallet/account_manager.go | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 0d9edea449..4a612f5562 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -1827,11 +1827,6 @@ func (s *ScopedKeyManager) RenameAccount(ns walletdb.ReadWriteBucket, return managerError(ErrDuplicateAccount, str, err) } - // Validate account name - if err := ValidateAccountName(name); err != nil { - return err - } - rowInterface, err := fetchAccountInfo(ns, &s.scope, account) if err != nil { return err diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 3ab6410aba..e678f90ee5 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -602,6 +602,11 @@ func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, return err } + // Validate new account name. + if err := waddrmgr.ValidateAccountName(newName); err != nil { + return err + } + return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) From dd42fada1b9d2daf6dff5789dd2de348ddea2082 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 21:54:48 +0800 Subject: [PATCH 016/691] wallet: implement `ImportAccount` --- wallet/account_manager.go | 29 +++++++++++++++++ wallet/account_manager_test.go | 57 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index e678f90ee5..57bde19736 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -94,6 +94,9 @@ type AccountManager interface { dryRun bool) (*waddrmgr.AccountProperties, error) } +// A compile time check to ensure that Wallet implements the interface. +var _ AccountManager = (*Wallet)(nil) + // NewAccount creates the next account and returns its account number. The name // must be unique under the kep scope. In order to support automatic seed // restoring, new accounts may not be created when all of the previous 100 @@ -692,3 +695,29 @@ func (w *Wallet) Balance(_ context.Context, conf int32, return balance, nil } +// ImportAccount imports an account from an extended public or private key. +// +// The time complexity of this method is dominated by the database lookup +// to ensure the account name is unique within the scope. +func (w *Wallet) ImportAccount(_ context.Context, + name string, accountKey *hdkeychain.ExtendedKey, + masterKeyFingerprint uint32, addrType waddrmgr.AddressType, + dryRun bool) (*waddrmgr.AccountProperties, error) { + + var ( + props *waddrmgr.AccountProperties + err error + ) + + if dryRun { + props, _, _, err = w.ImportAccountDryRun( + name, accountKey, masterKeyFingerprint, &addrType, 0, + ) + } else { + props, err = w.ImportAccountDeprecated( + name, accountKey, masterKeyFingerprint, &addrType, + ) + } + + return props, err +} diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 441f01da57..4d262e4ed4 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -501,4 +502,60 @@ func TestBalance(t *testing.T) { ) } +// TestImportAccount tests that the ImportAccount works as expected. +func TestImportAccount(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll start by creating a new account under the BIP0084 scope. + scope := waddrmgr.KeyScopeBIP0084 + addrType := waddrmgr.WitnessPubKey + masterPriv := "tprv8ZgxMBicQKsPeWwrFuNjEGTTDSY4mRLwd2KDJAPGa1AY" + + "quw38bZqNMSuB3V1Va3hqJBo9Pt8Sx7kBQer5cNMrb8SYquoWPt9" + + "Y3BZdhdtUcw" + root, err := hdkeychain.NewKeyFromString(masterPriv) + require.NoError(t, err) + acctPubKey := deriveAcctPubKey(t, root, scope, hardenedKey(0)) + + // We should be able to import the account. + props, err := w.ImportAccount( + context.Background(), testAccountName, acctPubKey, + root.ParentFingerprint(), addrType, false, + ) + require.NoError(t, err) + require.Equal(t, testAccountName, props.AccountName) + + // We should be able to get the account by its name. + _, err = w.GetAccount(context.Background(), scope, testAccountName) + require.NoError(t, err) + + // We should not be able to import an account with the same name. + _, err = w.ImportAccount( + context.Background(), testAccountName, acctPubKey, + root.ParentFingerprint(), addrType, false, + ) + require.Error(t, err) + require.True( + t, waddrmgr.IsError(err, waddrmgr.ErrDuplicateAccount), + "expected ErrDuplicateAccount", + ) + // We should be able to do a dry run of the import. + dryRunName := "dry run" + _, err = w.ImportAccount( + context.Background(), dryRunName, acctPubKey, + root.ParentFingerprint(), addrType, true, + ) + require.NoError(t, err) + + // The account should not have been imported. + _, err = w.GetAccount(context.Background(), scope, dryRunName) + require.Error(t, err) + require.True( + t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), + "expected ErrAccountNotFound", + ) +} From 21d7963c56140bed8d59d7ae02b9ac2f389ea773 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 Aug 2025 22:56:41 +0800 Subject: [PATCH 017/691] rpc+wallet: fix linter errors --- rpc/legacyrpc/methods.go | 1 + rpc/rpcserver/server.go | 2 +- wallet/chainntfns.go | 1 + wallet/deprecated.go | 4 ++-- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/rpc/legacyrpc/methods.go b/rpc/legacyrpc/methods.go index a41fdd66d1..2f3440d654 100644 --- a/rpc/legacyrpc/methods.go +++ b/rpc/legacyrpc/methods.go @@ -670,6 +670,7 @@ func renameAccount(icmd interface{}, w *wallet.Wallet) (interface{}, error) { if err != nil { return nil, err } + return nil, w.RenameAccountDeprecated(waddrmgr.KeyScopeBIP0044, account, cmd.NewAccount) } diff --git a/rpc/rpcserver/server.go b/rpc/rpcserver/server.go index e6e3eafffa..3700f246f1 100644 --- a/rpc/rpcserver/server.go +++ b/rpc/rpcserver/server.go @@ -188,7 +188,7 @@ func (s *walletServer) Accounts(ctx context.Context, req *pb.AccountsRequest) ( func (s *walletServer) RenameAccount(ctx context.Context, req *pb.RenameAccountRequest) ( *pb.RenameAccountResponse, error) { - err := s.wallet.RenameAccountDeprecated(waddrmgr.KeyScopeBIP0044, req.AccountNumber, req.NewName) + err := s.wallet.RenameAccountDeprecated(waddrmgr.KeyScopeBIP0044, req.GetAccountNumber(), req.GetNewName()) if err != nil { return nil, translateError(err) } diff --git a/wallet/chainntfns.go b/wallet/chainntfns.go index 478b11023a..8a1e6b1b03 100644 --- a/wallet/chainntfns.go +++ b/wallet/chainntfns.go @@ -273,6 +273,7 @@ func (w *Wallet) disconnectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) // Disconnect the removed block and all blocks after it if we know about // the disconnected block. Otherwise, the block is in the future. + //nolint:nestif if b.Height <= w.addrStore.SyncedTo().Height { hash, err := w.addrStore.BlockHash(addrmgrNs, b.Height) if err != nil { diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 1fb535943d..e94d36b2ee 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -113,11 +113,11 @@ func (w *Wallet) Accounts(scope waddrmgr.KeyScope) (*AccountsResult, error) { } } } - return nil + return nil }) - return &AccountsResult{ + return &AccountsResult{ Accounts: accounts, CurrentBlockHash: *syncBlockHash, CurrentBlockHeight: syncBlockHeight, From 37169c58a19bc6f1bcf2eff2704b45105076d3ba Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 17 Sep 2025 19:13:59 +0800 Subject: [PATCH 018/691] wallet: add method `fetchAccountBalances` --- wallet/account_manager.go | 248 +++++++++++++++++++++ wallet/account_manager_test.go | 378 +++++++++++++++++++++++++++++++++ 2 files changed, 626 insertions(+) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 57bde19736..2b902c02e3 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcwallet/waddrmgr" @@ -721,3 +722,250 @@ func (w *Wallet) ImportAccount(_ context.Context, return props, err } + +// extractAddrFromPKScript extracts an address from a public key script. If the +// script cannot be parsed or does not contain any addresses, it returns nil. +func extractAddrFromPKScript(pkScript []byte, + chainParams *chaincfg.Params) btcutil.Address { + + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + pkScript, chainParams, + ) + if err != nil { + // We'll log the error and return nil to prevent a single + // un-parsable script from failing a larger operation. + log.Errorf("Unable to parse pkscript: %v", err) + return nil + } + + // This can happen for scripts that don't resolve to a standard address, + // such as OP_RETURN outputs. We can safely ignore these. + if len(addrs) == 0 { + return nil + } + + // TODO(yy): For bare multisig outputs, ExtractPkScriptAddrs can + // return more than one address. Currently, we are only considering + // the first address, which could lead to incorrect balance + // attribution. However, since bare multisig is rare and modern + // wallets almost exclusively use P2SH or P2WSH for multisig (which + // are correctly handled as a single address), this is a low-priority + // issue. + return addrs[0] +} + +// accountFilter is an internal struct used to specify filters for account +// balance queries. +type accountFilter struct { + scope *waddrmgr.KeyScope + name string +} + +// filterOption is a functional option type for account filtering. +type filterOption func(*accountFilter) + +// withScope is a filter option to limit account queries to a specific key +// scope. +func withScope(scope waddrmgr.KeyScope) filterOption { + return func(f *accountFilter) { + f.scope = &scope + } +} + +// withName is a filter option to limit account queries to a specific account +// name. +func withName(name string) filterOption { + return func(f *accountFilter) { + f.name = name + } +} + +// scopedBalances is a type alias for a map of key scopes to a map of account +// numbers to their total balance. +type scopedBalances map[waddrmgr.KeyScope]map[uint32]btcutil.Amount + +// fetchAccountBalances creates a nested map of account balances, keyed by scope +// and account number. +// +// This function is a core component of the wallet's balance calculation +// logic. It is designed to be efficient, especially for wallets with a large +// number of addresses. +// +// Design Rationale: +// The primary performance consideration is the trade-off between iterating +// through all Unspent Transaction Outputs (UTXOs) versus iterating through all +// derived addresses for all accounts. A mature wallet may have millions of used +// addresses, but a relatively small set of UTXOs. Therefore, this function is +// optimized for this common case. +// +// The algorithm works as follows: +// 1. Make a single pass over all UTXOs in the wallet. +// 2. For each UTXO, look up the address and its corresponding account. +// 3. Aggregate the UTXO values into a map of balances per account. +// +// This approach avoids iterating through a potentially massive number of +// addresses and performing a database lookup for each one to check for a +// balance. Instead, it starts with the smaller, known set of UTXOs and works +// backward to the accounts. +// +// Filters: +// The function's behavior can be customized by passing one or more filterOption +// functions. This allows the caller to restrict the balance calculation to: +// - A specific account name (withName). +// - A specific key scope (withScope). +// +// If no filters are provided, balances for all accounts across all scopes will +// be fetched. +// +// TODO(yy): With a future SQL backend, this entire function could be +// replaced by a single, more efficient query. By adding `account_id` and +// `key_scope` columns to the `outputs` table, we could perform a direct +// aggregation in the database, like: +// `SELECT key_scope, account_id, SUM(value) FROM outputs +// WHERE is_spent = false GROUP BY key_scope, account_id;`. +// This would be significantly faster as the database is optimized for +// these types of operations. +func (w *Wallet) fetchAccountBalances(tx walletdb.ReadTx, + opts ...filterOption) (scopedBalances, error) { + + // Apply the filter options. + filter := &accountFilter{} + for _, opt := range opts { + opt(filter) + } + + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // First, fetch all unspent outputs. + utxos, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return nil, err + } + + // Now, create the nested map to hold the balances. + scopedBalances := make(scopedBalances) + + // Iterate through all UTXOs, mapping them back to their owning account + // to aggregate the total balance for each. + for _, utxo := range utxos { + addr := extractAddrFromPKScript(utxo.PkScript, w.chainParams) + if addr == nil { + // This can happen for non-standard script types. + continue + } + + // Now that we have the address, we'll look up which account it + // belongs to. + scope, accNum, err := w.addrStore.AddrAccount(addrmgrNs, addr) + if err != nil { + log.Errorf("Unable to query account using address %v: "+ + "%v", addr, err) + continue + } + + // If any filters were provided, we'll apply them now to + // determine if this UTXO should be included in the final + // balance calculation. + if filter.name != "" { + accName, err := scope.AccountName(addrmgrNs, accNum) + if err != nil { + // This indicates a potential database + // corruption, as we should always be able to + // look up the name for a valid account number. + log.Errorf("Unable to query account name for "+ + "account %d: %v", accNum, err) + continue + } + if accName != filter.name { + continue + } + } + if filter.scope != nil { + if scope.Scope() != *filter.scope { + continue + } + } + + // We'll use a nested map to store balances. If this is the + // first time we've seen this key scope, we'll need to + // initialize the inner map. + keyScope := scope.Scope() + if _, ok := scopedBalances[keyScope]; !ok { + scopedBalances[keyScope] = make( + map[uint32]btcutil.Amount, + ) + } + + // Finally, we'll add the UTXO's value to the account's + // balance. + scopedBalances[keyScope][accNum] += utxo.Amount + } + + return scopedBalances, nil +} + +// listAccountsWithBalances is a helper function that iterates through all +// accounts in a given scope, fetches their properties, and combines them with +// the provided account balances. +// +// This function is designed to be called after the balances for all relevant +// accounts have already been computed by a function like fetchAccountBalances. +// It serves as the final step to assemble the complete AccountResult objects. +// +// The function operates as follows: +// 1. It determines the last account number for the given scope. +// 2. It iterates from account number 0 to the last account. +// 3. For each account, it retrieves its properties from the database. +// 4. It looks up the pre-calculated balance from the accountBalances map. +// 5. It constructs an AccountResult object with both the properties and the +// balance. +// +// This separation of concerns (first calculating all balances, then assembling +// the results) is a key part of the overall optimization strategy. It ensures +// that we can efficiently gather all necessary data in distinct phases, rather +// than mixing database reads and balance calculations in a less efficient +// manner. +func listAccountsWithBalances(scopeMgr *waddrmgr.ScopedKeyManager, + addrmgrNs walletdb.ReadBucket, + accountBalances map[uint32]btcutil.Amount) ([]AccountResult, error) { + + var accounts []AccountResult + lastAccount, err := scopeMgr.LastAccount(addrmgrNs) + if err != nil { + // If the scope has no accounts, we can just return an empty + // slice. This is a normal condition and not an error. + if waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound) { + return nil, nil + } + return nil, err + } + + // Iterate through all accounts from 0 to the last known account + // number for this scope. + for accNum := uint32(0); accNum <= lastAccount; accNum++ { + // For each account number, we'll fetch its full set of + // properties from the database. + props, err := scopeMgr.AccountProperties(addrmgrNs, accNum) + if err != nil { + return nil, err + } + + // We'll look up the pre-calculated balance for this account. + // If the account has no UTXOs, it won't be in the map, so + // we'll default to a balance of 0. + balance, ok := accountBalances[accNum] + if !ok { + balance = 0 + } + + // Finally, we'll construct the full account result and add it + // to our list. + accounts = append(accounts, AccountResult{ + AccountProperties: *props, + TotalBalance: balance, + }) + } + + return accounts, nil +} diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 4d262e4ed4..7353969edf 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -559,3 +560,380 @@ func TestImportAccount(t *testing.T) { "expected ErrAccountNotFound", ) } + +// TestExtractAddrFromPKScript tests that the extractAddrFromPKScript +// helper function works as expected. +func TestExtractAddrFromPKScript(t *testing.T) { + t.Parallel() + + w, cleanup := testWallet(t) + defer cleanup() + + w.chainParams = &chaincfg.MainNetParams + + p2pkhAddr, err := btcutil.DecodeAddress( + "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhem", w.chainParams, + ) + require.NoError(t, err) + + p2shAddr, err := btcutil.DecodeAddress( + "347N1Thc213QqfYCz3PZkjoJpNv5b14kBd", w.chainParams, + ) + require.NoError(t, err) + + p2wpkhAddr, err := btcutil.DecodeAddress( + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", w.chainParams, + ) + require.NoError(t, err) + + testCases := []struct { + name string + script func() []byte + addr string + }{ + { + name: "p2pkh", + script: func() []byte { + pkScript, err := txscript.PayToAddrScript( + p2pkhAddr, + ) + require.NoError(t, err) + + return pkScript + }, + addr: p2pkhAddr.String(), + }, + { + name: "p2sh", + script: func() []byte { + pkScript, err := txscript.PayToAddrScript( + p2shAddr, + ) + require.NoError(t, err) + + return pkScript + }, + addr: p2shAddr.String(), + }, + { + name: "p2wpkh", + script: func() []byte { + pkScript, err := txscript.PayToAddrScript( + p2wpkhAddr, + ) + require.NoError(t, err) + + return pkScript + }, + addr: p2wpkhAddr.String(), + }, + { + name: "op_return", + script: func() []byte { + pkScript, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_RETURN). + AddData([]byte("test")). + Script() + require.NoError(t, err) + + return pkScript + }, + addr: "", + }, + { + name: "invalid script", + script: func() []byte { return []byte("invalid") }, + addr: "", + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + addr := extractAddrFromPKScript( + testCase.script(), w.chainParams, + ) + if addr == nil { + require.Equal(t, testCase.addr, "") + } else { + require.Equal(t, testCase.addr, addr.String()) + } + }) + } +} + +// addTestUTXOForBalance is a helper function to add a UTXO to the wallet. +func addTestUTXOForBalance(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, + account uint32, amount btcutil.Amount) { + + addr, err := w.NewAddress(account, scope) + require.NoError(t, err) + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + rec, err := wtxmgr.NewTxRecordFromMsgTx(&wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, + }, + TxOut: []*wire.TxOut{ + { + Value: int64(amount), + PkScript: pkScript, + }, + }, + }, time.Now()) + require.NoError(t, err) + + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + block := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 1}, + } + err := w.txStore.InsertTx(ns, rec, block) + if err != nil { + return err + } + return w.txStore.AddCredit(ns, rec, block, 0, false) + }) + require.NoError(t, err) +} + +// TestFetchAccountBalances tests that the fetchAccountBalances helper function +// works as expected. +func TestFetchAccountBalances(t *testing.T) { + t.Parallel() + + // setupTestCase is a helper closure to set up a test case. + // + // This function initializes a new test wallet and populates it with a + // predictable set of accounts and unspent transaction outputs (UTXOs). + // This provides a consistent starting state for each test case, + // ensuring that tests are isolated and repeatable. + // + // The initial state of the wallet is as follows: + // + // Accounts: + // - The default account (number 0) is implicitly created for each key + // scope. + // - "acc1-bip84": A named account (number 1) under the BIP0084 key + // scope. + // - "acc1-bip49": A named account (number 1) under the BIP0049 key + // scope. + // + // UTXOs: + // - 100 satoshis are credited to the default account in the BIP0084 + // scope. + // - 200 satoshis are credited to the "acc1-bip84" account. + // - 300 satoshis are credited to the "acc1-bip49" account. + // + // Sync State: + // - The wallet is marked as synced up to block height 1. This is + // necessary for the UTXOs to be considered confirmed and spendable. + // + // The function returns the fully initialized wallet and a cleanup + // function that should be deferred by the caller to ensure that the + // wallet's resources are properly released after the test completes. + setupTestCase := func(t *testing.T) (*Wallet, func()) { + w, cleanup := testWallet(t) + ctx := context.Background() + + // Create accounts. + _, err := w.NewAccount( + ctx, waddrmgr.KeyScopeBIP0084, "acc1-bip84", + ) + require.NoError(t, err) + _, err = w.NewAccount( + ctx, waddrmgr.KeyScopeBIP0049Plus, "acc1-bip49", + ) + require.NoError(t, err) + + // Add UTXOs. + addTestUTXOForBalance(t, w, waddrmgr.KeyScopeBIP0084, 0, 100) + addTestUTXOForBalance(t, w, waddrmgr.KeyScopeBIP0084, 1, 200) + addTestUTXOForBalance(t, w, waddrmgr.KeyScopeBIP0049Plus, 1, 300) + + // Update sync state. + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + bs := &waddrmgr.BlockStamp{Height: 1} + return w.addrStore.SetSyncedTo(addrmgrNs, bs) + }) + require.NoError(t, err) + + return w, cleanup + } + + testCases := []struct { + name string + setup func(t *testing.T, w *Wallet) + filters []filterOption + expectedBalances scopedBalances + }{ + { + name: "no filters", + filters: nil, + expectedBalances: scopedBalances{ + waddrmgr.KeyScopeBIP0084: {0: 100, 1: 200}, + waddrmgr.KeyScopeBIP0049Plus: {1: 300}, + }, + }, + { + name: "filter by name", + filters: []filterOption{withName("acc1-bip84")}, + expectedBalances: scopedBalances{ + waddrmgr.KeyScopeBIP0084: {1: 200}, + }, + }, + { + name: "filter by scope", + filters: []filterOption{ + withScope(waddrmgr.KeyScopeBIP0084), + }, + expectedBalances: scopedBalances{ + waddrmgr.KeyScopeBIP0084: {0: 100, 1: 200}, + }, + }, + { + name: "filter by name and scope", + filters: []filterOption{ + withName("acc1-bip84"), + withScope(waddrmgr.KeyScopeBIP0084), + }, + expectedBalances: scopedBalances{ + waddrmgr.KeyScopeBIP0084: {1: 200}, + }, + }, + { + name: "filter by non-existent name", + filters: []filterOption{withName("non-existent")}, + expectedBalances: scopedBalances{}, + }, + { + name: "account with no balance", + setup: func(t *testing.T, w *Wallet) { + _, err := w.NewAccount( + context.Background(), + waddrmgr.KeyScopeBIP0084, "no-balance", + ) + require.NoError(t, err) + }, + filters: nil, + expectedBalances: scopedBalances{ + waddrmgr.KeyScopeBIP0084: {0: 100, 1: 200}, + waddrmgr.KeyScopeBIP0049Plus: {1: 300}, + }, + }, + { + name: "filter by name that exists in multiple scopes", + setup: func(t *testing.T, w *Wallet) { + _, err := w.NewAccount( + context.Background(), + waddrmgr.KeyScopeBIP0049Plus, "acc1-bip84", + ) + require.NoError(t, err) + addTestUTXOForBalance( + t, w, waddrmgr.KeyScopeBIP0049Plus, 2, 400, + ) + }, + filters: []filterOption{withName("acc1-bip84")}, + expectedBalances: scopedBalances{ + waddrmgr.KeyScopeBIP0084: {1: 200}, + waddrmgr.KeyScopeBIP0049Plus: {2: 400}, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + w, cleanup := setupTestCase(t) + defer cleanup() + + if tc.setup != nil { + tc.setup(t, w) + } + + var balances scopedBalances + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + var err error + balances, err = w.fetchAccountBalances( + tx, tc.filters..., + ) + return err + }) + + require.NoError(t, err) + require.Equal(t, tc.expectedBalances, balances) + }) + } +} + +// TestListAccountsWithBalances tests that the listAccountsWithBalances helper +// function works as expected. +func TestListAccountsWithBalances(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // We'll create two new accounts under the BIP0084 scope to have a + // predictable state. + scope := waddrmgr.KeyScopeBIP0084 + acc1Name := "test account" + _, err := w.NewAccount(context.Background(), scope, acc1Name) + require.NoError(t, err) + + acc2Name := "no balance account" + _, err = w.NewAccount(context.Background(), scope, acc2Name) + require.NoError(t, err) + + // We'll now create a balance map for some of the accounts. We + // intentionally leave out the second new account to test the zero + // balance case. + balances := map[uint32]btcutil.Amount{ + 0: 100, // Default account + 1: 200, // "test account" + } + + // Now, we'll call listAccountsWithBalances within a read transaction + // and verify the results. + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) + require.NoError(t, err) + + // Call the function under test. + results, err := listAccountsWithBalances( + scopedMgr, addrmgrNs, balances, + ) + require.NoError(t, err) + + // The BIP0084 scope should have three accounts: the default + // one and the two we just created. + require.Len(t, results, 3, "expected three accounts for scope") + + // Check the default account's result. + require.Equal(t, "default", results[0].AccountName) + require.Equal(t, uint32(0), results[0].AccountNumber) + require.Equal(t, btcutil.Amount(100), results[0].TotalBalance) + + // Check the first new account's result. + require.Equal(t, acc1Name, results[1].AccountName) + require.Equal(t, uint32(1), results[1].AccountNumber) + require.Equal(t, btcutil.Amount(200), results[1].TotalBalance) + + // Check the second new account's result (zero balance). + require.Equal(t, acc2Name, results[2].AccountName) + require.Equal(t, uint32(2), results[2].AccountNumber) + require.Equal(t, btcutil.Amount(0), results[2].TotalBalance) + + return nil + }) + require.NoError(t, err) +} From bdf4590b8a3a6c433be1ed9b4b6ed2d988cc9b71 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 17 Sep 2025 21:45:30 +0800 Subject: [PATCH 019/691] wallet: use the new method `fetchAccountBalances` --- wallet/account_manager.go | 501 +++++++++++---------------------- wallet/account_manager_test.go | 98 ------- 2 files changed, 170 insertions(+), 429 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 2b902c02e3..53b9a23490 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -157,87 +157,55 @@ type AccountsResult struct { CurrentBlockHeight int32 } -// ListAccounts returns a list of all accounts for the wallet, including -// accounts with a zero balance. The current chain tip is included in the -// result for reference. -// -// The implementation is optimized for performance by first building a map of -// balances for all addresses with unspent outputs and then iterating through -// all known accounts to tally up their final balances. -// -// The time complexity of this method is O(U + A), where U is the number of -// UTXOs and A is the number of addresses in the wallet. This is a significant -// improvement over a naive implementation that would have a complexity of -// O(U * A), e.g., the old `Accounts` method. -// -// A potential future improvement would be to index UTXOs by account directly -// in the database, which would reduce the complexity to O(A). +// ListAccounts returns a list of all accounts for the wallet, including those +// with a zero balance. The current chain tip is included in the result for +// reference. +// +// The function calculates balances by first creating a comprehensive map of +// balances for all accounts that currently own UTXOs. It then iterates through +// all known accounts across all key scopes, retrieving their properties and +// assigning the pre-calculated balance. Accounts with no UTXOs will correctly +// be assigned a zero balance. +// +// The time complexity of this method is O(U*logA + A), where U is the number of +// UTXOs and A is the number of accounts in the wallet. A potential future +// improvement is to make the balance calculation optional. func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { - managers := w.addrStore.ActiveScopedKeyManagers() + // Get all active key scope managers to iterate through all available + // scopes. + scopes := w.addrStore.ActiveScopedKeyManagers() var accounts []AccountResult err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - // First, we'll create a map of all addresses to their balances - // by iterating through all unspent outputs. This is more - // efficient than iterating through all addresses and looking - // up their balances individually. - addrToBalance := make(map[string]btcutil.Amount) - utxos, err := w.txStore.UnspentOutputs(txmgrNs) + // First, build a map of balances for all accounts that own at + // least one UTXO. This is done by iterating through the UTXO + // set and aggregating the values by account. + scopedBalances, err := w.fetchAccountBalances(tx) if err != nil { return err } - for _, utxo := range utxos { - // Decode the script to find the address. - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - utxo.PkScript, w.chainParams, - ) - if err != nil { - // We'll log the error and skip this UTXO. This - // is to prevent a single un-parsable UTXO from - // failing the entire call, which would be a - // poor user experience. - log.Errorf("Unable to parse pkscript for UTXO "+ - "%v: %v", utxo.OutPoint, err) - continue - } - - // This can happen for scripts that don't resolve to a - // standard address, such as OP_RETURN outputs. We can - // safely ignore these. - if len(addrs) == 0 { - continue - } - - // TODO(yy): For bare multisig outputs, - // ExtractPkScriptAddrs can return more than one - // address. Currently, we are only considering the - // first address, which could lead to incorrect balance - // attribution. However, since bare multisig is rare - // and modern wallets almost exclusively use P2SH or - // P2WSH for multisig (which are correctly handled as a - // single address), this is a low-priority issue. - // - // Add the UTXO's value to the address's balance. - addrStr := addrs[0].String() - addrToBalance[addrStr] += utxo.Amount - } + // Now, iterate through each key scope to assemble the final list + // of accounts with their properties and balances. + for _, scopeMgr := range scopes { + scope := scopeMgr.Scope() + accountBalances := scopedBalances[scope] - // Now, we'll iterate through all the accounts and calculate - // their balances by summing up the balances of all their - // addresses. - for _, scopeMgr := range managers { - results, err := createResultForScope( - scopeMgr, addrmgrNs, addrToBalance, + // For the current scope, retrieve the properties for + // each account and combine them with the + // pre-calculated balances. + scopedAccounts, err := listAccountsWithBalances( + scopeMgr, addrmgrNs, accountBalances, ) if err != nil { return err } - accounts = append(accounts, results...) + // Append the accounts from this scope to the final + // list. + accounts = append(accounts, scopedAccounts...) } return nil @@ -246,9 +214,9 @@ func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { return nil, err } - // Get the sync tip to ensure atomicity. + // Include the wallet's current sync state in the result to provide a + // point-in-time reference for the balances. syncBlock := w.addrStore.SyncedTo() - return &AccountsResult{ Accounts: accounts, CurrentBlockHash: syncBlock.Hash, @@ -256,79 +224,22 @@ func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { }, nil } -// createResultForScope creates a slice of AccountResult for a given key -// scope. This function will iterate through all accounts in the scope, and -// calculate the balance for each of them. +// ListAccountsByScope returns a list of all accounts for a given key scope, +// including those with a zero balance. The current chain tip is included for +// reference. // -// The addrToBalance map is a map of all addresses to their balances. This is -// used to efficiently calculate the balance for each account. +// The function first fetches the balances for all accounts within the given +// scope by iterating over the wallet's UTXO set. It then retrieves the +// properties for each account in that scope and combines them with the +// pre-calculated balances. // -// The function returns a slice of AccountResult, and an error if any occurred. -func createResultForScope(scopeMgr *waddrmgr.ScopedKeyManager, - addrmgrNs walletdb.ReadBucket, - addrToBalance map[string]btcutil.Amount) ([]AccountResult, error) { - - var accounts []AccountResult - - // We'll start by getting the last account in the scope, so we can - // iterate from the first account to the last. - lastAccount, err := scopeMgr.LastAccount(addrmgrNs) - if err != nil { - return accounts, err - } - - for acctNum := uint32(0); acctNum <= lastAccount; acctNum++ { - // Get the account's properties. - props, err := scopeMgr.AccountProperties( - addrmgrNs, acctNum, - ) - if err != nil { - return accounts, err - } - - acctResult := AccountResult{ - AccountProperties: *props, - } - - // Iterate through all addresses of the account - // and sum up their balances. - err = scopeMgr.ForEachAccountAddress( - addrmgrNs, acctNum, - func(addr waddrmgr.ManagedAddress) error { - acctResult.TotalBalance += - addrToBalance[addr.Address().String()] - return nil - }, - ) - if err != nil { - return accounts, err - } - - accounts = append(accounts, acctResult) - } - - return accounts, nil -} - -// ListAccountsByScope returns a list of all accounts for a given scope for the -// wallet, including accounts with a zero balance. The current chain tip is -// included in the result for reference. -// -// The implementation is optimized for performance by first building a map of -// balances for all addresses with unspent outputs and then iterating through -// balances for all addresses with unspent outputs and then iterating -// through all known accounts to tally up their final balances. -// -// The time complexity of this method is O(U + A), where U is the number of -// UTXOs and A is the number of addresses in the wallet. This is a -// significant improvement over a naive implementation that would have a -// complexity of O(U * A), e.g., the old `Accounts` method. -// -// A potential future improvement would be to index UTXOs by account directly -// in the database, which would reduce the complexity to O(A). +// The time complexity of this method is O(U*logA + A), where U is the number of +// UTXOs and A is the number of accounts in the wallet. func (w *Wallet) ListAccountsByScope(_ context.Context, scope waddrmgr.KeyScope) (*AccountsResult, error) { + // First, we'll fetch the scoped key manager for the given scope. This + // manager will be used to list the accounts. manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err @@ -337,74 +248,29 @@ func (w *Wallet) ListAccountsByScope(_ context.Context, var accounts []AccountResult err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - // First, we'll create a map of all addresses to their balances - // by iterating through all unspent outputs. This is more - // efficient than iterating through all addresses and looking - // up their balances individually. - addrToBalance := make(map[string]btcutil.Amount) - utxos, err := w.txStore.UnspentOutputs(txmgrNs) - if err != nil { - return err - } - for _, utxo := range utxos { - // Decode the script to find the address. - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - utxo.PkScript, w.chainParams, - ) - if err != nil { - // We'll log the error and skip this UTXO. This - // is to prevent a single un-parsable UTXO from - // failing the entire call, which would be a - // poor user experience. - log.Errorf("Unable to parse pkscript for UTXO "+ - "%v: %v", utxo.OutPoint, err) - continue - } - - // This can happen for scripts that don't resolve to a - // standard address, such as OP_RETURN outputs. We can - // safely ignore these. - if len(addrs) == 0 { - continue - } - - // TODO(yy): For bare multisig outputs, - // ExtractPkScriptAddrs can return more than one - // address. Currently, we are only considering the - // first address, which could lead to incorrect balance - // attribution. However, since bare multisig is rare - // and modern wallets almost exclusively use P2SH or - // P2WSH for multisig (which are correctly handled as a - // single address), this is a low-priority issue. - // - // Add the UTXO's value to the address's balance. - addrStr := addrs[0].String() - addrToBalance[addrStr] += utxo.Amount - } - // Now, we'll iterate through all the accounts and calculate - // their balances by summing up the balances of all their - // addresses. - results, err := createResultForScope( - manager, addrmgrNs, addrToBalance, + // Calculate the balances for all accounts, but only for the + // key scope we are interested in. + scopedBalances, err := w.fetchAccountBalances( + tx, withScope(scope), ) if err != nil { return err } - accounts = append(accounts, results...) - - return nil + // Now, retrieve the properties for each account in the scope + // and combine them with the balances calculated above. + accounts, err = listAccountsWithBalances( + manager, addrmgrNs, scopedBalances[scope], + ) + return err }) if err != nil { return nil, err } - // Get the sync tip to ensure atomicity. + // Include the wallet's current sync state in the result. syncBlock := w.addrStore.SyncedTo() - return &AccountsResult{ Accounts: accounts, CurrentBlockHash: syncBlock.Hash, @@ -412,92 +278,70 @@ func (w *Wallet) ListAccountsByScope(_ context.Context, }, nil } -// ListAccountsByName returns a list of all accounts for a given account name -// for the wallet, including accounts with a zero balance. The current chain -// tip is included in the result for reference. +// ListAccountsByName returns a list of all accounts that have a given name. +// Since account names are only unique within a key scope, this can return +// multiple accounts. The current chain tip is included for reference. // -// The implementation is optimized for performance by first building a map of -// balances for all addresses with unspent outputs and then iterating -// through all known accounts to tally up their final balances. +// The function first calculates the balances for any accounts matching the +// given name, and then iterates through all key scopes to find and retrieve +// the properties of those accounts. // -// The time complexity of this method is O(U + A), where U is the number of -// UTXOs and A is the number of addresses in the wallet. This is a -// significant improvement over a naive implementation that would have a -// complexity of O(U * A), e.g., the old `Accounts` method. -// -// A potential future improvement would be to index UTXOs by account directly -// in the database, which would reduce the complexity to O(A). +// The time complexity of this method is O(U*logA), where U is the number of +// UTXOs and logA is the cost of an account lookup. func (w *Wallet) ListAccountsByName(_ context.Context, name string) (*AccountsResult, error) { - managers := w.addrStore.ActiveScopedKeyManagers() + scopes := w.addrStore.ActiveScopedKeyManagers() var accounts []AccountResult err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - // First, we'll create a map of all addresses to their balances - // by iterating through all unspent outputs. This is more - // efficient than iterating through all addresses and looking - // up their balances individually. - addrToBalance := make(map[string]btcutil.Amount) - utxos, err := w.txStore.UnspentOutputs(txmgrNs) + // First, calculate the balances for any accounts that match the + // given name. This is efficient as it iterates over the UTXO + // set, not accounts. + scopedBalances, err := w.fetchAccountBalances(tx, withName(name)) if err != nil { return err } - for _, utxo := range utxos { - // Decode the script to find the address. - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - utxo.PkScript, w.chainParams, - ) + + // Now, find all accounts that match the given name by iterating + // through all active scopes. + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + for _, scopeMgr := range scopes { + // Look up the account number for the given name in the + // current scope. + accNum, err := scopeMgr.LookupAccount(addrmgrNs, name) if err != nil { - // We'll log the error and skip this UTXO. This - // is to prevent a single un-parsable UTXO from - // failing the entire call, which would be a - // poor user experience. - log.Errorf("Unable to parse pkscript for UTXO "+ - "%v: %v", utxo.OutPoint, err) - continue - } + // If the account is not found in this scope, + // we can safely continue to the next one. + if waddrmgr.IsError( + err, waddrmgr.ErrAccountNotFound) { - // This can happen for scripts that don't resolve to a - // standard address, such as OP_RETURN outputs. We can - // safely ignore these. - if len(addrs) == 0 { - continue - } + continue + } - // TODO(yy): For bare multisig outputs, - // ExtractPkScriptAddrs can return more than one - // address. Currently, we are only considering the - // first address, which could lead to incorrect balance - // attribution. However, since bare multisig is rare - // and modern wallets almost exclusively use P2SH or - // P2WSH for multisig (which are correctly handled as a - // single address), this is a low-priority issue. - // - // Add the UTXO's value to the address's balance. - addrStr := addrs[0].String() - addrToBalance[addrStr] += utxo.Amount - } + return err + } - // Now, we'll iterate through all the accounts and calculate - // their balances by summing up the balances of all their - // addresses. - for _, scopeMgr := range managers { - results, err := createResultForScope( - scopeMgr, addrmgrNs, addrToBalance, + // Retrieve the account's properties. + props, err := scopeMgr.AccountProperties( + addrmgrNs, accNum, ) if err != nil { return err } - for _, acc := range results { - if acc.AccountName == name { - accounts = append(accounts, acc) - } + // Get the pre-calculated balance for this account. If + // the account has no balance, it will be zero. + var balance btcutil.Amount + balances, ok := scopedBalances[scopeMgr.Scope()] + if ok { + balance = balances[accNum] } + + accounts = append(accounts, AccountResult{ + AccountProperties: *props, + TotalBalance: balance, + }) } return nil @@ -506,9 +350,7 @@ func (w *Wallet) ListAccountsByName(_ context.Context, return nil, err } - // Get the sync tip to ensure atomicity. syncBlock := w.addrStore.SyncedTo() - return &AccountsResult{ Accounts: accounts, CurrentBlockHash: syncBlock.Hash, @@ -516,10 +358,13 @@ func (w *Wallet) ListAccountsByName(_ context.Context, }, nil } -// GetAccount returns the account for a given account name and scope. +// GetAccount returns the account for a given account name and key scope. +// +// The function first looks up the account's properties and then calculates its +// balance by iterating over the wallet's UTXO set. // -// The time complexity of this method is O(U + A_a), where U is the number of -// UTXOs in the wallet and A_a is the number of addresses in the account. +// The time complexity of this method is O(U*logA), where U is the number of +// UTXOs and logA is the cost of an account lookup. func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, name string) (*AccountResult, error) { @@ -531,15 +376,15 @@ func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, var account *AccountResult err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - // First, we'll look up the account number for the given name. + // Look up the account number for the given name and scope. This + // is a fast, indexed lookup. accNum, err := manager.LookupAccount(addrmgrNs, name) if err != nil { return err } - // Get the account's properties. + // Retrieve the static properties for the account. props, err := manager.AccountProperties(addrmgrNs, accNum) if err != nil { return err @@ -549,39 +394,20 @@ func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, AccountProperties: *props, } - // Now, we'll create a set of all addresses in the account. - // This is more efficient than iterating through all UTXOs and - // looking up the account for each one. - accountAddrs := make(map[string]struct{}) - err = manager.ForEachAccountAddress( - addrmgrNs, accNum, - func(addr waddrmgr.ManagedAddress) error { - addrStr := addr.Address().String() - accountAddrs[addrStr] = struct{}{} - return nil - }, + // Calculate the balance for this specific account by fetching + // the UTXOs that belong to it. + scopedBalances, err := w.fetchAccountBalances( + tx, withScope(scope), withName(name), ) if err != nil { return err } - // Finally, we'll iterate through all unspent outputs and sum - // up the balances for the addresses in our set. - utxos, err := w.txStore.UnspentOutputs(txmgrNs) - if err != nil { - return err - } - for _, utxo := range utxos { - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - utxo.PkScript, w.chainParams, - ) - if err != nil || len(addrs) == 0 { - continue - } - - addrStr := addrs[0].String() - if _, ok := accountAddrs[addrStr]; ok { - account.TotalBalance += utxo.Amount + // Assign the balance to the account result. If the account has + // no UTXOs, the balance will be zero. + if balances, ok := scopedBalances[scope]; ok { + if balance, ok := balances[accNum]; ok { + account.TotalBalance = balance } } @@ -594,7 +420,8 @@ func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, return account, nil } -// RenameAccount renames an existing account. +// RenameAccount renames an existing account. The new name must be unique within +// the same key scope. The reserved "imported" account cannot be renamed. // // The time complexity of this method is dominated by the database lookup for // the old account name. @@ -606,7 +433,8 @@ func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, return err } - // Validate new account name. + // Validate the new account name to ensure it meets the required + // criteria. if err := waddrmgr.ValidateAccountName(newName); err != nil { return err } @@ -614,75 +442,80 @@ func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - // First, we'll look up the account number for the given name. + // Look up the account number for the given name. This is + // required to perform the rename operation. accNum, err := manager.LookupAccount(addrmgrNs, oldName) if err != nil { return err } + // Perform the rename operation in the address manager. return manager.RenameAccount(addrmgrNs, accNum, newName) }) } -// Balance returns the balance for a specific account. +// Balance returns the balance for a specific account, identified by its scope +// and name, for a given number of required confirmations. // -// The time complexity of this method is O(U + A_a), where U is the -// number of UTXOs in the wallet and A_a is the number of addresses in the -// account. +// The function first looks up the account number and then iterates through all +// unspent transaction outputs (UTXOs), summing the values of those that belong +// to the account and meet the required number of confirmations. +// +// The time complexity of this method is O(U*logA), where U is the number of +// UTXOs and logA is the cost of an account lookup. func (w *Wallet) Balance(_ context.Context, conf int32, - scope waddrmgr.KeyScope, accountName string) (btcutil.Amount, error) { - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return 0, err - } + scope waddrmgr.KeyScope, name string) (btcutil.Amount, error) { var balance btcutil.Amount - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - // First, we'll look up the account number for the given name. - accNum, err := manager.LookupAccount(addrmgrNs, accountName) + // Look up the account number for the given name and scope. + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return err } - - // Now, we'll create a set of all addresses in the account. - accountAddrs := make(map[string]struct{}) - err = manager.ForEachAccountAddress( - addrmgrNs, accNum, - func(addr waddrmgr.ManagedAddress) error { - addrStr := addr.Address().String() - accountAddrs[addrStr] = struct{}{} - return nil - }, - ) + accNum, err := manager.LookupAccount(addrmgrNs, name) if err != nil { return err } - // Finally, we'll iterate through all unspent outputs and sum - // up the balances for the addresses in our set. + // Iterate through all unspent outputs and sum the balances for + // the addresses that belong to the target account. syncBlock := w.addrStore.SyncedTo() utxos, err := w.txStore.UnspentOutputs(txmgrNs) if err != nil { return err } for _, utxo := range utxos { + // Skip any UTXOs that have not yet reached the required + // number of confirmations. if !confirmed(conf, utxo.Height, syncBlock.Height) { continue } - _, addrs, _, err := txscript.ExtractPkScriptAddrs( + // Extract the address from the UTXO's public key script. + addr := extractAddrFromPKScript( utxo.PkScript, w.chainParams, ) - if err != nil || len(addrs) == 0 { + if addr == nil { continue } - addrStr := addrs[0].String() - if _, ok := accountAddrs[addrStr]; ok { + // Look up the account that owns the address. + addrScope, addrAcc, err := w.addrStore.AddrAccount( + addrmgrNs, addr, + ) + if err != nil { + // Ignore addresses that are not found in the + // wallet. + continue + } + + // If the address belongs to the target account, add the + // UTXO's value to the total balance. + if addrScope.Scope() == scope && addrAcc == accNum { balance += utxo.Amount } } @@ -825,6 +658,12 @@ type scopedBalances map[waddrmgr.KeyScope]map[uint32]btcutil.Amount // WHERE is_spent = false GROUP BY key_scope, account_id;`. // This would be significantly faster as the database is optimized for // these types of operations. +// +// TODO(yy): The current UTXO-first approach is optimal for mature wallets where +// the number of addresses greatly exceeds the number of UTXOs. For new wallets +// or accounts, an address-first approach might be more efficient. A future +// improvement could be to dynamically choose the strategy based on the relative +// counts of addresses and UTXOs for the accounts in question. func (w *Wallet) fetchAccountBalances(tx walletdb.ReadTx, opts ...filterOption) (scopedBalances, error) { @@ -914,12 +753,12 @@ func (w *Wallet) fetchAccountBalances(tx walletdb.ReadTx, // It serves as the final step to assemble the complete AccountResult objects. // // The function operates as follows: -// 1. It determines the last account number for the given scope. -// 2. It iterates from account number 0 to the last account. -// 3. For each account, it retrieves its properties from the database. -// 4. It looks up the pre-calculated balance from the accountBalances map. -// 5. It constructs an AccountResult object with both the properties and the -// balance. +// 1. It determines the last account number for the given scope. +// 2. It iterates from account number 0 to the last account. +// 3. For each account, it retrieves its properties from the database. +// 4. It looks up the pre-calculated balance from the accountBalances map. +// 5. It constructs an AccountResult object with both the properties and the +// balance. // // This separation of concerns (first calculating all balances, then assembling // the results) is a key part of the overall optimization strategy. It ensures diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 7353969edf..9933ec1685 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -119,104 +119,6 @@ func TestListAccounts(t *testing.T) { require.True(t, found, "expected to find new account") } -// getOrCreateAddress is a helper function to get an address for a given -// account, or create a new one if none exist. -func getOrCreateAddress(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, - account uint32) btcutil.Address { - - var addr btcutil.Address - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return err - } - - var addrs []waddrmgr.ManagedAddress - err = scopedMgr.ForEachAccountAddress( - addrmgrNs, account, - func(addr waddrmgr.ManagedAddress) error { - addrs = append(addrs, addr) - return nil - }, - ) - if err != nil || len(addrs) == 0 { - derivedAddrs, err := scopedMgr.NextExternalAddresses( - addrmgrNs, account, 1, - ) - if err != nil { - return err - } - addr = derivedAddrs[0].Address() - } else { - addr = addrs[0].Address() - } - - return nil - }) - require.NoError(t, err) - - return addr -} - -// TestCreateResultForScope tests that the createResultForScope helper function -// works as expected. -func TestCreateResultForScope(t *testing.T) { - t.Parallel() - - // Create a new test wallet. - w, cleanup := testWallet(t) - defer cleanup() - - // We'll create a new account under the BIP0084 scope to have a - // predictable state with more than just the default account. - scope := waddrmgr.KeyScopeBIP0084 - acc1Name := "test account" - _, err := w.NewAccount(context.Background(), scope, acc1Name) - require.NoError(t, err) - - // We'll now create a balance map for a few addresses. We need to - // derive some addresses first to have something to work with. - addrToBalance := make(map[string]btcutil.Amount) - defaultAddr := getOrCreateAddress(t, w, scope, 0) - acc1Addr := getOrCreateAddress(t, w, scope, 1) - - // Assign some balances to our derived addresses. - addrToBalance[defaultAddr.String()] = 100 - addrToBalance[acc1Addr.String()] = 200 - - // Now, we'll call createResultForScope within a read transaction and - // verify the results. - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) - require.NoError(t, err) - - // Call the function under test. - results, err := createResultForScope( - scopedMgr, addrmgrNs, addrToBalance, - ) - require.NoError(t, err) - - // The BIP0084 scope should have two accounts: the default one - // and the one we just created. - require.Len(t, results, 2, "expected two accounts for scope") - - // Check the default account's result. - require.Equal(t, "default", results[0].AccountName) - require.Equal(t, uint32(0), results[0].AccountNumber) - require.Equal(t, btcutil.Amount(100), results[0].TotalBalance) - - // Check the new account's result. - require.Equal(t, acc1Name, results[1].AccountName) - require.Equal(t, uint32(1), results[1].AccountNumber) - require.Equal(t, btcutil.Amount(200), results[1].TotalBalance) - - return nil - }) - require.NoError(t, err) -} - // TestListAccountsByScope tests that the ListAccountsByScope method works as // expected. func TestListAccountsByScope(t *testing.T) { From 08d8f1e20b7eef38753af63d99780a11cf219dc8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 17 Sep 2025 21:43:54 +0800 Subject: [PATCH 020/691] wallet: unify param names to reduce cognitive load Using uniformal names so it's easier to follow among methods. --- wallet/account_manager.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 53b9a23490..eb43ce769e 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -83,7 +83,7 @@ type AccountManager interface { // Balance returns the balance for a specific account, identified by its // scope and name, for a given number of required confirmations. Balance(ctx context.Context, conf int32, scope waddrmgr.KeyScope, - accountName string) (btcutil.Amount, error) + name string) (btcutil.Amount, error) // ImportAccount imports an account from an extended public or private // key. The key scope is derived from the version bytes of the @@ -122,13 +122,13 @@ func (w *Wallet) NewAccount(_ context.Context, scope waddrmgr.KeyScope, addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) // Create a new account under the current key scope. - accountID, err := manager.NewAccount(addrmgrNs, name) + accNum, err := manager.NewAccount(addrmgrNs, name) if err != nil { return err } // Get the account's properties. - props, err = manager.AccountProperties(addrmgrNs, accountID) + props, err = manager.AccountProperties(addrmgrNs, accNum) return err }) From 8b6cb9201363fd83dd9f303c0ec3e6ed7a4082d0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 22 Sep 2025 22:15:20 +0800 Subject: [PATCH 021/691] wallet: refactor `fetchAccountBalances` to remove name filter The fetchAccountBalances function has been refactored to remove the withName filter option. This change simplifies the function and improves performance by eliminating the need for repeated database lookups for account names within the UTXO loop. Call sites in ListAccountsByName and GetAccount have been updated to fetch all account balances and perform name-based filtering on the returned map. The corresponding test cases in TestFetchAccountBalances have also been updated to reflect the removal of the name filter. --- wallet/account_manager.go | 32 +++------------------------ wallet/account_manager_test.go | 40 ---------------------------------- 2 files changed, 3 insertions(+), 69 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index eb43ce769e..9406bc14c3 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -298,7 +298,7 @@ func (w *Wallet) ListAccountsByName(_ context.Context, // First, calculate the balances for any accounts that match the // given name. This is efficient as it iterates over the UTXO // set, not accounts. - scopedBalances, err := w.fetchAccountBalances(tx, withName(name)) + scopedBalances, err := w.fetchAccountBalances(tx) if err != nil { return err } @@ -397,7 +397,7 @@ func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, // Calculate the balance for this specific account by fetching // the UTXOs that belong to it. scopedBalances, err := w.fetchAccountBalances( - tx, withScope(scope), withName(name), + tx, withScope(scope), ) if err != nil { return err @@ -591,7 +591,6 @@ func extractAddrFromPKScript(pkScript []byte, // balance queries. type accountFilter struct { scope *waddrmgr.KeyScope - name string } // filterOption is a functional option type for account filtering. @@ -605,14 +604,6 @@ func withScope(scope waddrmgr.KeyScope) filterOption { } } -// withName is a filter option to limit account queries to a specific account -// name. -func withName(name string) filterOption { - return func(f *accountFilter) { - f.name = name - } -} - // scopedBalances is a type alias for a map of key scopes to a map of account // numbers to their total balance. type scopedBalances map[waddrmgr.KeyScope]map[uint32]btcutil.Amount @@ -644,7 +635,6 @@ type scopedBalances map[waddrmgr.KeyScope]map[uint32]btcutil.Amount // Filters: // The function's behavior can be customized by passing one or more filterOption // functions. This allows the caller to restrict the balance calculation to: -// - A specific account name (withName). // - A specific key scope (withScope). // // If no filters are provided, balances for all accounts across all scopes will @@ -703,23 +693,7 @@ func (w *Wallet) fetchAccountBalances(tx walletdb.ReadTx, continue } - // If any filters were provided, we'll apply them now to - // determine if this UTXO should be included in the final - // balance calculation. - if filter.name != "" { - accName, err := scope.AccountName(addrmgrNs, accNum) - if err != nil { - // This indicates a potential database - // corruption, as we should always be able to - // look up the name for a valid account number. - log.Errorf("Unable to query account name for "+ - "account %d: %v", accNum, err) - continue - } - if accName != filter.name { - continue - } - } + // If a scope filter was provided, apply it now. if filter.scope != nil { if scope.Scope() != *filter.scope { continue diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 9933ec1685..5e3d8199b4 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -682,13 +682,6 @@ func TestFetchAccountBalances(t *testing.T) { waddrmgr.KeyScopeBIP0049Plus: {1: 300}, }, }, - { - name: "filter by name", - filters: []filterOption{withName("acc1-bip84")}, - expectedBalances: scopedBalances{ - waddrmgr.KeyScopeBIP0084: {1: 200}, - }, - }, { name: "filter by scope", filters: []filterOption{ @@ -698,21 +691,6 @@ func TestFetchAccountBalances(t *testing.T) { waddrmgr.KeyScopeBIP0084: {0: 100, 1: 200}, }, }, - { - name: "filter by name and scope", - filters: []filterOption{ - withName("acc1-bip84"), - withScope(waddrmgr.KeyScopeBIP0084), - }, - expectedBalances: scopedBalances{ - waddrmgr.KeyScopeBIP0084: {1: 200}, - }, - }, - { - name: "filter by non-existent name", - filters: []filterOption{withName("non-existent")}, - expectedBalances: scopedBalances{}, - }, { name: "account with no balance", setup: func(t *testing.T, w *Wallet) { @@ -728,24 +706,6 @@ func TestFetchAccountBalances(t *testing.T) { waddrmgr.KeyScopeBIP0049Plus: {1: 300}, }, }, - { - name: "filter by name that exists in multiple scopes", - setup: func(t *testing.T, w *Wallet) { - _, err := w.NewAccount( - context.Background(), - waddrmgr.KeyScopeBIP0049Plus, "acc1-bip84", - ) - require.NoError(t, err) - addTestUTXOForBalance( - t, w, waddrmgr.KeyScopeBIP0049Plus, 2, 400, - ) - }, - filters: []filterOption{withName("acc1-bip84")}, - expectedBalances: scopedBalances{ - waddrmgr.KeyScopeBIP0084: {1: 200}, - waddrmgr.KeyScopeBIP0049Plus: {2: 400}, - }, - }, } for _, tc := range testCases { From 9fc75074e2c96b155acf45ca1dde81d6a064d5d8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 29 Sep 2025 15:21:55 +0800 Subject: [PATCH 022/691] wallet: temporarily ignore `wrapcheck` They are too noisy at the moment, and we will bring them back when we implement `Store` interface as the current `addrStore` returns a structured error, making it hard to check in the test once wrapped. --- wallet/account_manager.go | 5 +++++ wallet/wallet.go | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 9406bc14c3..19337d4c31 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -2,6 +2,11 @@ // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. +// Package wallet implements the account management for the wallet. +// +// TODO(yy): bring wrapcheck back when implementing the `Store` interface. +// +//nolint:wrapcheck package wallet import ( diff --git a/wallet/wallet.go b/wallet/wallet.go index 4bdc76954d..64b0afec90 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -3,6 +3,13 @@ // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. +// Package wallet provides a bitcoin wallet that is capable of fulfilling all +// the duties of a typical bitcoin wallet such as creating and managing keys, +// creating and signing transactions, and customizing of transaction fees. +// +// TODO(yy): bring wrapcheck back when implementing the `Store` interface. +// +//nolint:wrapcheck package wallet import ( From f5700742795c8ac0324f4deb363a773d794542e1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 29 Sep 2025 15:24:53 +0800 Subject: [PATCH 023/691] wallet: fix linter errors via `make lint` Run `make lint` to auto fix linter errors. --- wallet/account_manager.go | 18 ++++++++++++++++++ wallet/account_manager_test.go | 28 ++++++++++++++++++---------- wallet/chainntfns.go | 5 +++++ wallet/createtx.go | 2 ++ wallet/createtx_test.go | 1 + wallet/multisig.go | 1 + wallet/notifications.go | 4 ++++ wallet/unstable.go | 1 + wallet/utxos.go | 1 + wallet/wallet.go | 28 +++++++++++++++++++++++++++- 10 files changed, 78 insertions(+), 11 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 19337d4c31..5fa48ca491 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -123,6 +123,7 @@ func (w *Wallet) NewAccount(_ context.Context, scope waddrmgr.KeyScope, } var props *waddrmgr.AccountProperties + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) @@ -134,6 +135,7 @@ func (w *Wallet) NewAccount(_ context.Context, scope waddrmgr.KeyScope, // Get the account's properties. props, err = manager.AccountProperties(addrmgrNs, accNum) + return err }) @@ -181,6 +183,7 @@ func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { scopes := w.addrStore.ActiveScopedKeyManagers() var accounts []AccountResult + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) @@ -222,6 +225,7 @@ func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { // Include the wallet's current sync state in the result to provide a // point-in-time reference for the balances. syncBlock := w.addrStore.SyncedTo() + return &AccountsResult{ Accounts: accounts, CurrentBlockHash: syncBlock.Hash, @@ -251,6 +255,7 @@ func (w *Wallet) ListAccountsByScope(_ context.Context, } var accounts []AccountResult + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) @@ -268,6 +273,7 @@ func (w *Wallet) ListAccountsByScope(_ context.Context, accounts, err = listAccountsWithBalances( manager, addrmgrNs, scopedBalances[scope], ) + return err }) if err != nil { @@ -276,6 +282,7 @@ func (w *Wallet) ListAccountsByScope(_ context.Context, // Include the wallet's current sync state in the result. syncBlock := w.addrStore.SyncedTo() + return &AccountsResult{ Accounts: accounts, CurrentBlockHash: syncBlock.Hash, @@ -299,6 +306,7 @@ func (w *Wallet) ListAccountsByName(_ context.Context, scopes := w.addrStore.ActiveScopedKeyManagers() var accounts []AccountResult + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { // First, calculate the balances for any accounts that match the // given name. This is efficient as it iterates over the UTXO @@ -338,6 +346,7 @@ func (w *Wallet) ListAccountsByName(_ context.Context, // Get the pre-calculated balance for this account. If // the account has no balance, it will be zero. var balance btcutil.Amount + balances, ok := scopedBalances[scopeMgr.Scope()] if ok { balance = balances[accNum] @@ -356,6 +365,7 @@ func (w *Wallet) ListAccountsByName(_ context.Context, } syncBlock := w.addrStore.SyncedTo() + return &AccountsResult{ Accounts: accounts, CurrentBlockHash: syncBlock.Hash, @@ -379,6 +389,7 @@ func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, } var account *AccountResult + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) @@ -472,6 +483,7 @@ func (w *Wallet) Balance(_ context.Context, conf int32, scope waddrmgr.KeyScope, name string) (btcutil.Amount, error) { var balance btcutil.Amount + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) @@ -481,6 +493,7 @@ func (w *Wallet) Balance(_ context.Context, conf int32, if err != nil { return err } + accNum, err := manager.LookupAccount(addrmgrNs, name) if err != nil { return err @@ -489,10 +502,12 @@ func (w *Wallet) Balance(_ context.Context, conf int32, // Iterate through all unspent outputs and sum the balances for // the addresses that belong to the target account. syncBlock := w.addrStore.SyncedTo() + utxos, err := w.txStore.UnspentOutputs(txmgrNs) if err != nil { return err } + for _, utxo := range utxos { // Skip any UTXOs that have not yet reached the required // number of confirmations. @@ -695,6 +710,7 @@ func (w *Wallet) fetchAccountBalances(tx walletdb.ReadTx, if err != nil { log.Errorf("Unable to query account using address %v: "+ "%v", addr, err) + continue } @@ -749,6 +765,7 @@ func listAccountsWithBalances(scopeMgr *waddrmgr.ScopedKeyManager, accountBalances map[uint32]btcutil.Amount) ([]AccountResult, error) { var accounts []AccountResult + lastAccount, err := scopeMgr.LastAccount(addrmgrNs) if err != nil { // If the scope has no accounts, we can just return an empty @@ -756,6 +773,7 @@ func listAccountsWithBalances(scopeMgr *waddrmgr.ScopedKeyManager, if waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound) { return nil, nil } + return nil, err } diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 5e3d8199b4..f6257570ae 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -106,6 +106,7 @@ func TestListAccounts(t *testing.T) { for _, acc := range accounts.Accounts { if acc.AccountName == testAccountName { found = true + require.Equal( t, uint32(1), acc.AccountNumber, "expected new account number", @@ -116,6 +117,7 @@ func TestListAccounts(t *testing.T) { ) } } + require.True(t, found, "expected to find new account") } @@ -362,6 +364,7 @@ func TestBalance(t *testing.T) { err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + err := w.txStore.InsertTx(ns, rec, &wtxmgr.BlockMeta{ Block: wtxmgr.Block{ Height: 1, @@ -382,6 +385,7 @@ func TestBalance(t *testing.T) { // Now, we'll update the wallet's sync state. err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + return w.addrStore.SetSyncedTo(addrmgrNs, &waddrmgr.BlockStamp{ Height: 1, }) @@ -550,7 +554,6 @@ func TestExtractAddrFromPKScript(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() @@ -558,7 +561,7 @@ func TestExtractAddrFromPKScript(t *testing.T) { testCase.script(), w.chainParams, ) if addr == nil { - require.Equal(t, testCase.addr, "") + require.Empty(t, testCase.addr) } else { require.Equal(t, testCase.addr, addr.String()) } @@ -594,10 +597,12 @@ func addTestUTXOForBalance(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, block := &wtxmgr.BlockMeta{ Block: wtxmgr.Block{Height: 1}, } + err := w.txStore.InsertTx(ns, rec, block) if err != nil { return err } + return w.txStore.AddCredit(ns, rec, block, 0, false) }) require.NoError(t, err) @@ -661,8 +666,9 @@ func TestFetchAccountBalances(t *testing.T) { err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) bs := &waddrmgr.BlockStamp{Height: 1} - return w.addrStore.SetSyncedTo(addrmgrNs, bs) - }) + + return w.addrStore.SetSyncedTo(addrmgrNs, bs) + }) require.NoError(t, err) return w, cleanup @@ -709,7 +715,6 @@ func TestFetchAccountBalances(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -721,13 +726,16 @@ func TestFetchAccountBalances(t *testing.T) { } var balances scopedBalances + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { var err error - balances, err = w.fetchAccountBalances( - tx, tc.filters..., - ) - return err - }) + + balances, err = w.fetchAccountBalances( + tx, tc.filters..., + ) + + return err + }) require.NoError(t, err) require.Equal(t, tc.expectedBalances, balances) diff --git a/wallet/chainntfns.go b/wallet/chainntfns.go index 8a1e6b1b03..2e96dbb09e 100644 --- a/wallet/chainntfns.go +++ b/wallet/chainntfns.go @@ -67,6 +67,7 @@ func (w *Wallet) handleChainNotifications() { Hash: *hash, Timestamp: header.Timestamp, } + err = w.addrStore.SetSyncedTo(ns, &bs) if err != nil { return err @@ -248,6 +249,7 @@ func (w *Wallet) connectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) err Hash: b.Hash, Timestamp: b.Time, } + err := w.addrStore.SetSyncedTo(addrmgrNs, &bs) if err != nil { return err @@ -283,6 +285,7 @@ func (w *Wallet) disconnectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) bs := waddrmgr.BlockStamp{ Height: b.Height - 1, } + hash, err = w.addrStore.BlockHash(addrmgrNs, bs.Height) if err != nil { return err @@ -296,6 +299,7 @@ func (w *Wallet) disconnectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) } bs.Timestamp = header.Timestamp + err = w.addrStore.SetSyncedTo(addrmgrNs, &bs) if err != nil { return err @@ -390,6 +394,7 @@ func (w *Wallet) addRelevantTx(dbtx walletdb.ReadWriteTx, rec *wtxmgr.TxRecord, if err != nil { return err } + err = w.addrStore.MarkUsed(addrmgrNs, addr) if err != nil { return err diff --git a/wallet/createtx.go b/wallet/createtx.go index 3bf0df1dba..c0a18d4954 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -399,6 +399,7 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, if err != nil || len(addrs) != 1 { continue } + scopedMgr, addrAcct, err := w.addrStore.AddrAccount(addrmgrNs, addrs[0]) if err != nil { continue @@ -447,6 +448,7 @@ func (w *Wallet) addrMgrWithChangeSource(dbtx walletdb.ReadWriteTx, // It's possible for the account to have an address schema override, so // prefer that if it exists. addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + scopeMgr, err := w.addrStore.FetchScopedKeyManager(*changeKeyScope) if err != nil { return nil, nil, err diff --git a/wallet/createtx_test.go b/wallet/createtx_test.go index 0ffdc37e13..4a82c2d444 100644 --- a/wallet/createtx_test.go +++ b/wallet/createtx_test.go @@ -238,6 +238,7 @@ func addTxAndCredit(t *testing.T, w *Wallet, tx *wire.MsgTx, err = walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { ns := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) + err = w.txStore.InsertTx(ns, rec, block) if err != nil { return err diff --git a/wallet/multisig.go b/wallet/multisig.go index 7891f5ee9d..8ab388999a 100644 --- a/wallet/multisig.go +++ b/wallet/multisig.go @@ -52,6 +52,7 @@ func (w *Wallet) MakeMultiSigScript(addrs []btcutil.Address, nRequired int) ([]b } addrmgrNs = dbtx.ReadBucket(waddrmgrNamespaceKey) } + addrInfo, err := w.addrStore.Address(addrmgrNs, addr) if err != nil { return nil, err diff --git a/wallet/notifications.go b/wallet/notifications.go index 45803b192f..d8086b218c 100644 --- a/wallet/notifications.go +++ b/wallet/notifications.go @@ -54,6 +54,7 @@ func lookupInputAccount(dbtx walletdb.ReadTx, w *Wallet, details *wtxmgr.TxDetai // TODO: Debits should record which account(s?) they // debit from so this doesn't need to be looked up. prevOP := &details.MsgTx.TxIn[deb.Index].PreviousOutPoint + prev, err := w.txStore.TxDetails(txmgrNs, &prevOP.Hash) if err != nil { log.Errorf("Cannot query previous transaction details for %v: %v", prevOP.Hash, err) @@ -155,6 +156,7 @@ func makeTxSummary(dbtx walletdb.ReadTx, w *Wallet, details *wtxmgr.TxDetails) T func totalBalances(dbtx walletdb.ReadTx, w *Wallet, m map[uint32]btcutil.Amount) error { addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) + unspent, err := w.txStore.UnspentOutputs(dbtx.ReadBucket(wtxmgrNamespaceKey)) if err != nil { return err @@ -231,6 +233,7 @@ func (s *NotificationServer) notifyUnminedTransaction(dbtx walletdb.ReadTx, } unminedTxs := []TransactionSummary{makeTxSummary(dbtx, s.wallet, details)} + unminedHashes, err := s.wallet.txStore.UnminedTxHashes(dbtx.ReadBucket(wtxmgrNamespaceKey)) if err != nil { log.Errorf("Cannot fetch unmined transaction hashes: %v", err) @@ -353,6 +356,7 @@ func (s *NotificationServer) notifyAttachedBlock(dbtx walletdb.ReadTx, block *wt // a new, previously unseen transaction appearing in unconfirmed. txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + unminedHashes, err := s.wallet.txStore.UnminedTxHashes(txmgrNs) if err != nil { log.Errorf("Cannot fetch unmined transaction hashes: %v", err) diff --git a/wallet/unstable.go b/wallet/unstable.go index 2b79c90bda..8386ed7e3d 100644 --- a/wallet/unstable.go +++ b/wallet/unstable.go @@ -28,6 +28,7 @@ func (u unstableAPI) TxDetails(txHash *chainhash.Hash) (*wtxmgr.TxDetails, error err := walletdb.View(u.w.db, func(dbtx walletdb.ReadTx) error { txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) var err error + details, err = u.w.txStore.TxDetails(txmgrNs, txHash) return err }) diff --git a/wallet/utxos.go b/wallet/utxos.go index 62bfc00a59..1e7ba09c03 100644 --- a/wallet/utxos.go +++ b/wallet/utxos.go @@ -72,6 +72,7 @@ func (w *Wallet) UnspentOutputs(policy OutputSelectionPolicy) ([]*TransactionOut // per output. continue } + _, outputAcct, err := w.addrStore.AddrAccount(addrmgrNs, addrs[0]) if err != nil { return err diff --git a/wallet/wallet.go b/wallet/wallet.go index 64b0afec90..a4a31ed39c 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -370,6 +370,7 @@ func (w *Wallet) activeData(dbtx walletdb.ReadWriteTx) ([]btcutil.Address, []wtx txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) var addrs []btcutil.Address + err := w.addrStore.ForEachRelevantActiveAddress( addrmgrNs, func(addr btcutil.Address) error { addrs = append(addrs, addr) @@ -456,6 +457,7 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + err := w.addrStore.SetSyncedTo(ns, &waddrmgr.BlockStamp{ Hash: *startHash, Height: startHeight, @@ -733,6 +735,7 @@ func (w *Wallet) recovery(chainClient chain.Interface, } err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { txMgrNS := tx.ReadBucket(wtxmgrNamespaceKey) + credits, err := w.txStore.UnspentOutputs(txMgrNS) if err != nil { return err @@ -761,6 +764,7 @@ func (w *Wallet) recovery(chainClient chain.Interface, // that a wallet rescan will be performed from the wallet's tip, which // will be of bestHeight after completing the recovery process. var blocks []*waddrmgr.BlockStamp + startHeight := w.addrStore.SyncedTo().Height + 1 for height := startHeight; height <= bestHeight; height++ { if atomic.LoadUint32(&syncer.quit) == 1 { @@ -1445,6 +1449,7 @@ out: case req := <-w.changePassphrase: err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + return w.addrStore.ChangePassphrase( addrmgrNs, req.old, req.new, req.private, &waddrmgr.DefaultScryptOptions, @@ -1456,6 +1461,7 @@ out: case req := <-w.changePassphrases: err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + err := w.addrStore.ChangePassphrase( addrmgrNs, req.publicOld, req.publicNew, false, &waddrmgr.DefaultScryptOptions, @@ -1511,6 +1517,7 @@ out: <-w.endRecovery() timeout = nil + err := w.addrStore.Lock() if err != nil && !waddrmgr.IsError(err, waddrmgr.ErrLocked) { log.Errorf("Could not lock wallet: %v", err) @@ -1622,6 +1629,7 @@ func (w *Wallet) ChangePassphrases(publicOld, publicNew, privateOld, func (w *Wallet) AccountAddresses(account uint32) (addrs []btcutil.Address, err error) { err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + return w.addrStore.ForEachAccountAddress(addrmgrNs, account, func(maddr waddrmgr.ManagedAddress) error { addrs = append(addrs, maddr.Address()) return nil @@ -1675,6 +1683,7 @@ func (w *Wallet) CalculateBalance(confirms int32) (btcutil.Amount, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) var err error + blk := w.addrStore.SyncedTo() balance, err = w.txStore.Balance(txmgrNs, confirms, blk.Height) return err @@ -1819,6 +1828,7 @@ func (w *Wallet) PubKeyForAddress(a btcutil.Address) (*btcec.PublicKey, error) { var pubKey *btcec.PublicKey err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + managedAddr, err := w.addrStore.Address(addrmgrNs, a) if err != nil { return err @@ -1891,6 +1901,7 @@ func (w *Wallet) PrivKeyForAddress(a btcutil.Address) (*btcec.PrivateKey, error) var privKey *btcec.PrivateKey err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + managedAddr, err := w.addrStore.Address(addrmgrNs, a) if err != nil { return err @@ -1927,6 +1938,7 @@ func (w *Wallet) AccountOfAddress(a btcutil.Address) (uint32, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) var err error + _, account, err = w.addrStore.AddrAccount(addrmgrNs, a) return err }) @@ -1939,6 +1951,7 @@ func (w *Wallet) AddressInfo(a btcutil.Address) (waddrmgr.ManagedAddress, error) err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) var err error + managedAddress, err = w.addrStore.Address(addrmgrNs, a) return err }) @@ -2033,9 +2046,11 @@ func (w *Wallet) LookupAccount(name string) (waddrmgr.KeyScope, uint32, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(waddrmgrNamespaceKey) var err error + keyScope, account, err = w.addrStore.LookupAccount(ns, name) return err }) + return keyScope, account, err } @@ -2727,6 +2742,7 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32, syncBlock := w.addrStore.SyncedTo() filter := accountName != "" + unspent, err := w.txStore.UnspentOutputs(txmgrNs) if err != nil { return err @@ -2862,6 +2878,7 @@ func (w *Wallet) ListLeasedOutputs() ([]*ListLeasedOutputResult, error) { var results []*ListLeasedOutputResult err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(wtxmgrNamespaceKey) + outputs, err := w.txStore.ListLockedOutputs(ns) if err != nil { return err @@ -2938,6 +2955,7 @@ func (w *Wallet) DumpWIFPrivateKey(addr btcutil.Address) (string, error) { waddrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // Get private key from wallet if it exists. var err error + maddr, err = w.addrStore.Address(waddrmgrNs, addr) return err }) @@ -3035,6 +3053,7 @@ func (w *Wallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint, err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) var err error + expiry, err = w.txStore.LockOutput(ns, id, op, duration) return err }) @@ -3059,6 +3078,7 @@ func (w *Wallet) resendUnminedTxs() { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) var err error + txs, err = w.txStore.UnminedTxs(txmgrNs) return err }) @@ -3087,6 +3107,7 @@ func (w *Wallet) SortedActivePaymentAddresses() ([]string, error) { var addrStrs []string err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + return w.addrStore.ForEachActiveAddress(addrmgrNs, func(addr btcutil.Address) error { addrStrs = append(addrStrs, addr.EncodeAddress()) return nil @@ -3498,6 +3519,7 @@ func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, if !ok { prevHash := &txIn.PreviousOutPoint.Hash prevIndex := txIn.PreviousOutPoint.Index + txDetails, err := w.txStore.TxDetails(txmgrNs, prevHash) if err != nil { return fmt.Errorf("cannot query previous transaction "+ @@ -3525,6 +3547,7 @@ func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, } return wif.PrivKey, wif.CompressPubKey, nil } + address, err := w.addrStore.Address(addrmgrNs, addr) if err != nil { return nil, false, err @@ -3554,6 +3577,7 @@ func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, } return script, nil } + address, err := w.addrStore.Address(addrmgrNs, addr) if err != nil { return nil, err @@ -3751,7 +3775,9 @@ func (w *Wallet) reliablyPublishTransaction(tx *wire.MsgTx, // and record it in the tx store. if len(label) != 0 { txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) - if err = w.txStore.PutTxLabel(txmgrNs, tx.TxHash(), label); err != nil { + + err = w.txStore.PutTxLabel(txmgrNs, tx.TxHash(), label) + if err != nil { return err } } From 02d22e3666c4c696054a8b8011ca456c1a818072 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 29 Sep 2025 15:48:30 +0800 Subject: [PATCH 024/691] wallet: fix `noinlineerr`, `ireturn`, `thelper`, and `tparallel` --- wallet/account_manager.go | 8 +++++++- wallet/account_manager_test.go | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 5fa48ca491..37f2a3374c 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -451,7 +451,8 @@ func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, // Validate the new account name to ensure it meets the required // criteria. - if err := waddrmgr.ValidateAccountName(newName); err != nil { + err = waddrmgr.ValidateAccountName(newName) + if err != nil { return err } @@ -578,6 +579,11 @@ func (w *Wallet) ImportAccount(_ context.Context, // extractAddrFromPKScript extracts an address from a public key script. If the // script cannot be parsed or does not contain any addresses, it returns nil. +// +// The btcutil.Address is an interface that abstracts over different address +// types. Returning the interface is idiomatic in this context. +// +//nolint:ireturn func extractAddrFromPKScript(pkScript []byte, chainParams *chaincfg.Params) btcutil.Address { diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index f6257570ae..3e2ee59a41 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -473,7 +473,7 @@ func TestExtractAddrFromPKScript(t *testing.T) { t.Parallel() w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) w.chainParams = &chaincfg.MainNetParams @@ -573,6 +573,8 @@ func TestExtractAddrFromPKScript(t *testing.T) { func addTestUTXOForBalance(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, account uint32, amount btcutil.Amount) { + t.Helper() + addr, err := w.NewAddress(account, scope) require.NoError(t, err) @@ -644,6 +646,8 @@ func TestFetchAccountBalances(t *testing.T) { // function that should be deferred by the caller to ensure that the // wallet's resources are properly released after the test completes. setupTestCase := func(t *testing.T) (*Wallet, func()) { + t.Helper() + w, cleanup := testWallet(t) ctx := context.Background() @@ -700,6 +704,8 @@ func TestFetchAccountBalances(t *testing.T) { { name: "account with no balance", setup: func(t *testing.T, w *Wallet) { + t.Helper() + _, err := w.NewAccount( context.Background(), waddrmgr.KeyScopeBIP0084, "no-balance", From dfdeea979aae4a5cc32ec2b50cd7ac41a1d239cb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 29 Sep 2025 12:52:58 +0800 Subject: [PATCH 025/691] wallet: fix linter error from `cyclop` on method `Balance` --- wallet/account_manager.go | 53 +++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 37f2a3374c..278017d6dc 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -19,6 +19,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" ) // AccountManager provides a high-level interface for managing wallet @@ -516,29 +517,9 @@ func (w *Wallet) Balance(_ context.Context, conf int32, continue } - // Extract the address from the UTXO's public key script. - addr := extractAddrFromPKScript( - utxo.PkScript, w.chainParams, + balance += w.balanceForUTXO( + addrmgrNs, scope, accNum, utxo, ) - if addr == nil { - continue - } - - // Look up the account that owns the address. - addrScope, addrAcc, err := w.addrStore.AddrAccount( - addrmgrNs, addr, - ) - if err != nil { - // Ignore addresses that are not found in the - // wallet. - continue - } - - // If the address belongs to the target account, add the - // UTXO's value to the total balance. - if addrScope.Scope() == scope && addrAcc == accNum { - balance += utxo.Amount - } } return nil @@ -550,6 +531,34 @@ func (w *Wallet) Balance(_ context.Context, conf int32, return balance, nil } +// balanceForUTXO is a helper function for Balance that calculates the balance +// of a single UTXO if it belongs to the target account. +func (w *Wallet) balanceForUTXO(addrmgrNs walletdb.ReadBucket, + scope waddrmgr.KeyScope, accNum uint32, + utxo wtxmgr.Credit) btcutil.Amount { + + // Extract the address from the UTXO's public key script. + addr := extractAddrFromPKScript(utxo.PkScript, w.chainParams) + if addr == nil { + return 0 + } + + // Look up the account that owns the address. + addrScope, addrAcc, err := w.addrStore.AddrAccount(addrmgrNs, addr) + if err != nil { + // Ignore addresses that are not found in the wallet. + return 0 + } + + // If the address belongs to the target account, add the UTXO's value + // to the total balance. + if addrScope.Scope() == scope && addrAcc == accNum { + return utxo.Amount + } + + return 0 +} + // ImportAccount imports an account from an extended public or private key. // // The time complexity of this method is dominated by the database lookup From 3ce7e109d6f9bd60253f26d935b5ea4fb6238bf4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 29 Sep 2025 16:26:27 +0800 Subject: [PATCH 026/691] multi: fix `lll` linter errors --- rpc/rpcserver/server.go | 5 +- waddrmgr/scoped_manager.go | 4 +- wallet/account_manager.go | 4 +- wallet/account_manager_test.go | 26 ++++--- wallet/createtx.go | 4 +- wallet/import.go | 6 +- wallet/interface.go | 7 +- wallet/notifications.go | 12 ++- wallet/utxos.go | 4 +- wallet/wallet.go | 131 ++++++++++++++++++++++----------- 10 files changed, 136 insertions(+), 67 deletions(-) diff --git a/rpc/rpcserver/server.go b/rpc/rpcserver/server.go index 3700f246f1..35adf38a95 100644 --- a/rpc/rpcserver/server.go +++ b/rpc/rpcserver/server.go @@ -188,7 +188,10 @@ func (s *walletServer) Accounts(ctx context.Context, req *pb.AccountsRequest) ( func (s *walletServer) RenameAccount(ctx context.Context, req *pb.RenameAccountRequest) ( *pb.RenameAccountResponse, error) { - err := s.wallet.RenameAccountDeprecated(waddrmgr.KeyScopeBIP0044, req.GetAccountNumber(), req.GetNewName()) + err := s.wallet.RenameAccountDeprecated( + waddrmgr.KeyScopeBIP0044, req.GetAccountNumber(), + req.GetNewName(), + ) if err != nil { return nil, translateError(err) } diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 4a612f5562..2cb707f5ef 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -1566,7 +1566,9 @@ func (s *ScopedKeyManager) CanAddAccount() error { // differs from the NewAccount method in that this method takes the account // number *directly*, rather than taking a string name for the account, then // mapping that to the next highest account number. -func (s *ScopedKeyManager) NewRawAccount(ns walletdb.ReadWriteBucket, number uint32) error { +func (s *ScopedKeyManager) NewRawAccount( + ns walletdb.ReadWriteBucket, number uint32) error { + s.mtx.Lock() defer s.mtx.Unlock() diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 278017d6dc..56245949fd 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -196,8 +196,8 @@ func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { return err } - // Now, iterate through each key scope to assemble the final list - // of accounts with their properties and balances. + // Now, iterate through all key scopes to assemble the final + // list of accounts with their properties and balances. for _, scopeMgr := range scopes { scope := scopeMgr.Scope() accountBalances := scopedBalances[scope] diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 3e2ee59a41..5144477885 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -41,8 +41,8 @@ func TestNewAccount(t *testing.T) { ) require.NoError(t, err, "unable to create new account") - // The new account should be the first account created, so it should have - // an index of 1. + // The new account should be the first account created, so it should + // have an index of 1. require.Equal(t, uint32(1), account.AccountNumber, "expected account 1") // We should be able to retrieve the account by its name. @@ -251,7 +251,9 @@ func TestGetAccount(t *testing.T) { require.NoError(t, err) // We should be able to get the new account. - account, err := w.GetAccount(context.Background(), scope, testAccountName) + account, err := w.GetAccount( + context.Background(), scope, testAccountName, + ) require.NoError(t, err) require.Equal(t, testAccountName, account.AccountName) require.Equal(t, uint32(1), account.AccountNumber) @@ -664,12 +666,17 @@ func TestFetchAccountBalances(t *testing.T) { // Add UTXOs. addTestUTXOForBalance(t, w, waddrmgr.KeyScopeBIP0084, 0, 100) addTestUTXOForBalance(t, w, waddrmgr.KeyScopeBIP0084, 1, 200) - addTestUTXOForBalance(t, w, waddrmgr.KeyScopeBIP0049Plus, 1, 300) + addTestUTXOForBalance( + t, w, waddrmgr.KeyScopeBIP0049Plus, 1, 300, + ) // Update sync state. - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - bs := &waddrmgr.BlockStamp{Height: 1} + err = walletdb.Update( + w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket( + waddrmgrNamespaceKey, + ) + bs := &waddrmgr.BlockStamp{Height: 1} return w.addrStore.SetSyncedTo(addrmgrNs, bs) }) @@ -733,8 +740,9 @@ func TestFetchAccountBalances(t *testing.T) { var balances scopedBalances - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - var err error + err := walletdb.View( + w.db, func(tx walletdb.ReadTx) error { + var err error balances, err = w.fetchAccountBalances( tx, tc.filters..., diff --git a/wallet/createtx.go b/wallet/createtx.go index c0a18d4954..4bedee1956 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -400,7 +400,9 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, continue } - scopedMgr, addrAcct, err := w.addrStore.AddrAccount(addrmgrNs, addrs[0]) + scopedMgr, addrAcct, err := w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) if err != nil { continue } diff --git a/wallet/import.go b/wallet/import.go index 99e7108b30..3403737998 100644 --- a/wallet/import.go +++ b/wallet/import.go @@ -189,7 +189,8 @@ func (w *Wallet) validateExtendedPubKey(pubKey *hdkeychain.ExtendedKey, return nil } -// ImportAccountDeprecated imports an account backed by an account extended public key. +// ImportAccountDeprecated imports an account backed by an account extended +// public key. // The master key fingerprint denotes the fingerprint of the root key // corresponding to the account public key (also known as the key with // derivation path m/). This may be required by some hardware wallets for proper @@ -208,7 +209,8 @@ func (w *Wallet) validateExtendedPubKey(pubKey *hdkeychain.ExtendedKey, // distinction between the traditional BIP-0049 address schema (nested witness // pubkeys everywhere) and our own BIP-0049Plus address schema (nested // externally, witness internally). -func (w *Wallet) ImportAccountDeprecated(name string, accountPubKey *hdkeychain.ExtendedKey, +func (w *Wallet) ImportAccountDeprecated( + name string, accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, addrType *waddrmgr.AddressType) ( *waddrmgr.AccountProperties, error) { diff --git a/wallet/interface.go b/wallet/interface.go index ef21d5896d..27f2761cfa 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -120,11 +120,14 @@ type Interface interface { RenameAccountDeprecated(scope waddrmgr.KeyScope, account uint32, newName string) error - // ImportAccountDeprecated imports an account backed by an extended public key. + // ImportAccountDeprecated imports an account backed by an extended + // public key. + // // This creates a watch-only account. // // Deprecated: Use AccountManager.ImportAccount instead. - ImportAccountDeprecated(name string, accountPubKey *hdkeychain.ExtendedKey, + ImportAccountDeprecated(name string, + accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, addrType *waddrmgr.AddressType, ) (*waddrmgr.AccountProperties, error) diff --git a/wallet/notifications.go b/wallet/notifications.go index d8086b218c..9441dd617e 100644 --- a/wallet/notifications.go +++ b/wallet/notifications.go @@ -157,7 +157,9 @@ func makeTxSummary(dbtx walletdb.ReadTx, w *Wallet, details *wtxmgr.TxDetails) T func totalBalances(dbtx walletdb.ReadTx, w *Wallet, m map[uint32]btcutil.Amount) error { addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) - unspent, err := w.txStore.UnspentOutputs(dbtx.ReadBucket(wtxmgrNamespaceKey)) + unspent, err := w.txStore.UnspentOutputs( + dbtx.ReadBucket(wtxmgrNamespaceKey), + ) if err != nil { return err } @@ -167,7 +169,9 @@ func totalBalances(dbtx walletdb.ReadTx, w *Wallet, m map[uint32]btcutil.Amount) _, addrs, _, err := txscript.ExtractPkScriptAddrs( output.PkScript, w.chainParams) if err == nil && len(addrs) > 0 { - _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) + _, outputAcct, err = w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) } if err == nil { _, ok := m[outputAcct] @@ -234,7 +238,9 @@ func (s *NotificationServer) notifyUnminedTransaction(dbtx walletdb.ReadTx, unminedTxs := []TransactionSummary{makeTxSummary(dbtx, s.wallet, details)} - unminedHashes, err := s.wallet.txStore.UnminedTxHashes(dbtx.ReadBucket(wtxmgrNamespaceKey)) + unminedHashes, err := s.wallet.txStore.UnminedTxHashes( + dbtx.ReadBucket(wtxmgrNamespaceKey), + ) if err != nil { log.Errorf("Cannot fetch unmined transaction hashes: %v", err) return diff --git a/wallet/utxos.go b/wallet/utxos.go index 1e7ba09c03..3b5fea43e0 100644 --- a/wallet/utxos.go +++ b/wallet/utxos.go @@ -73,7 +73,9 @@ func (w *Wallet) UnspentOutputs(policy OutputSelectionPolicy) ([]*TransactionOut continue } - _, outputAcct, err := w.addrStore.AddrAccount(addrmgrNs, addrs[0]) + _, outputAcct, err := w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) if err != nil { return err } diff --git a/wallet/wallet.go b/wallet/wallet.go index a4a31ed39c..96211527f2 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -467,7 +467,9 @@ func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { return err } - return w.addrStore.SetBirthdayBlock(ns, *birthdayStamp, true) + return w.addrStore.SetBirthdayBlock( + ns, *birthdayStamp, true, + ) }) if err != nil { return fmt.Errorf("unable to persist initial sync "+ @@ -814,7 +816,9 @@ func (w *Wallet) recovery(chainClient chain.Interface, // point to become desyncronized. Refactor so // that this cannot happen. for _, block := range blocks { - err := w.addrStore.SetSyncedTo(ns, block) + err := w.addrStore.SetSyncedTo( + ns, block, + ) if err != nil { return err } @@ -1431,7 +1435,10 @@ out: case req := <-w.unlockRequests: err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - return w.addrStore.Unlock(addrmgrNs, req.passphrase) + + return w.addrStore.Unlock( + addrmgrNs, req.passphrase, + ) }) if err != nil { req.err <- err @@ -1630,10 +1637,12 @@ func (w *Wallet) AccountAddresses(account uint32) (addrs []btcutil.Address, err err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - return w.addrStore.ForEachAccountAddress(addrmgrNs, account, func(maddr waddrmgr.ManagedAddress) error { - addrs = append(addrs, maddr.Address()) - return nil - }) + return w.addrStore.ForEachAccountAddress( + addrmgrNs, account, + func(maddr waddrmgr.ManagedAddress) error { + addrs = append(addrs, maddr.Address()) + return nil + }) }) return } @@ -1726,7 +1735,9 @@ func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balan _, addrs, _, err := txscript.ExtractPkScriptAddrs( output.PkScript, w.chainParams) if err == nil && len(addrs) > 0 { - _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) + _, outputAcct, err = w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) } if err != nil || outputAcct != account { continue @@ -2295,8 +2306,10 @@ func (w *Wallet) ListTransactions(from, count int) ([]btcjson.ListTransactionsRe return true, nil } - jsonResults := listTransactions(tx, &details[i], - w.addrStore, syncBlock.Height, w.chainParams) + jsonResults := listTransactions( + tx, &details[i], w.addrStore, + syncBlock.Height, w.chainParams, + ) txList = append(txList, jsonResults...) if len(jsonResults) > 0 { @@ -2346,8 +2359,10 @@ func (w *Wallet) ListAddressTransactions(pkHashes map[string]struct{}) ([]btcjso continue } - jsonResults := listTransactions(tx, detail, - w.addrStore, syncBlock.Height, w.chainParams) + jsonResults := listTransactions( + tx, detail, w.addrStore, + syncBlock.Height, w.chainParams, + ) txList = append(txList, jsonResults...) continue loopDetails } @@ -2378,8 +2393,10 @@ func (w *Wallet) ListAllTransactions() ([]btcjson.ListTransactionsResult, error) // unsorted, but it will process mined transactions in the // reverse order they were marked mined. for i := len(details) - 1; i >= 0; i-- { - jsonResults := listTransactions(tx, &details[i], w.addrStore, - syncBlock.Height, w.chainParams) + jsonResults := listTransactions( + tx, &details[i], w.addrStore, + syncBlock.Height, w.chainParams, + ) txList = append(txList, jsonResults...) } return false, nil @@ -2791,7 +2808,9 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32, continue } if len(addrs) > 0 { - smgr, acct, err := w.addrStore.AddrAccount(addrmgrNs, addrs[0]) + smgr, acct, err := w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) if err == nil { s, err := smgr.AccountName(addrmgrNs, acct) if err == nil { @@ -2828,7 +2847,9 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32, spendable = true case txscript.MultiSigTy: for _, a := range addrs { - _, err := w.addrStore.Address(addrmgrNs, a) + _, err := w.addrStore.Address( + addrmgrNs, a, + ) if err == nil { continue } @@ -2885,7 +2906,9 @@ func (w *Wallet) ListLeasedOutputs() ([]*ListLeasedOutputResult, error) { } for _, output := range outputs { - details, err := w.txStore.TxDetails(ns, &output.Outpoint.Hash) + details, err := w.txStore.TxDetails( + ns, &output.Outpoint.Hash, + ) if err != nil { return err } @@ -2920,29 +2943,33 @@ func (w *Wallet) DumpPrivKeys() ([]string, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // Iterate over each active address, appending the private key to - // privkeys. - return w.addrStore.ForEachActiveAddress(addrmgrNs, func(addr btcutil.Address) error { - ma, err := w.addrStore.Address(addrmgrNs, addr) - if err != nil { - return err - } + return w.addrStore.ForEachActiveAddress( + addrmgrNs, func(addr btcutil.Address) error { + ma, err := w.addrStore.Address(addrmgrNs, addr) + if err != nil { + return err + } - // Only those addresses with keys needed. - pka, ok := ma.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return nil - } + // Only those addresses with keys needed. + pka, ok := ma.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil + } - wif, err := pka.ExportPrivKey() - if err != nil { - // It would be nice to zero out the array here. However, - // since strings in go are immutable, and we have no - // control over the caller I don't think we can. :( - return err - } - privkeys = append(privkeys, wif.String()) - return nil - }) + wif, err := pka.ExportPrivKey() + if err != nil { + // It would be nice to zero out the + // array here. However, since strings + // in go are immutable, and we have no + // control over the caller I don't + // think we can. :( + return err + } + + privkeys = append(privkeys, wif.String()) + + return nil + }) }) return privkeys, err } @@ -3108,10 +3135,14 @@ func (w *Wallet) SortedActivePaymentAddresses() ([]string, error) { err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - return w.addrStore.ForEachActiveAddress(addrmgrNs, func(addr btcutil.Address) error { - addrStrs = append(addrStrs, addr.EncodeAddress()) - return nil - }) + return w.addrStore.ForEachActiveAddress( + addrmgrNs, func(addr btcutil.Address) error { + addrStrs = append( + addrStrs, addr.EncodeAddress(), + ) + + return nil + }) }) if err != nil { return nil, err @@ -3323,6 +3354,7 @@ func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, stopHeight = -1 } + //nolint:lll rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { for i := range details { detail := &details[i] @@ -3352,7 +3384,9 @@ func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, return false, nil } - return w.txStore.RangeTransactions(txmgrNs, 0, stopHeight, rangeFn) + return w.txStore.RangeTransactions( + txmgrNs, 0, stopHeight, rangeFn, + ) }) return results, err } @@ -3400,7 +3434,9 @@ func (w *Wallet) TotalReceivedForAddr(addr btcutil.Address, minConf int32) (btcu return false, nil } - return w.txStore.RangeTransactions(txmgrNs, 0, stopHeight, rangeFn) + return w.txStore.RangeTransactions( + txmgrNs, 0, stopHeight, rangeFn, + ) }) return amount, err } @@ -3520,7 +3556,9 @@ func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, prevHash := &txIn.PreviousOutPoint.Hash prevIndex := txIn.PreviousOutPoint.Index - txDetails, err := w.txStore.TxDetails(txmgrNs, prevHash) + txDetails, err := w.txStore.TxDetails( + txmgrNs, prevHash, + ) if err != nil { return fmt.Errorf("cannot query previous transaction "+ "details for %v: %w", txIn.PreviousOutPoint, err) @@ -3537,6 +3575,8 @@ func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, // Set up our callbacks that we pass to txscript so it can // look up the appropriate keys and scripts by address. + // + //nolint:lll getKey := txscript.KeyClosure(func(addr btcutil.Address) (*btcec.PrivateKey, bool, error) { if len(additionalKeysByAddress) != 0 { addrStr := addr.EncodeAddress() @@ -3566,6 +3606,7 @@ func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, return key, pka.Compressed(), nil }) + //nolint:lll getScript := txscript.ScriptClosure(func(addr btcutil.Address) ([]byte, error) { // If keys were provided then we can only use the // redeem scripts provided with our inputs, too. From d12d4920f4c54262be6d190ccfef9a00ca602a5a Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Fri, 19 Sep 2025 02:01:33 +0000 Subject: [PATCH 027/691] wallet: generate benchmark samples sizes --- wallet/benchmark_helpers_test.go | 95 ++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 wallet/benchmark_helpers_test.go diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go new file mode 100644 index 0000000000..c1c8db96e0 --- /dev/null +++ b/wallet/benchmark_helpers_test.go @@ -0,0 +1,95 @@ +package wallet + +import ( + "fmt" +) + +// growthFunc defines how a benchmark parameter should scale with iteration +// index. It takes an iteration index i (0-based) and returns the parameter +// value for that iteration. This allows flexible configuration of benchmark +// data sizes with different growth patterns (linear, exponential, logarithmic, +// etc.). +type growthFunc func(i int) int + +// linearGrowth scales the parameter value linearly. +func linearGrowth(i int) int { + return 5 + (i * 5) +} + +// exponentialGrowth scales the parameter value exponentially. +func exponentialGrowth(i int) int { + return 1 << i +} + +// constantGrowth returns a constant value regardless of iteration. +func constantGrowth(i int) int { + return 0 +} + +// benchmarkDataSize represents different test data sizes for stress testing. +type benchmarkDataSize struct { + // numAccounts is the number of accounts to create. + numAccounts int + + // numUTXOs is the number of UTXOs to create. + numUTXOs int + + // maxAccounts is the maximum number of accounts in the benchmark + // series. + maxAccounts int + + // maxUTXOs is the maximum number of UTXOs in the benchmark series. + maxUTXOs int +} + +// name returns a dynamically generated benchmark name based on accounts and +// UTXOs. Uses dynamic padding based on maximum values for proper sorting in +// visualization tools. If numUTXOs is 0, it's omitted from the name. +func (b benchmarkDataSize) name() string { + accountDigits := len(fmt.Sprintf("%d", b.maxAccounts)) + + if b.numUTXOs == 0 { + return fmt.Sprintf("%0*d-Accounts", accountDigits, + b.numAccounts) + } + + utxoDigits := len(fmt.Sprintf("%d", b.maxUTXOs)) + + return fmt.Sprintf("%0*d-Accounts-%0*d-UTXOs", + accountDigits, b.numAccounts, utxoDigits, b.numUTXOs) +} + +// benchmarkConfig holds configuration for benchmark wallet setup. +type benchmarkConfig struct { + // accountGrowth is the function to use to grow the number of accounts. + accountGrowth growthFunc + + // utxoGrowth is the function to use to grow the number of UTXOs. + utxoGrowth growthFunc + + // maxIterations is the maximum number of iterations to run. + maxIterations int + + // startIndex is the index to start the benchmark at. + startIndex int +} + +// generateBenchmarkSizes creates benchmark data sizes programmatically. +func generateBenchmarkSizes(config benchmarkConfig) []benchmarkDataSize { + var sizes []benchmarkDataSize + + // Calculate maximum values for proper padding. + maxAccounts := config.accountGrowth(config.maxIterations) + maxUTXOs := config.utxoGrowth(config.maxIterations) + + for i := config.startIndex; i <= config.maxIterations; i++ { + sizes = append(sizes, benchmarkDataSize{ + numAccounts: config.accountGrowth(i), + numUTXOs: config.utxoGrowth(i), + maxAccounts: maxAccounts, + maxUTXOs: maxUTXOs, + }) + } + + return sizes +} From 73c069d5041411c8fc69d859b94cc3c25178f3a8 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Fri, 19 Sep 2025 02:02:19 +0000 Subject: [PATCH 028/691] wallet: setup benchmark wallet --- wallet/benchmark_helpers_test.go | 248 +++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index c1c8db96e0..5f21399435 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -2,6 +2,19 @@ package wallet import ( "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" ) // growthFunc defines how a benchmark parameter should scale with iteration @@ -93,3 +106,238 @@ func generateBenchmarkSizes(config benchmarkConfig) []benchmarkDataSize { return sizes } + +// benchmarkWalletConfig holds configuration for benchmark wallet setup. +type benchmarkWalletConfig struct { + // scopes is the key scopes to create accounts in. + scopes []waddrmgr.KeyScope + + // numAccounts is the number of accounts to create. + numAccounts int + + // numUTXOs is the number of UTXOs to create. + numUTXOs int + + // skipUTXOs skips UTXO creation for account-only benchmarks. + skipUTXOs bool +} + +// setupBenchmarkWallet creates a wallet with test data based on the provided +// configuration. It distributes accounts evenly across the specified scopes. +func setupBenchmarkWallet(t testing.TB, config benchmarkWalletConfig) *Wallet { + t.Helper() + + // Since testWallet requires a *testing.T, we can't pass the benchmark's + // *testing.B. Instead, we create a dummy *testing.T and manually fail + // the benchmark if the setup fails. + dummyT := &testing.T{} + w, cleanup := testWallet(dummyT) + t.Cleanup(cleanup) + require.False(t, dummyT.Failed(), "testWallet setup failed") + + addresses := createTestAccounts(t, w, config.scopes, config.numAccounts) + + if !config.skipUTXOs && config.numUTXOs > 0 { + createTestUTXOs(t, w, addresses, config.numUTXOs) + } + + return w +} + +// createTestAccounts creates test accounts across the specified key scopes +// and returns all generated addresses. +func createTestAccounts(t testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, + numAccounts int) []waddrmgr.ManagedAddress { + + t.Helper() + + var allAddresses []waddrmgr.ManagedAddress + + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + // Distribute accounts across the specified key scopes. + accountsPerScope := numAccounts / len(scopes) + remainder := numAccounts % len(scopes) + + for i, scope := range scopes { + scopeAccounts := accountsPerScope + if i < remainder { + // Distribute remainder accounts. + scopeAccounts++ + } + + err := createAccountsInScope( + w, tx, scope, scopeAccounts, i*accountsPerScope, + allAddresses, + ) + if err != nil { + return err + } + } + return nil + }) + + require.NoError(t, err, "failed to create test accounts: %v", err) + + return allAddresses +} + +// createAccountsInScope creates accounts within a specific scope with unique +// naming across scopes. +func createAccountsInScope(w *Wallet, tx walletdb.ReadWriteTx, + scope waddrmgr.KeyScope, numAccounts, offset int, + allAddresses []waddrmgr.ManagedAddress) error { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return err + } + + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + for i := range numAccounts { + name := fmt.Sprintf("bench-scope-%d-%d-account-%d", + scope.Purpose, scope.Coin, offset+i) + + account, err := manager.NewAccount(addrmgrNs, name) + if err != nil { + return err + } + + addrs, err := manager.NextExternalAddresses( + addrmgrNs, account, 5, + ) + if err != nil { + return err + } + + allAddresses = append(allAddresses, addrs...) + } + + return nil +} + +// createTestUTXOs creates the specified number of test UTXOs using the provided +// addresses for benchmark data setup. +func createTestUTXOs(t testing.TB, w *Wallet, + addresses []waddrmgr.ManagedAddress, numUTXOs int) { + + t.Helper() + + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + msgTx := TstTx.MsgTx() + + for i := 0; i < numUTXOs && i < len(addresses); i++ { + newMsgTx := wire.NewMsgTx(msgTx.Version) + addr := addresses[i%len(addresses)] + + pkScript, err := txscript.PayToAddrScript( + addr.Address(), + ) + if err != nil { + return err + } + + // Add a dummy tx output to make it valid. + amount := btcutil.Amount(100000 + i*1000) + txOut := wire.NewTxOut(int64(amount), pkScript) + newMsgTx.AddTxOut(txOut) + + // Add a dummy tx input to make it valid. + prevHash := chainhash.Hash{} + prevHash[0] = byte(i) + txIn := wire.NewTxIn( + wire.NewOutPoint(&prevHash, 0), nil, nil, + ) + newMsgTx.AddTxIn(txIn) + + rec, err := wtxmgr.NewTxRecordFromMsgTx( + newMsgTx, time.Now(), + ) + if err != nil { + return err + } + + blockMeta := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: chainhash.Hash{}, + Height: 1, + }, + Time: time.Now(), + } + + err = w.txStore.InsertTx(txmgrNs, rec, blockMeta) + if err != nil { + return err + } + + // Mark the output as unspent. + if err = w.txStore.AddCredit( + txmgrNs, rec, blockMeta, 0, false, + ); err != nil { + return err + } + } + + return nil + }) + + require.NoError(t, err, "failed to create test UTXOs: %v", err) +} + +// generateAccountName generates a consistent account name and number for +// benchmarking based on the given number of accounts and scopes. It returns +// the first account name and number in the last scope, which provides a good +// heuristic case for evaluating search performance. +func generateAccountName(numAccounts int, + scopes []waddrmgr.KeyScope) (string, uint32) { + + accountsPerScope := numAccounts / len(scopes) + + lastScopeIndex := len(scopes) - 1 + lastScope := scopes[lastScopeIndex] + lastScopeOffset := lastScopeIndex * accountsPerScope + + accountName := fmt.Sprintf("bench-scope-%d-%d-account-%d", + lastScope.Purpose, lastScope.Coin, lastScopeOffset) + + // Account numbers start from 1, not 0. Account 0 is reserved for + // "default". + accountNumber := uint32(lastScopeOffset + 1) + + return accountName, accountNumber +} + +// generateTestExtendedKey generates a test extended public key for benchmarking +// ImportAccount operations. It uses a deterministic seed based on the +// iteration index to ensure consistent results across benchmark runs. +func generateTestExtendedKey(t testing.TB, + i int) (*hdkeychain.ExtendedKey, uint32, waddrmgr.AddressType) { + + t.Helper() + + // Use a simple deterministic seed based on iteration index. + seed := make([]byte, 32) + for j := range seed { + seed[j] = byte(i + j) + } + + // Create master key from seed. + masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.TestNet3Params) + require.NoError(t, err) + + // Derive account key for BIP0084 (m/84'/1'/i'). + purpose, err := masterKey.Derive(hdkeychain.HardenedKeyStart + 84) + require.NoError(t, err) + + coin, err := purpose.Derive(hdkeychain.HardenedKeyStart + 1) + require.NoError(t, err) + + account, err := coin.Derive(hdkeychain.HardenedKeyStart + uint32(i)) + require.NoError(t, err) + + accountPubKey, err := account.Neuter() + require.NoError(t, err) + + return accountPubKey, uint32(i), waddrmgr.WitnessPubKey +} From 4e28f8fcc1f848a8bee82304107406b113b4d4a7 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Sep 2025 03:21:51 +0000 Subject: [PATCH 029/691] wallet: benchmark `ListAccountsByScope` API --- wallet/account_manager_benchmark_test.go | 62 ++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 wallet/account_manager_benchmark_test.go diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go new file mode 100644 index 0000000000..1651ef25fb --- /dev/null +++ b/wallet/account_manager_benchmark_test.go @@ -0,0 +1,62 @@ +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// BenchmarkListAccountsByScopeAPI benchmarks ListAccountsByScope API and a +// deprecated variant of it using same key scope and identical test data across +// multiple dataset sizes. Test names start with dataset size to group API +// comparisons for benchstat analysis. +func BenchmarkListAccountsByScopeAPI(b *testing.B) { + benchmarkSizes := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: exponentialGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + + for _, size := range benchmarkSizes { + b.Run(size.name()+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := w.Accounts(scopes[0]) + require.NoError(b, err) + } + }) + + b.Run(size.name()+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := w.ListAccountsByScope( + b.Context(), scopes[0], + ) + require.NoError(b, err) + } + }) + } +} From 5c9e5a6a8a8e2ae2f1f7a18d3927ff9a46a02e43 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Sep 2025 03:37:19 +0000 Subject: [PATCH 030/691] wallet: benchmark `ListAccounts` API --- wallet/account_manager_benchmark_test.go | 52 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 31 ++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index 1651ef25fb..5357cdd4c4 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -60,3 +60,55 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { }) } } + +// BenchmarkListAccountsAPI benchmarks ListAccounts API and a deprecated variant +// of it using same key scopes and identical test data across multiple dataset +// sizes. Test names start with dataset size to group API comparisons for +// benchstat analysis. +func BenchmarkListAccountsAPI(b *testing.B) { + benchmarkSizes := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: exponentialGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := waddrmgr.DefaultKeyScopes + + for _, size := range benchmarkSizes { + b.Run(size.name()+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := listAccountsDeprecated(w) + require.NoError(b, err) + } + }) + + b.Run(size.name()+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := w.ListAccounts(b.Context()) + require.NoError(b, err) + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 5f21399435..2e80211bbf 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -341,3 +341,34 @@ func generateTestExtendedKey(t testing.TB, return accountPubKey, uint32(i), waddrmgr.WitnessPubKey } + +// listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same +// contract as ListAccounts by calling Accounts API across all active key scopes +// and aggregating the results. +func listAccountsDeprecated(w *Wallet) (*AccountsResult, error) { + var ( + allAccounts []AccountResult + finalBlockHash chainhash.Hash + finalBlockHeight int32 + scopeManagers = w.addrStore.ActiveScopedKeyManagers() + ) + + for _, scopeMgr := range scopeManagers { + scope := scopeMgr.Scope() + result, err := w.Accounts(scope) + if err != nil { + return nil, err + } + + allAccounts = append(allAccounts, result.Accounts...) + + finalBlockHash = result.CurrentBlockHash + finalBlockHeight = result.CurrentBlockHeight + } + + return &AccountsResult{ + Accounts: allAccounts, + CurrentBlockHash: finalBlockHash, + CurrentBlockHeight: finalBlockHeight, + }, nil +} From 8596ab6e0b4f4b44540c189b6cdb3d6f59f803f0 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Sep 2025 03:50:14 +0000 Subject: [PATCH 031/691] wallet: benchmark `ListAccountsByName` API --- wallet/account_manager_benchmark_test.go | 58 +++++++++++++++++ wallet/benchmark_helpers_test.go | 81 ++++++++++++------------ 2 files changed, 98 insertions(+), 41 deletions(-) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index 5357cdd4c4..fbef9d0a01 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -112,3 +112,61 @@ func BenchmarkListAccountsAPI(b *testing.B) { }) } } + +// BenchmarkListAccountsByNameAPI benchmarks ListAccountsByName API and a +// deprecated variant of it using same key scopes and identical test data across +// multiple dataset sizes. Test names start with dataset size to group API +// comparisons for benchstat analysis. +func BenchmarkListAccountsByNameAPI(b *testing.B) { + benchmarkSizes := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: exponentialGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := waddrmgr.DefaultKeyScopes + + for _, size := range benchmarkSizes { + accountName, _ := generateAccountName(size.numAccounts, scopes) + + b.Run(size.name()+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := listAccountsByNameDeprecated( + w, accountName, + ) + require.NoError(b, err) + } + }) + + b.Run(size.name()+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := w.ListAccountsByName( + b.Context(), accountName, + ) + require.NoError(b, err) + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 2e80211bbf..25f6d3b170 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -6,8 +6,6 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -34,11 +32,6 @@ func exponentialGrowth(i int) int { return 1 << i } -// constantGrowth returns a constant value regardless of iteration. -func constantGrowth(i int) int { - return 0 -} - // benchmarkDataSize represents different test data sizes for stress testing. type benchmarkDataSize struct { // numAccounts is the number of accounts to create. @@ -308,40 +301,6 @@ func generateAccountName(numAccounts int, return accountName, accountNumber } -// generateTestExtendedKey generates a test extended public key for benchmarking -// ImportAccount operations. It uses a deterministic seed based on the -// iteration index to ensure consistent results across benchmark runs. -func generateTestExtendedKey(t testing.TB, - i int) (*hdkeychain.ExtendedKey, uint32, waddrmgr.AddressType) { - - t.Helper() - - // Use a simple deterministic seed based on iteration index. - seed := make([]byte, 32) - for j := range seed { - seed[j] = byte(i + j) - } - - // Create master key from seed. - masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.TestNet3Params) - require.NoError(t, err) - - // Derive account key for BIP0084 (m/84'/1'/i'). - purpose, err := masterKey.Derive(hdkeychain.HardenedKeyStart + 84) - require.NoError(t, err) - - coin, err := purpose.Derive(hdkeychain.HardenedKeyStart + 1) - require.NoError(t, err) - - account, err := coin.Derive(hdkeychain.HardenedKeyStart + uint32(i)) - require.NoError(t, err) - - accountPubKey, err := account.Neuter() - require.NoError(t, err) - - return accountPubKey, uint32(i), waddrmgr.WitnessPubKey -} - // listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same // contract as ListAccounts by calling Accounts API across all active key scopes // and aggregating the results. @@ -372,3 +331,43 @@ func listAccountsDeprecated(w *Wallet) (*AccountsResult, error) { CurrentBlockHeight: finalBlockHeight, }, nil } + +// listAccountsByNameDeprecated wraps the deprecated Accounts API to satisfy the +// same contract as ListAccountsByName by calling Accounts API across all active +// key scopes, filtering by account name, and aggregating the results. +func listAccountsByNameDeprecated(w *Wallet, + name string) (*AccountsResult, error) { + + var ( + matchingAccounts []AccountResult + finalBlockHash chainhash.Hash + finalBlockHeight int32 + scopeManagers = w.addrStore.ActiveScopedKeyManagers() + ) + + for _, scopeMgr := range scopeManagers { + scope := scopeMgr.Scope() + result, err := w.Accounts(scope) + if err != nil { + return nil, err + } + + // Filter accounts by name from this scope's results. + for _, account := range result.Accounts { + if account.AccountName == name { + matchingAccounts = append( + matchingAccounts, account, + ) + } + } + + finalBlockHash = result.CurrentBlockHash + finalBlockHeight = result.CurrentBlockHeight + } + + return &AccountsResult{ + Accounts: matchingAccounts, + CurrentBlockHash: finalBlockHash, + CurrentBlockHeight: finalBlockHeight, + }, nil +} From 574426cf19850bc74af318366fe47b3040c66a64 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 30 Sep 2025 12:15:42 +0000 Subject: [PATCH 032/691] wallet: fix proposed linting issues --- wallet/account_manager_benchmark_test.go | 4 +++ wallet/benchmark_helpers_test.go | 38 ++++++++++++++---------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index fbef9d0a01..b6aafd99a2 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -34,6 +34,7 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := w.Accounts(scopes[0]) require.NoError(b, err) @@ -51,6 +52,7 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := w.ListAccountsByScope( b.Context(), scopes[0], @@ -88,6 +90,7 @@ func BenchmarkListAccountsAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := listAccountsDeprecated(w) require.NoError(b, err) @@ -105,6 +108,7 @@ func BenchmarkListAccountsAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := w.ListAccounts(b.Context()) require.NoError(b, err) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 25f6d3b170..853ba04c4a 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -2,6 +2,7 @@ package wallet import ( "fmt" + "strconv" "testing" "time" @@ -52,14 +53,14 @@ type benchmarkDataSize struct { // UTXOs. Uses dynamic padding based on maximum values for proper sorting in // visualization tools. If numUTXOs is 0, it's omitted from the name. func (b benchmarkDataSize) name() string { - accountDigits := len(fmt.Sprintf("%d", b.maxAccounts)) + accountDigits := len(strconv.Itoa(b.maxAccounts)) if b.numUTXOs == 0 { return fmt.Sprintf("%0*d-Accounts", accountDigits, b.numAccounts) } - utxoDigits := len(fmt.Sprintf("%d", b.maxUTXOs)) + utxoDigits := len(strconv.Itoa(b.maxUTXOs)) return fmt.Sprintf("%0*d-Accounts-%0*d-UTXOs", accountDigits, b.numAccounts, utxoDigits, b.numUTXOs) @@ -117,21 +118,23 @@ type benchmarkWalletConfig struct { // setupBenchmarkWallet creates a wallet with test data based on the provided // configuration. It distributes accounts evenly across the specified scopes. -func setupBenchmarkWallet(t testing.TB, config benchmarkWalletConfig) *Wallet { - t.Helper() +func setupBenchmarkWallet(tb testing.TB, config benchmarkWalletConfig) *Wallet { + tb.Helper() // Since testWallet requires a *testing.T, we can't pass the benchmark's // *testing.B. Instead, we create a dummy *testing.T and manually fail // the benchmark if the setup fails. dummyT := &testing.T{} w, cleanup := testWallet(dummyT) - t.Cleanup(cleanup) - require.False(t, dummyT.Failed(), "testWallet setup failed") + tb.Cleanup(cleanup) + require.False(tb, dummyT.Failed(), "testWallet setup failed") - addresses := createTestAccounts(t, w, config.scopes, config.numAccounts) + addresses := createTestAccounts( + tb, w, config.scopes, config.numAccounts, + ) if !config.skipUTXOs && config.numUTXOs > 0 { - createTestUTXOs(t, w, addresses, config.numUTXOs) + createTestUTXOs(tb, w, addresses, config.numUTXOs) } return w @@ -139,10 +142,10 @@ func setupBenchmarkWallet(t testing.TB, config benchmarkWalletConfig) *Wallet { // createTestAccounts creates test accounts across the specified key scopes // and returns all generated addresses. -func createTestAccounts(t testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, +func createTestAccounts(tb testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, numAccounts int) []waddrmgr.ManagedAddress { - t.Helper() + tb.Helper() var allAddresses []waddrmgr.ManagedAddress @@ -166,10 +169,11 @@ func createTestAccounts(t testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, return err } } + return nil }) - require.NoError(t, err, "failed to create test accounts: %v", err) + require.NoError(tb, err, "failed to create test accounts: %v", err) return allAddresses } @@ -211,10 +215,10 @@ func createAccountsInScope(w *Wallet, tx walletdb.ReadWriteTx, // createTestUTXOs creates the specified number of test UTXOs using the provided // addresses for benchmark data setup. -func createTestUTXOs(t testing.TB, w *Wallet, +func createTestUTXOs(tb testing.TB, w *Wallet, addresses []waddrmgr.ManagedAddress, numUTXOs int) { - t.Helper() + tb.Helper() err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) @@ -265,9 +269,10 @@ func createTestUTXOs(t testing.TB, w *Wallet, } // Mark the output as unspent. - if err = w.txStore.AddCredit( + err = w.txStore.AddCredit( txmgrNs, rec, blockMeta, 0, false, - ); err != nil { + ) + if err != nil { return err } } @@ -275,7 +280,7 @@ func createTestUTXOs(t testing.TB, w *Wallet, return nil }) - require.NoError(t, err, "failed to create test UTXOs: %v", err) + require.NoError(tb, err, "failed to create test UTXOs: %v", err) } // generateAccountName generates a consistent account name and number for @@ -314,6 +319,7 @@ func listAccountsDeprecated(w *Wallet) (*AccountsResult, error) { for _, scopeMgr := range scopeManagers { scope := scopeMgr.Scope() + result, err := w.Accounts(scope) if err != nil { return nil, err From 13fd76188bda5079ad7c1d46fdc34745b5f4de85 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Sep 2025 04:03:59 +0000 Subject: [PATCH 033/691] wallet: benchmark `NewAccount` API --- wallet/account_manager_benchmark_test.go | 69 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 5 ++ 2 files changed, 74 insertions(+) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index b6aafd99a2..66e53eadb5 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -1,6 +1,7 @@ package wallet import ( + "fmt" "testing" "github.com/btcsuite/btcwallet/waddrmgr" @@ -174,3 +175,71 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { }) } } + +// BenchmarkNewAccountAPI benchmarks NewAccount API and NextAccount API using +// identical account creation operations across multiple dataset sizes. Test +// names start with dataset size to group API comparisons for benchstat +// analysis. +func BenchmarkNewAccountAPI(b *testing.B) { + benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: constantGrowth, + maxIterations: 10, + startIndex: 0, + }) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + + for _, size := range benchmarkSizes { + b.Run(size.name()+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + skipUTXOs: true, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + count := 0 + for b.Loop() { + // Generate a unique account name for each + // iteration to ensure the idempotent nature of + // the benchmark. + accountName := fmt.Sprintf("new-account-%d", + count) + + _, err := w.NextAccount(scopes[0], accountName) + require.NoError(b, err) + count++ + } + }) + + b.Run(size.name()+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + skipUTXOs: true, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + count := 0 + for b.Loop() { + // Generate a unique account name for each + // iteration to ensure the idempotent nature of + // the benchmark. + accountName := fmt.Sprintf("new-account-%d", + count) + + _, err := w.NewAccount( + b.Context(), scopes[0], accountName, + ) + require.NoError(b, err) + count++ + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 853ba04c4a..aee1f29f8d 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -33,6 +33,11 @@ func exponentialGrowth(i int) int { return 1 << i } +// constantGrowth scales the parameter value to a constant value. +func constantGrowth(i int) int { + return 5 +} + // benchmarkDataSize represents different test data sizes for stress testing. type benchmarkDataSize struct { // numAccounts is the number of accounts to create. From caa3be116070c7e5e5064bbece6244ad8f111efe Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Sep 2025 04:18:01 +0000 Subject: [PATCH 034/691] wallet: benchmark `GetAccount` API --- wallet/account_manager_benchmark_test.go | 55 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 20 +++++++++ 2 files changed, 75 insertions(+) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index 66e53eadb5..9958087ee3 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -243,3 +243,58 @@ func BenchmarkNewAccountAPI(b *testing.B) { }) } } + +// BenchmarkGetAccountAPI benchmarks GetAccount API and a deprecated wrapper API +// using identical account lookups across multiple dataset sizes. Test names +// start with dataset size to group API comparisons for benchstat analysis. +func BenchmarkGetAccountAPI(b *testing.B) { + benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + accountGrowth: exponentialGrowth, + utxoGrowth: exponentialGrowth, + maxIterations: 14, + startIndex: 0, + }) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + + for _, size := range benchmarkSizes { + accountName, _ := generateAccountName(size.numAccounts, scopes) + + b.Run(size.name()+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := getAccountDeprecated( + w, scopes[0], accountName, + ) + require.NoError(b, err) + } + }) + + b.Run(size.name()+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := w.GetAccount( + b.Context(), scopes[0], accountName, + ) + require.NoError(b, err) + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index aee1f29f8d..fed05bd9ac 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -382,3 +382,23 @@ func listAccountsByNameDeprecated(w *Wallet, CurrentBlockHeight: finalBlockHeight, }, nil } + +// getAccountDeprecated wraps the deprecated Accounts API to satisfy the same +// contract as GetAccount by calling Accounts API across all active key scopes +// and filtering by account name. +func getAccountDeprecated(w *Wallet, scope waddrmgr.KeyScope, + accountName string) (*AccountResult, error) { + + result, err := w.Accounts(scope) + if err != nil { + return nil, err + } + + for _, account := range result.Accounts { + if account.AccountName == accountName { + return &account, nil + } + } + + return nil, fmt.Errorf("account '%s' not found", accountName) +} From e7e49551490df4232b0cac313cfb6670ab1fd8fd Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Sep 2025 04:34:19 +0000 Subject: [PATCH 035/691] wallet: benchmark `RenameAccount` API --- wallet/account_manager_benchmark_test.go | 82 ++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index 9958087ee3..ab88b285b4 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -298,3 +298,85 @@ func BenchmarkGetAccountAPI(b *testing.B) { }) } } + +// BenchmarkRenameAccountAPI benchmarks RenameAccount API and +// RenameAccountDeprecated API using identical rename operations across multiple +// dataset sizes. Test names start with dataset size to group API comparisons +// for benchstat analysis. +func BenchmarkRenameAccountAPI(b *testing.B) { + benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + accountGrowth: exponentialGrowth, + utxoGrowth: constantGrowth, + maxIterations: 11, + startIndex: 0, + }) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + + for _, size := range benchmarkSizes { + accountName, accountNumber := generateAccountName( + size.numAccounts, scopes, + ) + newName := accountName + "-renamed" + + b.Run(size.name()+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + skipUTXOs: true, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + count := 0 + for b.Loop() { + newName2 := fmt.Sprintf("%s-%d", newName, count) + err := w.RenameAccountDeprecated( + scopes[0], accountNumber, newName2, + ) + require.NoError(b, err) + + // Rename back to original to keep the benchmark + // idempotent. + err = w.RenameAccountDeprecated( + scopes[0], accountNumber, accountName, + ) + require.NoError(b, err) + count++ + } + }) + + b.Run(size.name()+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + skipUTXOs: true, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + count := 0 + for b.Loop() { + newName2 := fmt.Sprintf("%s-%d", newName, count) + err := w.RenameAccount( + b.Context(), scopes[0], accountName, + newName2, + ) + require.NoError(b, err) + + // Rename back to original to keep the benchmark + // idempotent. + err = w.RenameAccount( + b.Context(), scopes[0], newName2, + accountName, + ) + require.NoError(b, err) + + count++ + } + }) + } +} From ebff2404488516a1607e9f375bc040ebc7a9725d Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Sep 2025 05:18:00 +0000 Subject: [PATCH 036/691] wallet: benchmark `Balance` API --- wallet/account_manager_benchmark_test.go | 58 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 23 ++++++++++ 2 files changed, 81 insertions(+) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index ab88b285b4..07165f8647 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -380,3 +380,61 @@ func BenchmarkRenameAccountAPI(b *testing.B) { }) } } + +// BenchmarkGetBalanceAPI benchmarks Balance API and a deprecated wrapper API +// using identical balance lookups across multiple dataset sizes. Test names +// start with dataset size to group API comparisons for benchstat analysis. +func BenchmarkGetBalanceAPI(b *testing.B) { + benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: exponentialGrowth, + maxIterations: 14, + startIndex: 0, + }) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + confirmations := int32(0) + + for _, size := range benchmarkSizes { + accountName, _ := generateAccountName(size.numAccounts, scopes) + + b.Run(size.name()+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := getBalanceDeprecated( + w, scopes[0], accountName, + confirmations, + ) + require.NoError(b, err) + } + }) + + b.Run(size.name()+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _, err := w.Balance( + b.Context(), confirmations, scopes[0], + accountName, + ) + require.NoError(b, err) + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index fed05bd9ac..91287e840f 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -402,3 +402,26 @@ func getAccountDeprecated(w *Wallet, scope waddrmgr.KeyScope, return nil, fmt.Errorf("account '%s' not found", accountName) } + +// getBalanceDeprecated wraps the deprecated Accounts API to satisfy the same +// contract as GetBalance by calling Accounts API across all active key scopes +// and filtering by account name. +func getBalanceDeprecated(w *Wallet, scope waddrmgr.KeyScope, + accountName string, _ int32) (btcutil.Amount, error) { + + result, err := w.Accounts(scope) + if err != nil { + return 0, err + } + + for _, account := range result.Accounts { + if account.AccountName == accountName { + // The deprecated Accounts API doesn't support + // confirmation filtering. It always returns total + // balance. + return account.TotalBalance, nil + } + } + + return 0, fmt.Errorf("account '%s' not found", accountName) +} From 55c88002b99787bda78e27fec22079a182bf6b90 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Sep 2025 05:36:00 +0000 Subject: [PATCH 037/691] wallet: benchmark `ImportAccount` API --- wallet/account_manager_benchmark_test.go | 77 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 36 +++++++++++ 2 files changed, 113 insertions(+) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index 07165f8647..f8ab7378d1 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -438,3 +438,80 @@ func BenchmarkGetBalanceAPI(b *testing.B) { }) } } + +// BenchmarkImportAccountAPI benchmarks ImportAccount API and +// ImportAccountDeprecated API using identical account import operations +// across multiple dataset sizes. Test names start with dataset size to group +// API comparisons for benchstat analysis. +func BenchmarkImportAccountAPI(b *testing.B) { + benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: constantGrowth, + maxIterations: 10, + startIndex: 0, + }) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + dryRun := false + + for _, size := range benchmarkSizes { + accountKey, masterFingerprint, addrT := generateTestExtendedKey( + b, size.numAccounts, + ) + + b.Run(size.name()+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + skipUTXOs: true, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + count := 0 + for b.Loop() { + // Generate a unique account name for each + // iteration to ensure the idempotent nature of + // the benchmark. + accountName := fmt.Sprintf("import-account-%d", + count) + + _, err := w.ImportAccountDeprecated( + accountName, accountKey, + masterFingerprint, &addrT, + ) + require.NoError(b, err) + count++ + } + }) + + b.Run(size.name()+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + skipUTXOs: true, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + count := 0 + for b.Loop() { + // Generate a unique account name for each + // iteration to ensure the idempotent nature of + // the benchmark. + accountName := fmt.Sprintf("import-account-%d", + count) + + _, err := w.ImportAccount( + b.Context(), accountName, accountKey, + masterFingerprint, addrT, dryRun, + ) + require.NoError(b, err) + count++ + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 91287e840f..4b3684d202 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -7,6 +7,8 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -311,6 +313,40 @@ func generateAccountName(numAccounts int, return accountName, accountNumber } +// generateTestExtendedKey generates a test extended public key for benchmarking +// ImportAccount operations. It uses a deterministic seed based on the +// iteration index to ensure consistent results across benchmark runs. +func generateTestExtendedKey(t testing.TB, + i int) (*hdkeychain.ExtendedKey, uint32, waddrmgr.AddressType) { + + t.Helper() + + // Use a simple deterministic seed based on iteration index. + seed := make([]byte, 32) + for j := range seed { + seed[j] = byte(i + j) + } + + // Create master key from seed. + masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.TestNet3Params) + require.NoError(t, err) + + // Derive account key for BIP0084 (m/84'/1'/i'). + purpose, err := masterKey.Derive(hdkeychain.HardenedKeyStart + 84) + require.NoError(t, err) + + coin, err := purpose.Derive(hdkeychain.HardenedKeyStart + 1) + require.NoError(t, err) + + account, err := coin.Derive(hdkeychain.HardenedKeyStart + uint32(i)) + require.NoError(t, err) + + accountPubKey, err := account.Neuter() + require.NoError(t, err) + + return accountPubKey, uint32(i), waddrmgr.WitnessPubKey +} + // listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same // contract as ListAccounts by calling Accounts API across all active key scopes // and aggregating the results. From 9c33511e1a3c66bd38a0bb22bd14644f905f4c04 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 30 Sep 2025 17:12:52 +0000 Subject: [PATCH 038/691] wallet: fix linting issues --- wallet/account_manager_benchmark_test.go | 17 +++++++++++++++++ wallet/benchmark_helpers_test.go | 22 +++++++++++++--------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index f8ab7378d1..5cc2c91193 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -147,6 +147,7 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := listAccountsByNameDeprecated( w, accountName, @@ -166,6 +167,7 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := w.ListAccountsByName( b.Context(), accountName, @@ -201,6 +203,7 @@ func BenchmarkNewAccountAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + count := 0 for b.Loop() { // Generate a unique account name for each @@ -211,6 +214,7 @@ func BenchmarkNewAccountAPI(b *testing.B) { _, err := w.NextAccount(scopes[0], accountName) require.NoError(b, err) + count++ } }) @@ -226,6 +230,7 @@ func BenchmarkNewAccountAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + count := 0 for b.Loop() { // Generate a unique account name for each @@ -238,6 +243,7 @@ func BenchmarkNewAccountAPI(b *testing.B) { b.Context(), scopes[0], accountName, ) require.NoError(b, err) + count++ } }) @@ -270,6 +276,7 @@ func BenchmarkGetAccountAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := getAccountDeprecated( w, scopes[0], accountName, @@ -289,6 +296,7 @@ func BenchmarkGetAccountAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := w.GetAccount( b.Context(), scopes[0], accountName, @@ -329,6 +337,7 @@ func BenchmarkRenameAccountAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + count := 0 for b.Loop() { newName2 := fmt.Sprintf("%s-%d", newName, count) @@ -343,6 +352,7 @@ func BenchmarkRenameAccountAPI(b *testing.B) { scopes[0], accountNumber, accountName, ) require.NoError(b, err) + count++ } }) @@ -358,6 +368,7 @@ func BenchmarkRenameAccountAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + count := 0 for b.Loop() { newName2 := fmt.Sprintf("%s-%d", newName, count) @@ -408,6 +419,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := getBalanceDeprecated( w, scopes[0], accountName, @@ -428,6 +440,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + for b.Loop() { _, err := w.Balance( b.Context(), confirmations, scopes[0], @@ -469,6 +482,7 @@ func BenchmarkImportAccountAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + count := 0 for b.Loop() { // Generate a unique account name for each @@ -482,6 +496,7 @@ func BenchmarkImportAccountAPI(b *testing.B) { masterFingerprint, &addrT, ) require.NoError(b, err) + count++ } }) @@ -497,6 +512,7 @@ func BenchmarkImportAccountAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + count := 0 for b.Loop() { // Generate a unique account name for each @@ -510,6 +526,7 @@ func BenchmarkImportAccountAPI(b *testing.B) { masterFingerprint, addrT, dryRun, ) require.NoError(b, err) + count++ } }) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 4b3684d202..a4ca70538b 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -1,6 +1,7 @@ package wallet import ( + "errors" "fmt" "strconv" "testing" @@ -18,6 +19,8 @@ import ( "github.com/stretchr/testify/require" ) +var errAccountNotFound = errors.New("account not found") + // growthFunc defines how a benchmark parameter should scale with iteration // index. It takes an iteration index i (0-based) and returns the parameter // value for that iteration. This allows flexible configuration of benchmark @@ -316,10 +319,10 @@ func generateAccountName(numAccounts int, // generateTestExtendedKey generates a test extended public key for benchmarking // ImportAccount operations. It uses a deterministic seed based on the // iteration index to ensure consistent results across benchmark runs. -func generateTestExtendedKey(t testing.TB, +func generateTestExtendedKey(tb testing.TB, i int) (*hdkeychain.ExtendedKey, uint32, waddrmgr.AddressType) { - t.Helper() + tb.Helper() // Use a simple deterministic seed based on iteration index. seed := make([]byte, 32) @@ -329,20 +332,20 @@ func generateTestExtendedKey(t testing.TB, // Create master key from seed. masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.TestNet3Params) - require.NoError(t, err) + require.NoError(tb, err) // Derive account key for BIP0084 (m/84'/1'/i'). purpose, err := masterKey.Derive(hdkeychain.HardenedKeyStart + 84) - require.NoError(t, err) + require.NoError(tb, err) coin, err := purpose.Derive(hdkeychain.HardenedKeyStart + 1) - require.NoError(t, err) + require.NoError(tb, err) account, err := coin.Derive(hdkeychain.HardenedKeyStart + uint32(i)) - require.NoError(t, err) + require.NoError(tb, err) accountPubKey, err := account.Neuter() - require.NoError(t, err) + require.NoError(tb, err) return accountPubKey, uint32(i), waddrmgr.WitnessPubKey } @@ -394,6 +397,7 @@ func listAccountsByNameDeprecated(w *Wallet, for _, scopeMgr := range scopeManagers { scope := scopeMgr.Scope() + result, err := w.Accounts(scope) if err != nil { return nil, err @@ -436,7 +440,7 @@ func getAccountDeprecated(w *Wallet, scope waddrmgr.KeyScope, } } - return nil, fmt.Errorf("account '%s' not found", accountName) + return nil, fmt.Errorf("%w: %s", errAccountNotFound, accountName) } // getBalanceDeprecated wraps the deprecated Accounts API to satisfy the same @@ -459,5 +463,5 @@ func getBalanceDeprecated(w *Wallet, scope waddrmgr.KeyScope, } } - return 0, fmt.Errorf("account '%s' not found", accountName) + return 0, fmt.Errorf("%w: %s", errAccountNotFound, accountName) } From 329fc8c0938beaa0b36900ca0e8043640e7a3b88 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 28 Aug 2025 18:50:20 +0800 Subject: [PATCH 039/691] wallet: add interface `AddressManager` --- wallet/address_manager.go | 74 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 wallet/address_manager.go diff --git a/wallet/address_manager.go b/wallet/address_manager.go new file mode 100644 index 0000000000..dd1ab35be4 --- /dev/null +++ b/wallet/address_manager.go @@ -0,0 +1,74 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "context" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" +) + +// AddressProperty represents an address and its balance. +type AddressProperty struct { + // Address is the address. + Address btcutil.Address + + // Balance is the total unspent balance of the address, including both + // confirmed and unconfirmed funds. + Balance btcutil.Amount +} + +// AddressManager provides an interface for generating and inspecting wallet +// addresses and scripts. +type AddressManager interface { + // NewAddress returns a new address for the given account and address + // type. + // + // NOTE: This method should be used with caution. Unlike + // GetUnusedAddress, it does not scan for previously derived but unused + // addresses. Using this method repeatedly can create gaps in the + // address chain, which may negatively impact wallet recovery under + // BIP44. It is primarily intended for advanced use cases such as bulk + // address generation. + NewAddress(ctx context.Context, accountName string, + addrType waddrmgr.AddressType, + change bool) (btcutil.Address, error) + + // GetUnusedAddress returns the first, oldest, unused address by + // scanning forward from the start of the derivation path. This method + // is the recommended default for obtaining a new receiving address, as + // it prevents address reuse and avoids creating gaps in the address + // chain that could impact wallet recovery. + GetUnusedAddress(ctx context.Context, accountName string, + addrType waddrmgr.AddressType, change bool) ( + btcutil.Address, error) + + // AddressInfo returns detailed information about a managed address. If + // the address is not known to the wallet, an error is returned. + AddressInfo(ctx context.Context, + a btcutil.Address) (waddrmgr.ManagedAddress, error) + + // ListAddresses lists all addresses for a given account, including + // their balances. + ListAddresses(ctx context.Context, accountName string, + addrType waddrmgr.AddressType) ([]AddressProperty, error) + + // ImportPublicKey imports a single public key as a watch-only address. + ImportPublicKey(ctx context.Context, pubKey *btcec.PublicKey, + addrType waddrmgr.AddressType) error + + // ImportTaprootScript imports a taproot script for tracking and + // spending. + ImportTaprootScript(ctx context.Context, + tapscript waddrmgr.Tapscript) (waddrmgr.ManagedAddress, error) + + // ScriptForOutput returns the address, witness program, and redeem + // script for a given UTXO. + ScriptForOutput(ctx context.Context, output wire.TxOut) ( + waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) +} From 5ddb27c8c207c80ff4b410f61b0331ea03cb1c52 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 28 Aug 2025 20:45:42 +0800 Subject: [PATCH 040/691] wallet+rpc: rename `NewAddress` to `NewAddressDeprecated` --- rpc/legacyrpc/methods.go | 2 +- rpc/rpcserver/server.go | 2 +- wallet/account_manager_test.go | 4 +-- wallet/import_test.go | 2 +- wallet/interface.go | 8 +++-- wallet/wallet.go | 63 ++++++++++++++++++---------------- wallet/wallet_test.go | 2 +- 7 files changed, 46 insertions(+), 37 deletions(-) diff --git a/rpc/legacyrpc/methods.go b/rpc/legacyrpc/methods.go index 2f3440d654..74e26fcc78 100644 --- a/rpc/legacyrpc/methods.go +++ b/rpc/legacyrpc/methods.go @@ -702,7 +702,7 @@ func getNewAddress(icmd interface{}, w *wallet.Wallet) (interface{}, error) { if err != nil { return nil, err } - addr, err := w.NewAddress(account, keyScope) + addr, err := w.NewAddressDeprecated(account, keyScope) if err != nil { return nil, err } diff --git a/rpc/rpcserver/server.go b/rpc/rpcserver/server.go index 35adf38a95..ab91d8b323 100644 --- a/rpc/rpcserver/server.go +++ b/rpc/rpcserver/server.go @@ -234,7 +234,7 @@ func (s *walletServer) NextAddress(ctx context.Context, req *pb.NextAddressReque ) switch req.Kind { case pb.NextAddressRequest_BIP0044_EXTERNAL: - addr, err = s.wallet.NewAddress(req.Account, waddrmgr.KeyScopeBIP0044) + addr, err = s.wallet.NewAddressDeprecated(req.GetAccount(), waddrmgr.KeyScopeBIP0044) case pb.NextAddressRequest_BIP0044_INTERNAL: addr, err = s.wallet.NewChangeAddress(req.Account, waddrmgr.KeyScopeBIP0044) default: diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 5144477885..5df350f183 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -347,7 +347,7 @@ func TestBalance(t *testing.T) { require.Equal(t, btcutil.Amount(0), balance) // Now, we'll add a UTXO to the account. - addr, err := w.NewAddress(1, scope) + addr, err := w.NewAddressDeprecated(1, scope) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) require.NoError(t, err) @@ -577,7 +577,7 @@ func addTestUTXOForBalance(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, t.Helper() - addr, err := w.NewAddress(account, scope) + addr, err := w.NewAddressDeprecated(account, scope) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) diff --git a/wallet/import_test.go b/wallet/import_test.go index 4af5869d1d..16c489dc20 100644 --- a/wallet/import_test.go +++ b/wallet/import_test.go @@ -243,7 +243,7 @@ func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, require.Equal(t, uint32(0), acct2.ImportedKeyCount) // Test address derivation. - extAddr, err := w.NewAddress(acct1.AccountNumber, tc.expectedScope) + extAddr, err := w.NewAddressDeprecated(acct1.AccountNumber, tc.expectedScope) require.NoError(t, err) require.Equal(t, tc.expectedAddr, extAddr.String()) intAddr, err := w.NewChangeAddress(acct1.AccountNumber, tc.expectedScope) diff --git a/wallet/interface.go b/wallet/interface.go index 27f2761cfa..4766d6b56a 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -154,8 +154,12 @@ type Interface interface { CurrentAddress(account uint32, scope waddrmgr.KeyScope) ( btcutil.Address, error) - // NewAddress returns a new address for a given account and scope. - NewAddress(account uint32, scope waddrmgr.KeyScope) ( + // NewAddressDeprecated returns a new address for a given account and + // scope. + // + // Deprecated: This method will be removed in a future release. Use the + // AddressManager interface instead. + NewAddressDeprecated(account uint32, scope waddrmgr.KeyScope) ( btcutil.Address, error) // NewChangeAddress returns a new change address for a given account diff --git a/wallet/wallet.go b/wallet/wallet.go index 96211527f2..339082d2bb 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1797,7 +1797,7 @@ func (w *Wallet) CurrentAddress(account uint32, scope waddrmgr.KeyScope) (btcuti // If no address exists yet, create the first external // address. if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { - addr, props, err = w.newAddress( + addr, props, err = w.newAddressDeprecated( addrmgrNs, account, scope, ) } @@ -1807,7 +1807,7 @@ func (w *Wallet) CurrentAddress(account uint32, scope waddrmgr.KeyScope) (btcuti // Get next chained address if the last one has already been // used. if maddr.Used(addrmgrNs) { - addr, props, err = w.newAddress( + addr, props, err = w.newAddressDeprecated( addrmgrNs, account, scope, ) return err @@ -3152,8 +3152,8 @@ func (w *Wallet) SortedActivePaymentAddresses() ([]string, error) { return addrStrs, nil } -// NewAddress returns the next external chained address for a wallet. -func (w *Wallet) NewAddress(account uint32, +// NewAddressDeprecated returns the next external chained address for a wallet. +func (w *Wallet) NewAddressDeprecated(account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) { chainClient, err := w.requireChainClient() @@ -3177,7 +3177,10 @@ func (w *Wallet) NewAddress(account uint32, err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) var err error - addr, props, err = w.newAddress(addrmgrNs, account, scope) + + addr, props, err = w.newAddressDeprecated( + addrmgrNs, account, scope, + ) return err }) if err != nil { @@ -3195,30 +3198,6 @@ func (w *Wallet) NewAddress(account uint32, return addr, nil } -func (w *Wallet) newAddress(addrmgrNs walletdb.ReadWriteBucket, account uint32, - scope waddrmgr.KeyScope) (btcutil.Address, *waddrmgr.AccountProperties, error) { - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, nil, err - } - - // Get next address from wallet. - addrs, err := manager.NextExternalAddresses(addrmgrNs, account, 1) - if err != nil { - return nil, nil, err - } - - props, err := manager.AccountProperties(addrmgrNs, account) - if err != nil { - log.Errorf("Cannot fetch account properties for notification "+ - "after deriving next external address: %v", err) - return nil, nil, err - } - - return addrs[0].Address(), props, nil -} - // NewChangeAddress returns a new change address for a wallet. func (w *Wallet) NewChangeAddress(account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) { @@ -4179,6 +4158,32 @@ func (w *Wallet) NotificationServer() *NotificationServer { return w.NtfnServer } +func (w *Wallet) newAddressDeprecated(addrmgrNs walletdb.ReadWriteBucket, + account uint32, scope waddrmgr.KeyScope) (btcutil.Address, + *waddrmgr.AccountProperties, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, nil, err + } + + // Get next address from wallet. + addrs, err := manager.NextExternalAddresses(addrmgrNs, account, 1) + if err != nil { + return nil, nil, err + } + + props, err := manager.AccountProperties(addrmgrNs, account) + if err != nil { + log.Errorf("Cannot fetch account properties for notification "+ + "after deriving next external address: %v", err) + + return nil, nil, err + } + + return addrs[0].Address(), props, nil +} + // CreateWithCallback is the same as Create with an added callback that will be // called in the same transaction the wallet structure is initialized. func CreateWithCallback(db walletdb.DB, pubPass, privPass []byte, diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 3f8ce6c3be..ca513d58ab 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -507,7 +507,7 @@ func TestDuplicateAddressDerivation(t *testing.T) { eg.Go(func() error { addrs := make([]btcutil.Address, 10) for i := 0; i < 10; i++ { - addr, err := w.NewAddress( + addr, err := w.NewAddressDeprecated( 0, waddrmgr.KeyScopeBIP0084, ) if err != nil { From 356ca9497a818df76301a42eee2a825cb721ac83 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 28 Aug 2025 21:21:23 +0800 Subject: [PATCH 041/691] wallet: implement `NewAddress` This commit introduces a new, refactored `NewAddress` method and deprecates the old one. The new method provides a more intuitive and usable API for generating wallet addresses. The key improvements are: - It accepts a human-readable account name (string) and an address type (e.g., P2WKH, Taproot) instead of requiring the caller to manage internal account numbers and key scopes. - It consolidates the logic for creating both external (receiving) and internal (change) addresses into a single method, controlled by a boolean parameter. - It better encapsulates the logic for mapping high-level address types to the underlying cryptographic key scopes, improving separation of concerns. This change simplifies the wallet's public API, reduces the burden on the caller, and makes the code easier to maintain and extend. --- waddrmgr/scoped_manager.go | 44 +++++++++ wallet/address_manager.go | 143 ++++++++++++++++++++++++++++ wallet/address_manager_test.go | 165 +++++++++++++++++++++++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 wallet/address_manager_test.go diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 2cb707f5ef..5448bc352a 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -2571,3 +2571,47 @@ func (s *ScopedKeyManager) InvalidateAccountCache(account uint32) { defer s.mtx.Unlock() delete(s.acctInfo, account) } + +// NewAddress returns a new address for the given account. The `change` +// parameter dictates whether a change address (internal) or a receiving +// address (external) should be generated. The caller is responsible for +// providing a database transaction. The method first looks up the account +// number from the provided account name. It then uses the appropriate +// method (`NextInternalAddresses` or `NextExternalAddresses`) to derive the +// next chained address for that account. +func (s *ScopedKeyManager) NewAddress(addrmgrNs walletdb.ReadWriteBucket, + account string, change bool) (btcutil.Address, error) { + + accountNum, err := s.LookupAccount(addrmgrNs, account) + if err != nil { + return nil, err + } + + // TODO(yy): get rid of the list, we should always return one address + // here. + var addrs []ManagedAddress + + if change { + // Get next chained change address from wallet for account. + addrs, err = s.NextInternalAddresses(addrmgrNs, accountNum, 1) + if err != nil { + return nil, err + } + } else { + // Get next address from wallet. + addrs, err = s.NextExternalAddresses(addrmgrNs, accountNum, 1) + if err != nil { + return nil, err + } + } + + if len(addrs) == 0 { + return nil, managerError( + ErrAddressNotFound, "no addresses were generated", nil, + ) + } + + addr := addrs[0].Address() + + return addr, nil +} diff --git a/wallet/address_manager.go b/wallet/address_manager.go index dd1ab35be4..0b360b64b1 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -6,11 +6,25 @@ package wallet import ( "context" + "errors" + "fmt" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" +) + +var ( + // ErrUnknownAddrType is an error returned when a wallet function is + // called with an unknown address type. + ErrUnknownAddrType = errors.New("unknown address type") + + // ErrImportedAccountNoAddrGen is an error returned when a new address + // is requested for the default imported account within the wallet. + ErrImportedAccountNoAddrGen = errors.New("addresses cannot be " + + "generated for the default imported account") ) // AddressProperty represents an address and its balance. @@ -72,3 +86,132 @@ type AddressManager interface { ScriptForOutput(ctx context.Context, output wire.TxOut) ( waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) } + +// NewAddress returns the next external or internal address for the wallet +// dictated by the value of the `change` parameter. If change is true, then an +// internal address will be returned, otherwise an external address should be +// returned. The account parameter is the name of the account from which the +// address should be generated. The addrType parameter specifies the type of +// address to be generated. +func (w *Wallet) NewAddress(_ context.Context, accountName string, + addrType waddrmgr.AddressType, change bool) (btcutil.Address, error) { + + // Addresses cannot be derived from the catch-all imported accounts. + if accountName == waddrmgr.ImportedAddrAccountName { + return nil, ErrImportedAccountNoAddrGen + } + + keyScope, err := w.keyScopeFromAddrType(addrType) + if err != nil { + return nil, err + } + + chainClient, err := w.requireChainClient() + if err != nil { + return nil, err + } + + manager, err := w.addrStore.FetchScopedKeyManager(keyScope) + if err != nil { + return nil, err + } + + addr, err := w.newAddress(manager, accountName, change) + if err != nil { + return nil, err + } + + // Notify the rpc server about the newly created address. + err = chainClient.NotifyReceived([]btcutil.Address{addr}) + if err != nil { + return nil, err + } + + return addr, nil +} + +// keyScopeFromAddrType determines the appropriate key scope for a given +// address type. +// +// NOTE: While it may seem intuitive to iterate over the waddrmgr.ScopeAddrMap +// to act as a single source of truth, doing so is unsafe. The map contains +// ambiguities where a single address type, such as waddrmgr.WitnessPubKey, can +// map to multiple key scopes (e.g., KeyScopeBIP0084 and +// KeyScopeBIP0049Plus). Because map iteration in Go is non-deterministic, this +// would lead to unpredictable behavior. The switch statement is used here +// intentionally to enforce a clear, deterministic policy, ensuring that +// ambiguous types always resolve to their preferred, modern key scope. +func (w *Wallet) keyScopeFromAddrType( + addrType waddrmgr.AddressType) (waddrmgr.KeyScope, error) { + + // Map the requested address type to its key scope. + var addrKeyScope waddrmgr.KeyScope + switch addrType { + case waddrmgr.PubKeyHash: + addrKeyScope = waddrmgr.KeyScopeBIP0044 + + case waddrmgr.WitnessPubKey: + addrKeyScope = waddrmgr.KeyScopeBIP0084 + + case waddrmgr.NestedWitnessPubKey: + addrKeyScope = waddrmgr.KeyScopeBIP0049Plus + + case waddrmgr.TaprootPubKey: + addrKeyScope = waddrmgr.KeyScopeBIP0086 + + // The following address types are not supported by this function as + // they are not derived from a single public key using a key scope. + // They are typically imported or involve more complex script-based + // constructions. + case waddrmgr.Script, waddrmgr.RawPubKey, + waddrmgr.WitnessScript, waddrmgr.TaprootScript: + return waddrmgr.KeyScope{}, fmt.Errorf("%w: %v", + ErrUnknownAddrType, addrType) + default: + return waddrmgr.KeyScope{}, fmt.Errorf("%w: %v", + ErrUnknownAddrType, addrType) + } + + return addrKeyScope, nil +} + +// newAddress returns the next external chained address for a wallet. It +// wraps the database transaction and the call to the scoped key manager's +// NewAddress method. A mutex is used to protect the in-memory state of the +// address manager from concurrent access during address creation. +func (w *Wallet) newAddress(manager *waddrmgr.ScopedKeyManager, + accountName string, change bool) (btcutil.Address, error) { + + // The address manager uses OnCommit on the walletdb tx to update the + // in-memory state of the account state. But because the commit happens + // _after_ the account manager internal lock has been released, there + // is a chance for the address index to be accessed concurrently, even + // though the closure in OnCommit re-acquires the lock. To avoid this + // issue, we surround the whole address creation process with a lock. + // + // TODO(yy): remove the lock - we should separate the db action and + // memory cache. + w.newAddrMtx.Lock() + defer w.newAddrMtx.Unlock() + + var ( + addr btcutil.Address + err error + ) + + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + addr, err = manager.NewAddress(addrmgrNs, accountName, change) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + return addr, nil +} diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go new file mode 100644 index 0000000000..37cd1cb1e8 --- /dev/null +++ b/wallet/address_manager_test.go @@ -0,0 +1,165 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// TestKeyScopeFromAddrType tests the keyScopeFromAddrType method to ensure +// it correctly maps address types to their corresponding key scopes. +func TestKeyScopeFromAddrType(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + addrType waddrmgr.AddressType + expectedScope waddrmgr.KeyScope + expectedErr error + }{ + { + name: "pubkey hash", + addrType: waddrmgr.PubKeyHash, + expectedScope: waddrmgr.KeyScopeBIP0044, + expectedErr: nil, + }, + { + name: "witness pubkey", + addrType: waddrmgr.WitnessPubKey, + expectedScope: waddrmgr.KeyScopeBIP0084, + expectedErr: nil, + }, + { + name: "nested witness pubkey", + addrType: waddrmgr.NestedWitnessPubKey, + expectedScope: waddrmgr.KeyScopeBIP0049Plus, + expectedErr: nil, + }, + { + name: "taproot pubkey", + addrType: waddrmgr.TaprootPubKey, + expectedScope: waddrmgr.KeyScopeBIP0086, + expectedErr: nil, + }, + { + name: "unknown address type", + addrType: waddrmgr.WitnessScript, + expectedErr: ErrUnknownAddrType, + }, + } + + w := &Wallet{} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + scope, err := w.keyScopeFromAddrType(tc.addrType) + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expectedScope, scope) + }) + } +} + +// TestNewAddress tests the NewAddress method, ensuring it can generate +// various address types for different accounts and correctly handles both +// internal and external address generation. +func TestNewAddress(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // Define a set of test cases to cover different address types and + // scenarios. + testCases := []struct { + name string + accountName string + addrType waddrmgr.AddressType + change bool + expectErr bool + expectedAddrType btcutil.Address + }{ + { + name: "default account p2wkh", + accountName: "default", + addrType: waddrmgr.WitnessPubKey, + change: false, + expectedAddrType: &btcutil.AddressWitnessPubKeyHash{}, + }, + { + name: "p2wkh change address", + accountName: "default", + addrType: waddrmgr.WitnessPubKey, + change: true, + expectedAddrType: &btcutil.AddressWitnessPubKeyHash{}, + }, + { + name: "default account np2wkh", + accountName: "default", + addrType: waddrmgr.NestedWitnessPubKey, + change: false, + expectedAddrType: &btcutil.AddressScriptHash{}, + }, + { + name: "default account p2tr", + accountName: "default", + addrType: waddrmgr.TaprootPubKey, + change: false, + expectedAddrType: &btcutil.AddressTaproot{}, + }, + { + name: "unknown address type", + accountName: "default", + addrType: waddrmgr.WitnessScript, + expectErr: true, + }, + { + name: "imported account", + accountName: waddrmgr.ImportedAddrAccountName, + addrType: waddrmgr.WitnessPubKey, + expectErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Attempt to generate a new address with the specified + // parameters. + addr, err := w.NewAddress( + context.Background(), tc.accountName, + tc.addrType, tc.change, + ) + + // If an error is expected, assert that it occurs. + if tc.expectErr { + require.Error(t, err) + return + } + + // If no error is expected, assert that the address is + // generated successfully and perform any + // additional checks. + require.NoError(t, err) + require.NotNil(t, addr) + + // Verify that the address is of the correct type. + require.IsType(t, tc.expectedAddrType, addr) + + // Verify that the address is correctly marked as + // internal or external. + addrInfo, err := w.AddressInfo(addr) + require.NoError(t, err) + require.Equal(t, tc.change, addrInfo.Internal()) + }) + } +} From 65a5eaa18a1eae4a965afe2c9f7b393d8e304f7b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 28 Aug 2025 21:12:45 +0800 Subject: [PATCH 042/691] wallet+waddrmgr: identify issues and add TODOs --- waddrmgr/scoped_manager.go | 35 +++++++++++++++++++++++++++++++ wallet/address_manager.go | 43 ++++++++++++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 5448bc352a..6523c9f18b 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -1000,6 +1000,41 @@ func (s *ScopedKeyManager) accountAddrType(acctInfo *accountInfo, return addrSchema.ExternalAddrType } +// TODO(yy): This method is a "God method" that does too much, leading to +// several issues. It should be refactored to improve separation of concerns, +// reduce complexity, and increase robustness. +// +// Issues: +// 1. **Excessive Responsibility:** The method handles account validation, key +// derivation, address creation, database persistence, and in-memory state +// management, violating the Single Responsibility Principle. +// 2. **Fragile Concurrency Model:** The use of an `onCommit` closure to sync +// the in-memory cache with the database state is prone to race +// conditions and deadlocks, as it requires re-acquiring a mutex outside +// of the original database transaction's scope. +// 3. **Inefficiency and Redundancy:** The method performs a read-after-write +// validation by loading addresses from the DB immediately after saving +// them, which indicates a lack of confidence in the persistence logic. +// 4. **Complex API:** The method returns a slice of addresses but is almost +// always called with a request for a single address, making the API and +// its usage unnecessarily complex. +// +// Refactoring Tasks: +// - **Separate DB Logic:** Encapsulate all database read/write operations +// within this package. The method should handle its own transaction +// instead of accepting a `walletdb.ReadWriteBucket`. +// - **Simplify State Management:** Refactor the in-memory cache (`s.addrs`) +// and the next address index (`acctInfo.next...Index`) to be updated +// atomically with the database write, removing the fragile `onCommit` +// closure. +// - **Decompose the Method:** Break this function into smaller, private +// helpers for each distinct responsibility: deriving keys, creating +// managed addresses, and persisting them. +// - **Simplify the API:** Create a new, simpler method that returns a single +// address, as this is the most common use case. The batch generation +// logic can be deprecated or refactored into a separate method if still +// needed. +// // nextAddresses returns the specified number of next chained address from the // branch indicated by the internal flag. // diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 0b360b64b1..7fda607ac7 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -87,12 +87,43 @@ type AddressManager interface { waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) } -// NewAddress returns the next external or internal address for the wallet -// dictated by the value of the `change` parameter. If change is true, then an -// internal address will be returned, otherwise an external address should be -// returned. The account parameter is the name of the account from which the -// address should be generated. The addrType parameter specifies the type of -// address to be generated. +// TODO(yy): The current implementation of NewAddress has several architectural +// issues that should be addressed: +// +// 1. **Lack of Separation of Concerns:** The method tightly couples the +// database logic with the address generation and chain backend +// notification logic. The `waddrmgr` package currently handles both +// derivation and persistence within a single database transaction, which +// makes the transaction larger and longer than necessary. +// +// 2. **Incorrect Ordering of Operations:** The current flow is: +// 1. Create DB transaction. +// 2. Derive address. +// 3. Save address to DB. +// 4. Commit DB transaction. +// 5. Notify the chain backend to watch the new address. +// This creates a potential race condition. If the program crashes after +// committing the address to the database but before successfully +// notifying the chain backend, the wallet will own an address that the +// backend is not aware of. This could lead to a permanent loss of funds +// if coins are sent to that address. +// +// Refactoring Plan: +// - **Decouple `waddrmgr`:** The `waddrmgr` package should be refactored to +// separate its concerns. It should provide: +// - A pure, stateless function to derive an address from account info. +// - A simple method to persist a newly derived address to the database. +// - **Improve Operation Ordering in `wallet`:** The `NewAddress` method in +// the `wallet` package should be updated to follow a more robust +// sequence: +// 1. Start a DB transaction to read the required account information. +// 2. Use the pure derivation function from `waddrmgr` to generate the +// new address *outside* of any DB transaction. +// 3. Notify the chain backend to watch the new address. +// 4. If the notification is successful, start a *second*, short-lived DB +// transaction to persist the new address. +// This ensures that we only save an address after we are confident that +// it is being watched by the backend, preventing fund loss. func (w *Wallet) NewAddress(_ context.Context, accountName string, addrType waddrmgr.AddressType, change bool) (btcutil.Address, error) { From 5a57f608644ac21419b12d8320e1151bf22920f3 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 17:53:07 +0800 Subject: [PATCH 043/691] wallet: implement `GetUnusedAddress` This commit introduces a new, safer method for obtaining receiving addresses, `GetUnusedAddress`, and refactors the public `AddressManager` interface to promote its use. The previous approach, relying on methods like `CurrentAddress` (which internally used `LastExternalAddress`), was prone to creating gaps in the address chain. It would only check the most recently derived address, and if it was used, it would generate a new one immediately. This could leave earlier, unused addresses behind, creating a gap. This behavior risks fund loss during wallet recovery from seed, as the BIP44 standard dictates that a wallet should stop scanning for addresses after encountering a gap of 20 consecutive unused addresses. The new `GetUnusedAddress` method solves this by always scanning forward from the beginning of an account's address chain (index 0). It returns the very first address it finds that has never been marked as used. This guarantees that the earliest available address is always returned, preventing gaps and ensuring robust wallet recovery. The method only falls back to generating a new address if all previously derived addresses for the account have been used. Future work should address the performance of this new method. The current implementation performs a linear scan, which can be slow for wallets with a long transaction history. The correct optimization is to persist a "first unused address pointer" for each account in the database. This would make `GetUnusedAddress` an O(1) lookup, moving the more expensive forward-scan operation to the one-time event of an address being marked as used. --- wallet/address_manager.go | 115 +++++++++++++++++++++++++++++++++ wallet/address_manager_test.go | 88 +++++++++++++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 7fda607ac7..c3d3737865 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -25,6 +25,16 @@ var ( // is requested for the default imported account within the wallet. ErrImportedAccountNoAddrGen = errors.New("addresses cannot be " + "generated for the default imported account") + + // ErrNotPubKeyAddress is an error returned when a function requires a + // public key address, but a different type of address is provided. + ErrNotPubKeyAddress = errors.New( + "address is not a p2wkh or np2wkh address", + ) + + // errStopIteration is a special error used to stop the iteration in + // ForEachAccountAddress. + errStopIteration = errors.New("stop iteration") ) // AddressProperty represents an address and its balance. @@ -246,3 +256,108 @@ func (w *Wallet) newAddress(manager *waddrmgr.ScopedKeyManager, return addr, nil } + +// GetUnusedAddress returns the first, oldest, unused address by scanning +// forward from the start of the derivation path. The address is considered +// "unused" if it has never appeared in a transaction. This method is the +// recommended default for obtaining a new receiving address. It prevents +// address reuse and avoids creating gaps in the address chain, which is +// critical for reliable wallet recovery under standards like BIP44 that +// enforce a gap limit of 20 unused addresses. If all previously derived +// addresses have been used, this method will delegate to NewAddress to +// generate a new one. +// +// TODO(yy): The current implementation of GetUnusedAddress is inefficient for +// wallets with a large number of used addresses. It iterates from the first +// address (index 0) forward until it finds an unused one, resulting in an O(n) +// complexity where n is the number of used addresses. +// +// A potential optimization of scanning backwards from the last derived address +// is UNSAFE. While faster in the common case, it can create gaps in the +// address chain. For example, if addresses [0, 1, 3] are used but [2] is not, +// a backward scan would return a new address after 3, leaving 2 as a gap. +// This violates the BIP44 gap limit (typically 20) and can lead to fund loss +// upon wallet recovery from seed, as the recovery process would stop scanning +// at the gap. +// +// The correct optimization is to persist a "first unused address pointer" +// (e.g., `firstUnusedExternalIndex`) for each account in the database. +// +// This would change the logic to: +// 1. `GetUnusedAddress`: Becomes an O(1) lookup. It reads the index from the +// database and derives the address at that index. +// 2. `MarkUsed`: When an address is marked as used, if its index matches the +// stored pointer, a one-time forward scan is performed to find the next +// unused address, and the pointer is updated in the database. +// +// This moves the expensive scan from the frequent "read" operation to the less +// frequent "write" operation, providing both performance and safety. +func (w *Wallet) GetUnusedAddress(ctx context.Context, accountName string, + addrType waddrmgr.AddressType, change bool) (btcutil.Address, error) { + + if accountName == waddrmgr.ImportedAddrAccountName { + return nil, ErrImportedAccountNoAddrGen + } + + keyScope, err := w.keyScopeFromAddrType(addrType) + if err != nil { + return nil, err + } + + manager, err := w.addrStore.FetchScopedKeyManager(keyScope) + if err != nil { + return nil, err + } + + var unusedAddr btcutil.Address + + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + // First, look up the account number for the passed account + // name. + acctNum, err := manager.LookupAccount(addrmgrNs, accountName) + if err != nil { + return err + } + + // Now, iterate through all addresses for the account and + // return the first one that is unused. + return manager.ForEachAccountAddress( + addrmgrNs, acctNum, + func(maddr waddrmgr.ManagedAddress) error { + // We only want to consider addresses that match + // the change parameter. + if maddr.Internal() != change { + return nil + } + + if !maddr.Used(addrmgrNs) { + unusedAddr = maddr.Address() + + // Return a special error to signal + // that the iteration should be + // stopped. This is the idiomatic way + // to halt a ForEach* loop in this + // codebase. + return errStopIteration + } + + return nil + }, + ) + }) + + // We'll ignore the special error that we use to stop the iteration. + if err != nil && !errors.Is(err, errStopIteration) { + return nil, err + } + + // If we found an unused address, we can return it now. + if unusedAddr != nil { + return unusedAddr, nil + } + + // Otherwise, we'll generate a new one. + return w.NewAddress(ctx, accountName, addrType, change) +} diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 37cd1cb1e8..b8ff2ba338 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -7,9 +7,14 @@ package wallet import ( "context" "testing" + "time" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/require" ) @@ -163,3 +168,86 @@ func TestNewAddress(t *testing.T) { }) } } + +// TestGetUnusedAddress tests the GetUnusedAddress method to ensure it +// correctly returns the earliest unused address. +func TestGetUnusedAddress(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // Get a new address to start with. + addr, err := w.NewAddress( + context.Background(), "default", waddrmgr.WitnessPubKey, false, + ) + require.NoError(t, err) + + // The first unused address should be the one we just created. + unusedAddr, err := w.GetUnusedAddress( + context.Background(), "default", waddrmgr.WitnessPubKey, false, + ) + require.NoError(t, err) + require.Equal(t, addr.String(), unusedAddr.String()) + + // "Use" the address by creating a fake UTXO for it. + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + // We need to create a realistic transaction that has at least one + // input. + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + + // Create a new transaction and set the output to the address + // we want to mark as used. + msgTx := TstTx.MsgTx() + msgTx.TxOut = []*wire.TxOut{{ + PkScript: pkScript, + Value: 1000, + }} + + rec, err := wtxmgr.NewTxRecordFromMsgTx(msgTx, time.Now()) + if err != nil { + return err + } + + err = w.txStore.InsertTx(txmgrNs, rec, nil) + if err != nil { + return err + } + + err = w.txStore.AddCredit(txmgrNs, rec, nil, 0, false) + if err != nil { + return err + } + + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + return w.addrStore.MarkUsed(addrmgrNs, addr) + }) + require.NoError(t, err) + + // Get the next unused address. + nextAddr, err := w.GetUnusedAddress( + context.Background(), "default", waddrmgr.WitnessPubKey, false, + ) + require.NoError(t, err) + + // The next unused address should not be the same as the first one. + require.NotEqual(t, addr.String(), nextAddr.String()) + + // Now, let's test the change address. + changeAddr, err := w.NewAddress( + context.Background(), "default", waddrmgr.WitnessPubKey, true, + ) + require.NoError(t, err) + + // The first unused change address should be the one we just created. + unusedChangeAddr, err := w.GetUnusedAddress( + context.Background(), "default", waddrmgr.WitnessPubKey, true, + ) + require.NoError(t, err) + require.Equal(t, changeAddr.String(), unusedChangeAddr.String()) +} From 7a4da9ccd6ec6f27bb44658bb4b0ad74082dfa5b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 18:27:05 +0800 Subject: [PATCH 044/691] wallet: rename `AddressInfo` to `AddressInfoDeprecated` --- rpc/legacyrpc/methods.go | 2 +- wallet/address_manager_test.go | 2 +- wallet/import_test.go | 2 +- wallet/interface.go | 12 +++++++++--- wallet/utxos.go | 2 +- wallet/wallet.go | 4 ++-- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/rpc/legacyrpc/methods.go b/rpc/legacyrpc/methods.go index 74e26fcc78..2fe2393d30 100644 --- a/rpc/legacyrpc/methods.go +++ b/rpc/legacyrpc/methods.go @@ -1778,7 +1778,7 @@ func validateAddress(icmd interface{}, w *wallet.Wallet) (interface{}, error) { result.Address = addr.EncodeAddress() result.IsValid = true - ainfo, err := w.AddressInfo(addr) + ainfo, err := w.AddressInfoDeprecated(addr) if err != nil { if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { // No additional information available about the address. diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index b8ff2ba338..de7de349e2 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -162,7 +162,7 @@ func TestNewAddress(t *testing.T) { // Verify that the address is correctly marked as // internal or external. - addrInfo, err := w.AddressInfo(addr) + addrInfo, err := w.AddressInfoDeprecated(addr) require.NoError(t, err) require.Equal(t, tc.change, addrInfo.Internal()) }) diff --git a/wallet/import_test.go b/wallet/import_test.go index 16c489dc20..ecdf36a065 100644 --- a/wallet/import_test.go +++ b/wallet/import_test.go @@ -289,7 +289,7 @@ func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, t.Fatalf("unhandled address type %v", tc.addrType) } - addrManaged, err := w.AddressInfo(intAddr) + addrManaged, err := w.AddressInfoDeprecated(intAddr) require.NoError(t, err) require.Equal(t, true, addrManaged.Imported()) } diff --git a/wallet/interface.go b/wallet/interface.go index 4766d6b56a..565e2a4907 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -167,9 +167,15 @@ type Interface interface { NewChangeAddress(account uint32, scope waddrmgr.KeyScope) ( btcutil.Address, error) - // AddressInfo returns detailed information about a managed address, - // including its derivation path and whether it's compressed. - AddressInfo(a btcutil.Address) (waddrmgr.ManagedAddress, error) + // AddressInfoDeprecated returns detailed information about a managed + // address, including its derivation path and whether it's compressed. + // + // Deprecated: This method leaks internal waddrmgr types. Callers + // should use specific methods such as AccountOfAddress, + // IsInternalAddress, etc. instead. + AddressInfoDeprecated(a btcutil.Address) ( + waddrmgr.ManagedAddress, error, + ) // HaveAddress returns whether the wallet is the owner of the address. HaveAddress(a btcutil.Address) (bool, error) diff --git a/wallet/utxos.go b/wallet/utxos.go index 3b5fea43e0..13ce324be0 100644 --- a/wallet/utxos.go +++ b/wallet/utxos.go @@ -144,7 +144,7 @@ func (w *Wallet) fetchOutputAddr(script []byte) (waddrmgr.ManagedAddress, error) // Therefore, we simply select the key for the first address we know // of. for _, addr := range addrs { - addr, err := w.AddressInfo(addr) + addr, err := w.AddressInfoDeprecated(addr) if err == nil { return addr, nil } diff --git a/wallet/wallet.go b/wallet/wallet.go index 339082d2bb..68ae16aa06 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1956,8 +1956,8 @@ func (w *Wallet) AccountOfAddress(a btcutil.Address) (uint32, error) { return account, err } -// AddressInfo returns detailed information regarding a wallet address. -func (w *Wallet) AddressInfo(a btcutil.Address) (waddrmgr.ManagedAddress, error) { +// AddressInfoDeprecated returns detailed information regarding a wallet address. +func (w *Wallet) AddressInfoDeprecated(a btcutil.Address) (waddrmgr.ManagedAddress, error) { var managedAddress waddrmgr.ManagedAddress err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) From efe5c78d3fa80e10ba13403c7fffadf6c66cec8f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 19:07:44 +0800 Subject: [PATCH 045/691] wallet: implement `AddressInfo` --- wallet/address_manager.go | 44 ++++++++++++++++++++++++++++++++ wallet/address_manager_test.go | 46 +++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index c3d3737865..011d75e448 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -361,3 +361,47 @@ func (w *Wallet) GetUnusedAddress(ctx context.Context, accountName string, // Otherwise, we'll generate a new one. return w.NewAddress(ctx, accountName, addrType, change) } + +// AddressInfo returns detailed information regarding a wallet address. +// +// This method provides metadata about a managed address, such as its type, +// derivation path, and whether it's internal or compressed. +// +// How it works: +// The method performs a direct lookup in the address manager to find the +// requested address. +// +// Logical Steps: +// 1. Initiate a read-only database transaction. +// 2. Call the underlying address manager's `Address` method to look up the +// address. +// 3. Return the managed address information. +// +// Database Actions: +// - This method performs a single read-only database transaction +// (`walletdb.View`). +// - It reads from the `waddrmgr` namespace to find the address. +// +// Time Complexity: +// - The operation is a direct database lookup, making its complexity roughly +// O(1) or O(log N) depending on the database backend's indexing strategy +// for addresses. It is a very fast operation. +func (w *Wallet) AddressInfo(_ context.Context, + a btcutil.Address) (waddrmgr.ManagedAddress, error) { + + var ( + managedAddress waddrmgr.ManagedAddress + err error + ) + + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddress, err = w.addrStore.Address(addrmgrNs, a) + + return err + }) + + return managedAddress, err +} + diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index de7de349e2..e0267a703b 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -162,7 +162,7 @@ func TestNewAddress(t *testing.T) { // Verify that the address is correctly marked as // internal or external. - addrInfo, err := w.AddressInfoDeprecated(addr) + addrInfo, err := w.AddressInfo(context.Background(), addr) require.NoError(t, err) require.Equal(t, tc.change, addrInfo.Internal()) }) @@ -251,3 +251,47 @@ func TestGetUnusedAddress(t *testing.T) { require.NoError(t, err) require.Equal(t, changeAddr.String(), unusedChangeAddr.String()) } + +// TestAddressInfo tests the AddressInfo method to ensure it returns correct +// information for both internal and external addresses. +func TestAddressInfo(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // Get a new external address to test with. + extAddr, err := w.NewAddress( + context.Background(), "default", waddrmgr.WitnessPubKey, false, + ) + require.NoError(t, err) + + // Get the address info for the external address. + extInfo, err := w.AddressInfo(context.Background(), extAddr) + require.NoError(t, err) + + // Check the external address info. + require.Equal(t, extAddr.String(), extInfo.Address().String()) + require.False(t, extInfo.Internal()) + require.True(t, extInfo.Compressed()) + require.False(t, extInfo.Imported()) + require.Equal(t, waddrmgr.WitnessPubKey, extInfo.AddrType()) + + // Get a new internal address to test with. + intAddr, err := w.NewAddress( + context.Background(), "default", waddrmgr.WitnessPubKey, true, + ) + require.NoError(t, err) + + // Get the address info for the internal address. + intInfo, err := w.AddressInfo(context.Background(), intAddr) + require.NoError(t, err) + + // Check the internal address info. + require.Equal(t, intAddr.String(), intInfo.Address().String()) + require.True(t, intInfo.Internal()) + require.True(t, intInfo.Compressed()) + require.False(t, intInfo.Imported()) + require.Equal(t, waddrmgr.WitnessPubKey, intInfo.AddrType()) +} From f538744b7a8a360125b14e5893eb616ee752a543 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 22:59:32 +0800 Subject: [PATCH 046/691] wallet: implement `ListAddresses` The new `ListAddresses` method is a significant improvement over the previous implementation found in `lnd`. The old approach had a time complexity of O(U * A) (UTXOs * Addresses), as it would iterate through all UTXOs for each address to calculate its balance. The new implementation is much more efficient with a time complexity of O(U + A). It achieves this by first iterating through all UTXOs once to create a balance map, and then iterating through the addresses to look up their balances in the map. This is all done within a single, read-only database transaction. --- wallet/address_manager.go | 97 ++++++++++++++++++++++++++++++++++ wallet/address_manager_test.go | 65 +++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 011d75e448..8807b6e886 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -405,3 +405,100 @@ func (w *Wallet) AddressInfo(_ context.Context, return managedAddress, err } +// ListAddresses lists all addresses for a given account, including their +// balances. +// +// This method provides a comprehensive view of all addresses within a +// specific account, along with their current confirmed balances. +// +// How it works: +// The method first calculates the balances of all UTXOs in the wallet and +// stores them in a map. It then iterates through all addresses of the +// specified account and looks up their balance in the map. +// +// Logical Steps: +// 1. Initiate a read-only database transaction. +// 2. Create a map to store address balances. +// 3. Iterate through all unspent transaction outputs (UTXOs) in the +// wallet's `wtxmgr` namespace. +// 4. For each UTXO, extract the address and add the output's value to the +// address's balance in the map. +// 5. Fetch the scoped key manager for the given address type. +// 6. Look up the account number for the given account name. +// 7. Iterate through all addresses in that account. +// 8. For each address, create an `AddressProperty` with the address and its +// balance from the map. +// 9. Return the list of `AddressProperty` objects. +// +// Database Actions: +// - This method performs a single read-only database transaction +// (`walletdb.View`). +// - It reads from both the `wtxmgr` and `waddrmgr` namespaces. +// +// Time Complexity: +// - The complexity is O(U + A), where U is the number of unspent +// transaction outputs in the wallet and A is the number of addresses in +// the specified account. This is because it iterates through all UTXOs to +// build the balance map and then iterates through all account addresses. +func (w *Wallet) ListAddresses(_ context.Context, accountName string, + addrType waddrmgr.AddressType) ([]AddressProperty, error) { + + var properties []AddressProperty + + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // First, we'll create a map of address to balance by iterating + // through all the unspent outputs. + addrToBalance := make(map[string]btcutil.Amount) + + utxos, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + + for _, utxo := range utxos { + addr := extractAddrFromPKScript( + utxo.PkScript, w.chainParams, + ) + if addr == nil { + continue + } + + addrToBalance[addr.String()] += utxo.Amount + } + + keyScope, err := w.keyScopeFromAddrType(addrType) + if err != nil { + return err + } + + manager, err := w.addrStore.FetchScopedKeyManager(keyScope) + if err != nil { + return err + } + + acctNum, err := manager.LookupAccount(addrmgrNs, accountName) + if err != nil { + return err + } + + return manager.ForEachAccountAddress(addrmgrNs, acctNum, + func(maddr waddrmgr.ManagedAddress) error { + addr := maddr.Address() + properties = append(properties, AddressProperty{ + Address: addr, + Balance: addrToBalance[addr.String()], + }) + + return nil + }) + }) + if err != nil { + return nil, err + } + + return properties, nil +} + diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index e0267a703b..7130930796 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -295,3 +295,68 @@ func TestAddressInfo(t *testing.T) { require.False(t, intInfo.Imported()) require.Equal(t, waddrmgr.WitnessPubKey, intInfo.AddrType()) } + +// TestListAddresses tests the ListAddresses method to ensure it returns the +// correct addresses and balances for a given account. +func TestListAddresses(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // Get a new address and give it a balance. + addr, err := w.NewAddress( + context.Background(), "default", waddrmgr.WitnessPubKey, false, + ) + require.NoError(t, err) + + // "Use" the address by creating a fake UTXO for it. + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + // We need to create a realistic transaction that has at least one + // input. + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + + // Create a new transaction and set the output to the address + // we want to mark as used. + msgTx := TstTx.MsgTx() + msgTx.TxOut = []*wire.TxOut{{ + PkScript: pkScript, + Value: 1000, + }} + + rec, err := wtxmgr.NewTxRecordFromMsgTx(msgTx, time.Now()) + if err != nil { + return err + } + + err = w.txStore.InsertTx(txmgrNs, rec, nil) + if err != nil { + return err + } + + err = w.txStore.AddCredit(txmgrNs, rec, nil, 0, false) + if err != nil { + return err + } + + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + return w.addrStore.MarkUsed(addrmgrNs, addr) + }) + require.NoError(t, err) + + // List the addresses for the default account. + addrs, err := w.ListAddresses( + context.Background(), "default", waddrmgr.WitnessPubKey, + ) + require.NoError(t, err) + + // We should have one address with a balance of 1000. + require.Len(t, addrs, 1) + require.Equal(t, addr.String(), addrs[0].Address.String()) + require.Equal(t, btcutil.Amount(1000), addrs[0].Balance) +} \ No newline at end of file From faaf470c6f2468e6aec6568853b165fb11ed7844 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 19:39:27 +0800 Subject: [PATCH 047/691] wallet: rename `ImportPublicKey` to `ImportPublicKeyDeprecated` --- wallet/import.go | 2 +- wallet/import_test.go | 2 +- wallet/interface.go | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/wallet/import.go b/wallet/import.go index 3403737998..77126e518e 100644 --- a/wallet/import.go +++ b/wallet/import.go @@ -393,7 +393,7 @@ func (w *Wallet) ImportAccountDryRun(name string, // case of legacy versions (xpub, tpub), an address type must be specified as we // intend to not support importing BIP-44 keys into the wallet using the legacy // pay-to-pubkey-hash (P2PKH) scheme. -func (w *Wallet) ImportPublicKey(pubKey *btcec.PublicKey, +func (w *Wallet) ImportPublicKeyDeprecated(pubKey *btcec.PublicKey, addrType waddrmgr.AddressType) error { // Determine what key scope the public key should belong to and import diff --git a/wallet/import_test.go b/wallet/import_test.go index ecdf36a065..88d0271362 100644 --- a/wallet/import_test.go +++ b/wallet/import_test.go @@ -204,7 +204,7 @@ func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, require.NoError(t, err) require.Equal(t, tc.expectedScope, acct2.KeyScope) - err = w.ImportPublicKey(acct3ExternalPub, tc.addrType) + err = w.ImportPublicKeyDeprecated(acct3ExternalPub, tc.addrType) require.NoError(t, err) // If the wallet is watch only, there is no default account and our diff --git a/wallet/interface.go b/wallet/interface.go index 565e2a4907..6d9e2a2242 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -180,8 +180,11 @@ type Interface interface { // HaveAddress returns whether the wallet is the owner of the address. HaveAddress(a btcutil.Address) (bool, error) - // ImportPublicKey imports a public key as a watch-only address. - ImportPublicKey(pubKey *btcec.PublicKey, + // ImportPublicKeyDeprecated imports a public key as a watch-only + // address. + // + // Deprecated: Use AddressManager.ImportPublicKey instead. + ImportPublicKeyDeprecated(pubKey *btcec.PublicKey, addrType waddrmgr.AddressType) error // ImportTaprootScript imports a taproot script into the wallet. From 810462c1a56eee07d2552407bcd39221a306354e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 19:42:50 +0800 Subject: [PATCH 048/691] wallet: implement ImportPublicKey --- wallet/address_manager.go | 49 ++++++++++++++++++++++++++++++++++ wallet/address_manager_test.go | 34 ++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 8807b6e886..a93e841fd7 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -502,3 +502,52 @@ func (w *Wallet) ListAddresses(_ context.Context, accountName string, return properties, nil } +// ImportPublicKey imports a single public key as a watch-only address. +// +// This method allows the wallet to track transactions related to a specific +// public key without having access to the corresponding private key. This is +// useful for monitoring addresses without compromising their security. +// +// How it works: +// The method determines the appropriate key scope based on the provided +// address type and then uses the corresponding scoped key manager to import +// the public key. +// +// Logical Steps: +// 1. Determine the key scope from the address type (e.g., P2WKH, NP2WKH). +// 2. Fetch the scoped key manager for that scope. +// 3. Initiate a database transaction. +// 4. Within the transaction, call the underlying address manager's +// ImportPublicKey method to store the key. +// 5. Commit the transaction. +// +// Database Actions: +// - This method performs a single database write transaction +// (`walletdb.Update`). +// - It stores the public key and its associated address information within +// the `waddrmgr` namespace. +// +// Time Complexity: +// - The operation is dominated by the database write, making its complexity +// roughly O(1) or O(log N) depending on the database backend's indexing +// strategy for keys. It is generally a fast operation. +func (w *Wallet) ImportPublicKey(_ context.Context, pubKey *btcec.PublicKey, + addrType waddrmgr.AddressType) error { + + keyScope, err := w.keyScopeFromAddrType(addrType) + if err != nil { + return err + } + + manager, err := w.addrStore.FetchScopedKeyManager(keyScope) + if err != nil { + return err + } + + return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + _, err := manager.ImportPublicKey(addrmgrNs, pubKey, nil) + + return err + }) +} diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 7130930796..84a542e358 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -359,4 +360,35 @@ func TestListAddresses(t *testing.T) { require.Len(t, addrs, 1) require.Equal(t, addr.String(), addrs[0].Address.String()) require.Equal(t, btcutil.Amount(1000), addrs[0].Balance) -} \ No newline at end of file +} + +// TestImportPublicKey tests the ImportPublicKey method to ensure it can +// import a public key as a watch-only address. +func TestImportPublicKey(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // Create a new public key to import. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + // Import the public key. + err = w.ImportPublicKey( + context.Background(), pubKey, waddrmgr.WitnessPubKey, + ) + require.NoError(t, err) + + // Check that the address is now managed by the wallet. + addr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), w.chainParams, + ) + require.NoError(t, err) + managed, err := w.HaveAddress(addr) + require.NoError(t, err) + require.True(t, managed) +} From 017bc8c1ce593c0582e2fe904d11465f1375ec7d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 19:47:55 +0800 Subject: [PATCH 049/691] wallet: rename `ImportTaprootScript` to `ImportTaprootScriptDeprecated` --- wallet/import.go | 8 +++++--- wallet/interface.go | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/wallet/import.go b/wallet/import.go index 77126e518e..7f957309f2 100644 --- a/wallet/import.go +++ b/wallet/import.go @@ -440,9 +440,11 @@ func (w *Wallet) ImportPublicKeyDeprecated(pubKey *btcec.PublicKey, return nil } -// ImportTaprootScript imports a user-provided taproot script into the address -// manager. The imported script will act as a pay-to-taproot address. -func (w *Wallet) ImportTaprootScript(scope waddrmgr.KeyScope, +// ImportTaprootScriptDeprecated imports a user-provided taproot script into the +// address manager. The imported script will act as a pay-to-taproot address. +// +// Deprecated: Use AddressManager.ImportTaprootScript instead. +func (w *Wallet) ImportTaprootScriptDeprecated(scope waddrmgr.KeyScope, tapscript *waddrmgr.Tapscript, bs *waddrmgr.BlockStamp, witnessVersion byte, isSecretScript bool) (waddrmgr.ManagedAddress, error) { diff --git a/wallet/interface.go b/wallet/interface.go index 6d9e2a2242..4e342fa692 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -187,8 +187,11 @@ type Interface interface { ImportPublicKeyDeprecated(pubKey *btcec.PublicKey, addrType waddrmgr.AddressType) error - // ImportTaprootScript imports a taproot script into the wallet. - ImportTaprootScript(scope waddrmgr.KeyScope, + // ImportTaprootScriptDeprecated imports a taproot script into the + // wallet. + // + // Deprecated: Use AddressManager.ImportTaprootScript instead. + ImportTaprootScriptDeprecated(scope waddrmgr.KeyScope, tapscript *waddrmgr.Tapscript, bs *waddrmgr.BlockStamp, witnessVersion byte, isSecretScript bool) ( waddrmgr.ManagedAddress, error) From ddcafe1c6767c080ff670194418c50fe6e9217ae Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 19:54:46 +0800 Subject: [PATCH 050/691] wallet: implement ImportTaprootScript --- wallet/address_manager.go | 55 ++++++++++++++++++++++++++++++++++ wallet/address_manager_test.go | 48 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index a93e841fd7..43f32c6de9 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -551,3 +551,58 @@ func (w *Wallet) ImportPublicKey(_ context.Context, pubKey *btcec.PublicKey, return err }) } + +// ImportTaprootScript imports a taproot script for tracking and spending. +// +// This method allows the wallet to import a taproot script, which is +// necessary for spending from or tracking a taproot address. +// +// How it works: +// The method uses the BIP-0086 key scope to fetch the taproot-specific +// scoped key manager. It then calls the underlying manager's +// ImportTaprootScript method to store the script information. +// +// Logical Steps: +// 1. Fetch the scoped key manager for the taproot key scope (BIP-0086). +// 2. Initiate a database transaction. +// 3. Within the transaction, get the wallet's current sync state to use as +// the "birthday" for the new script. +// 4. Call the underlying address manager's ImportTaprootScript method. +// 5. Commit the transaction. +// +// Database Actions: +// - This method performs a single database write transaction +// (`walletdb.Update`). +// - It stores the taproot script and its derived address information within +// the `waddrmgr` namespace. +// +// Time Complexity: +// - Similar to ImportPublicKey, this operation is dominated by a database +// write, making it a fast operation with a complexity of roughly O(1). +func (w *Wallet) ImportTaprootScript(_ context.Context, + tapscript waddrmgr.Tapscript) (waddrmgr.ManagedAddress, error) { + + manager, err := w.addrStore.FetchScopedKeyManager( + waddrmgr.KeyScopeBIP0086, + ) + if err != nil { + return nil, err + } + + var addr waddrmgr.ManagedAddress + + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + syncedTo := w.addrStore.SyncedTo() + addr, err = manager.ImportTaprootScript( + ns, &tapscript, &syncedTo, 1, false, + ) + + return err + }) + if err != nil { + return nil, err + } + + return addr, nil +} \ No newline at end of file diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 84a542e358..7ac25d43f7 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -392,3 +393,50 @@ func TestImportPublicKey(t *testing.T) { require.NoError(t, err) require.True(t, managed) } + +// TestImportTaprootScript tests the ImportTaprootScript method to ensure it can +// import a taproot script as a watch-only address. +func TestImportTaprootScript(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // Create a new tapscript to import. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + script, err := txscript.NewScriptBuilder(). + AddData(pubKey.SerializeCompressed()). + AddOp(txscript.OP_CHECKSIG). + Script() + require.NoError(t, err) + + leaf := txscript.NewTapLeaf(txscript.BaseLeafVersion, script) + tree := txscript.AssembleTaprootScriptTree(leaf) + rootHash := tree.RootNode.TapHash() + tapscript := waddrmgr.Tapscript{ + Type: waddrmgr.TapscriptTypeFullTree, + ControlBlock: &txscript.ControlBlock{ + InternalKey: pubKey, + }, + Leaves: []txscript.TapLeaf{leaf}, + } + + // Import the tapscript. + _, err = w.ImportTaprootScript(context.Background(), tapscript) + require.NoError(t, err) + + // Check that the address is now managed by the wallet. + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(txscript.ComputeTaprootOutputKey( + pubKey, rootHash[:], + )), w.chainParams, + ) + require.NoError(t, err) + managed, err := w.HaveAddress(addr) + require.NoError(t, err) + require.True(t, managed) +} \ No newline at end of file From 6936ed5b98b6bc4be8940a98ce44572332414f2f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 19:59:26 +0800 Subject: [PATCH 051/691] wallet: rename `ScriptForOutput` to `ScriptForOutputDeprecated` --- wallet/interface.go | 8 +++++--- wallet/psbt.go | 4 ++-- wallet/signer.go | 12 +++++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/wallet/interface.go b/wallet/interface.go index 4e342fa692..4aa347b165 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -337,9 +337,11 @@ type Interface interface { hashType txscript.SigHashType, tweaker PrivKeyTweaker) (wire.TxWitness, []byte, error) - // ScriptForOutput returns the address, witness program and redeem - // script for a given UTXO. - ScriptForOutput(output *wire.TxOut) (waddrmgr.ManagedPubKeyAddress, + // ScriptForOutputDeprecated returns the address, witness program and + // redeem script for a given UTXO. + // + // Deprecated: Use AddressManager.ScriptForOutput instead. + ScriptForOutputDeprecated(output *wire.TxOut) (waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) } diff --git a/wallet/psbt.go b/wallet/psbt.go index cb342dfe3f..3a0f604f85 100644 --- a/wallet/psbt.go +++ b/wallet/psbt.go @@ -217,7 +217,7 @@ func (w *Wallet) FundPsbt(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, packet.UnsignedTx.TxOut, changeTxOut, ) - addr, _, _, err := w.ScriptForOutput(changeTxOut) + addr, _, _, err := w.ScriptForOutputDeprecated(changeTxOut) if err != nil { return 0, fmt.Errorf("error querying wallet for "+ "change addr: %w", err) @@ -278,7 +278,7 @@ func (w *Wallet) DecorateInputs(packet *psbt.Packet, failOnUnknown bool) error { return fmt.Errorf("error fetching UTXO: %w", err) } - addr, witnessProgram, _, err := w.ScriptForOutput(utxo) + addr, witnessProgram, _, err := w.ScriptForOutputDeprecated(utxo) if err != nil { return fmt.Errorf("error fetching UTXO script: %w", err) } diff --git a/wallet/signer.go b/wallet/signer.go index 38b8d787fb..8b523585f4 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -14,10 +14,12 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" ) -// ScriptForOutput returns the address, witness program and redeem script for a -// given UTXO. An error is returned if the UTXO does not belong to our wallet or -// it is not a managed pubKey address. -func (w *Wallet) ScriptForOutput(output *wire.TxOut) ( +// ScriptForOutputDeprecated returns the address, witness program and redeem +// script for a given UTXO. An error is returned if the UTXO does not +// belong to our wallet or it is not a managed pubKey address. +// +// Deprecated: Use AddressManager.ScriptForOutput instead. +func (w *Wallet) ScriptForOutputDeprecated(output *wire.TxOut) ( waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) { // First make sure we can sign for the input by making sure the script @@ -92,7 +94,7 @@ func (w *Wallet) ComputeInputScript(tx *wire.MsgTx, output *wire.TxOut, hashType txscript.SigHashType, tweaker PrivKeyTweaker) (wire.TxWitness, []byte, error) { - walletAddr, witnessProgram, sigScript, err := w.ScriptForOutput(output) + walletAddr, witnessProgram, sigScript, err := w.ScriptForOutputDeprecated(output) if err != nil { return nil, nil, err } From 47fcbf22d75dbd747f162b4d127ab673d54110ab Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 29 Aug 2025 20:02:52 +0800 Subject: [PATCH 052/691] wallet: implement ScriptForOutput --- wallet/address_manager.go | 120 ++++++++++++++++++++++++++++++++- wallet/address_manager_test.go | 35 +++++++++- wallet/signer.go | 61 +---------------- 3 files changed, 155 insertions(+), 61 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 43f32c6de9..6cff951bfb 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" @@ -97,6 +98,28 @@ type AddressManager interface { waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) } +// A compile time check to ensure that Wallet implements the interface. +var _ AddressManager = (*Wallet)(nil) + +// NewAddress returns a new address for the given account and address type. +// This method is a low-level primitive that will always derive a new, unused +// address from the end of the address chain. +// +// It returns the next external or internal address for the wallet dictated by +// the value of the `change` parameter. If change is true, then an internal +// address will be returned, otherwise an external address should be returned. +// The account parameter is the name of the account from which the address +// should be generated. The addrType parameter specifies the type of address to +// be generated. +// +// NOTE: This method should be used with caution. Unlike GetUnusedAddress, it +// does not scan for previously derived but unused addresses. Using this method +// repeatedly can create gaps in the address chain. If a gap of 20 consecutive +// unused addresses is created, wallet recovery from seed may fail under BIP44. +// It is primarily intended for advanced use cases such as bulk address +// generation. For most applications, GetUnusedAddress is the recommended +// method for obtaining a receiving address. +// // TODO(yy): The current implementation of NewAddress has several architectural // issues that should be addressed: // @@ -605,4 +628,99 @@ func (w *Wallet) ImportTaprootScript(_ context.Context, } return addr, nil -} \ No newline at end of file +} + +// ScriptForOutput returns the address, witness program, and redeem script +// for a given UTXO. +// +// This method is essential for constructing the necessary scripts to spend a +// transaction output. It provides the components required to build the +// scriptSig and witness fields of a transaction input. +// +// How it works: +// The method first identifies which of the wallet's addresses corresponds to +// the output's script. It then determines the correct script format (redeem +// script, witness program) based on the address type. +// +// Logical Steps: +// 1. Look up the output's pkScript in the database to find the +// corresponding managed address. +// 2. Verify that the address is a public key address that the wallet can +// sign for (e.g., P2WKH, NP2WKH, P2TR). +// 3. Based on the address type, construct the appropriate scripts: +// - For nested P2WKH (NP2WKH), it creates a redeem script +// (`sigScript`) that contains the P2WKH witness program. +// - For native SegWit outputs (P2WKH, P2TR), the `witnessProgram` is +// the output's `pkScript`, and the `sigScript` is nil. +// +// Database Actions: +// - This method performs a read-only database access to fetch address +// details from the `waddrmgr` namespace. +// +// Time Complexity: +// - The operation is dominated by the database lookup for the address, which +// is typically fast (O(log N) or O(1) with indexing). The script +// generation is a constant-time operation. +func (w *Wallet) ScriptForOutput(_ context.Context, output wire.TxOut) ( + waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) { + + // First make sure we can sign for the input by making sure the script + // in the UTXO belongs to our wallet and we have the private key for it. + walletAddr, err := w.fetchOutputAddr(output.PkScript) + if err != nil { + return nil, nil, nil, err + } + + pubKeyAddr, ok := walletAddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil, nil, nil, fmt.Errorf("address %s is not a "+ + "p2wkh or np2wkh address", walletAddr.Address()) + } + + var ( + witnessProgram []byte + sigScript []byte + ) + + switch { + // If we're spending p2wkh output nested within a p2sh output, then + // we'll need to attach a sigScript in addition to witness data. + case walletAddr.AddrType() == waddrmgr.NestedWitnessPubKey: + pubKey := pubKeyAddr.PubKey() + pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) + + // Next, we'll generate a valid sigScript that will allow us to + // spend the p2sh output. The sigScript will contain only a + // single push of the p2wkh witness program corresponding to + // the matching public key of this address. + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + pubKeyHash, w.chainParams, + ) + if err != nil { + return nil, nil, nil, err + } + + witnessProgram, err = txscript.PayToAddrScript(p2wkhAddr) + if err != nil { + return nil, nil, nil, err + } + + bldr := txscript.NewScriptBuilder() + bldr.AddData(witnessProgram) + + sigScript, err = bldr.Script() + if err != nil { + return nil, nil, nil, err + } + + // Otherwise, this is a regular p2wkh or p2tr output, so we include the + // witness program itself as the subscript to generate the proper + // sighash digest. As part of the new sighash digest algorithm, the + // p2wkh witness program will be expanded into a regular p2kh + // script. + default: + witnessProgram = output.PkScript + } + + return pubKeyAddr, witnessProgram, sigScript, nil +} diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 7ac25d43f7..1e109f3239 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -439,4 +439,37 @@ func TestImportTaprootScript(t *testing.T) { managed, err := w.HaveAddress(addr) require.NoError(t, err) require.True(t, managed) -} \ No newline at end of file +} + +// TestScriptForOutput tests the ScriptForOutput method to ensure it returns the +// correct script for a given output. +func TestScriptForOutput(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, cleanup := testWallet(t) + defer cleanup() + + // Create a new p2wkh address and output. + addr, err := w.NewAddress( + context.Background(), "default", waddrmgr.WitnessPubKey, false, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + output := wire.TxOut{ + Value: 1000, + PkScript: pkScript, + } + + // Get the script for the output. + _, witnessProgram, sigScript, err := w.ScriptForOutput( + context.Background(), output, + ) + require.NoError(t, err) + + // Check that the script is correct. + require.Equal(t, pkScript, witnessProgram) + require.Nil(t, sigScript) +} diff --git a/wallet/signer.go b/wallet/signer.go index 8b523585f4..af94cf5500 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -5,10 +5,9 @@ package wallet import ( - "fmt" + "context" "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -22,63 +21,7 @@ import ( func (w *Wallet) ScriptForOutputDeprecated(output *wire.TxOut) ( waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) { - // First make sure we can sign for the input by making sure the script - // in the UTXO belongs to our wallet and we have the private key for it. - walletAddr, err := w.fetchOutputAddr(output.PkScript) - if err != nil { - return nil, nil, nil, err - } - - pubKeyAddr, ok := walletAddr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return nil, nil, nil, fmt.Errorf("address %s is not a "+ - "p2wkh or np2wkh address", walletAddr.Address()) - } - - var ( - witnessProgram []byte - sigScript []byte - ) - - switch { - // If we're spending p2wkh output nested within a p2sh output, then - // we'll need to attach a sigScript in addition to witness data. - case walletAddr.AddrType() == waddrmgr.NestedWitnessPubKey: - pubKey := pubKeyAddr.PubKey() - pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) - - // Next, we'll generate a valid sigScript that will allow us to - // spend the p2sh output. The sigScript will contain only a - // single push of the p2wkh witness program corresponding to - // the matching public key of this address. - p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( - pubKeyHash, w.chainParams, - ) - if err != nil { - return nil, nil, nil, err - } - witnessProgram, err = txscript.PayToAddrScript(p2wkhAddr) - if err != nil { - return nil, nil, nil, err - } - - bldr := txscript.NewScriptBuilder() - bldr.AddData(witnessProgram) - sigScript, err = bldr.Script() - if err != nil { - return nil, nil, nil, err - } - - // Otherwise, this is a regular p2wkh or p2tr output, so we include the - // witness program itself as the subscript to generate the proper - // sighash digest. As part of the new sighash digest algorithm, the - // p2wkh witness program will be expanded into a regular p2kh - // script. - default: - witnessProgram = output.PkScript - } - - return pubKeyAddr, witnessProgram, sigScript, nil + return w.ScriptForOutput(context.Background(), *output) } // PrivKeyTweaker is a function type that can be used to pass in a callback for From 491f2f2041eceeb28602f17a04432f2ddce8d618 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 18 Sep 2025 18:03:15 +0800 Subject: [PATCH 053/691] wallet: use `NewAddress` in account manager's unit tests --- wallet/account_manager_test.go | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 5df350f183..bdd4ef05f6 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -347,7 +347,10 @@ func TestBalance(t *testing.T) { require.Equal(t, btcutil.Amount(0), balance) // Now, we'll add a UTXO to the account. - addr, err := w.NewAddressDeprecated(1, scope) + addr, err := w.NewAddress( + context.Background(), testAccountName, + waddrmgr.WitnessPubKey, false, + ) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) require.NoError(t, err) @@ -573,11 +576,21 @@ func TestExtractAddrFromPKScript(t *testing.T) { // addTestUTXOForBalance is a helper function to add a UTXO to the wallet. func addTestUTXOForBalance(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, - account uint32, amount btcutil.Amount) { + accountName string, amount btcutil.Amount) { t.Helper() - addr, err := w.NewAddressDeprecated(account, scope) + // This is a hack to ensure that we generate the correct address type + // for the given scope. A better solution would be to pass the + // address type as a parameter to this function. + addrType := waddrmgr.WitnessPubKey + if scope == waddrmgr.KeyScopeBIP0049Plus { + addrType = waddrmgr.NestedWitnessPubKey + } + + addr, err := w.NewAddress( + context.Background(), accountName, addrType, false, + ) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) @@ -664,10 +677,14 @@ func TestFetchAccountBalances(t *testing.T) { require.NoError(t, err) // Add UTXOs. - addTestUTXOForBalance(t, w, waddrmgr.KeyScopeBIP0084, 0, 100) - addTestUTXOForBalance(t, w, waddrmgr.KeyScopeBIP0084, 1, 200) addTestUTXOForBalance( - t, w, waddrmgr.KeyScopeBIP0049Plus, 1, 300, + t, w, waddrmgr.KeyScopeBIP0084, "default", 100, + ) + addTestUTXOForBalance( + t, w, waddrmgr.KeyScopeBIP0084, "acc1-bip84", 200, + ) + addTestUTXOForBalance( + t, w, waddrmgr.KeyScopeBIP0049Plus, "acc1-bip49", 300, ) // Update sync state. From 3f1efcd60218c3743e7ff523805927f08d06cafb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 18 Sep 2025 18:12:01 +0800 Subject: [PATCH 054/691] wallet: return `Script` in `ScriptForOutput` for clarity --- wallet/address_manager.go | 35 ++++++++++++++++++++++++---------- wallet/address_manager_test.go | 8 +++----- wallet/signer.go | 7 ++++++- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 6cff951bfb..c02e5232bf 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -48,6 +48,18 @@ type AddressProperty struct { Balance btcutil.Amount } +// Script represents the script information required to spend a UTXO. +type Script struct { + // Addr is the managed address of the UTXO. + Addr waddrmgr.ManagedPubKeyAddress + + // WitnessProgram is the witness program of the UTXO. + WitnessProgram []byte + + // RedeemScript is the redeem script of the UTXO. + RedeemScript []byte +} + // AddressManager provides an interface for generating and inspecting wallet // addresses and scripts. type AddressManager interface { @@ -94,8 +106,7 @@ type AddressManager interface { // ScriptForOutput returns the address, witness program, and redeem // script for a given UTXO. - ScriptForOutput(ctx context.Context, output wire.TxOut) ( - waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) + ScriptForOutput(ctx context.Context, output wire.TxOut) (Script, error) } // A compile time check to ensure that Wallet implements the interface. @@ -662,19 +673,19 @@ func (w *Wallet) ImportTaprootScript(_ context.Context, // is typically fast (O(log N) or O(1) with indexing). The script // generation is a constant-time operation. func (w *Wallet) ScriptForOutput(_ context.Context, output wire.TxOut) ( - waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) { + Script, error) { // First make sure we can sign for the input by making sure the script // in the UTXO belongs to our wallet and we have the private key for it. walletAddr, err := w.fetchOutputAddr(output.PkScript) if err != nil { - return nil, nil, nil, err + return Script{}, err } pubKeyAddr, ok := walletAddr.(waddrmgr.ManagedPubKeyAddress) if !ok { - return nil, nil, nil, fmt.Errorf("address %s is not a "+ - "p2wkh or np2wkh address", walletAddr.Address()) + return Script{}, fmt.Errorf("%w: %s", ErrNotPubKeyAddress, + walletAddr.Address()) } var ( @@ -697,12 +708,12 @@ func (w *Wallet) ScriptForOutput(_ context.Context, output wire.TxOut) ( pubKeyHash, w.chainParams, ) if err != nil { - return nil, nil, nil, err + return Script{}, err } witnessProgram, err = txscript.PayToAddrScript(p2wkhAddr) if err != nil { - return nil, nil, nil, err + return Script{}, err } bldr := txscript.NewScriptBuilder() @@ -710,7 +721,7 @@ func (w *Wallet) ScriptForOutput(_ context.Context, output wire.TxOut) ( sigScript, err = bldr.Script() if err != nil { - return nil, nil, nil, err + return Script{}, err } // Otherwise, this is a regular p2wkh or p2tr output, so we include the @@ -722,5 +733,9 @@ func (w *Wallet) ScriptForOutput(_ context.Context, output wire.TxOut) ( witnessProgram = output.PkScript } - return pubKeyAddr, witnessProgram, sigScript, nil + return Script{ + Addr: pubKeyAddr, + WitnessProgram: witnessProgram, + RedeemScript: sigScript, + }, nil } diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 1e109f3239..22914827d4 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -464,12 +464,10 @@ func TestScriptForOutput(t *testing.T) { } // Get the script for the output. - _, witnessProgram, sigScript, err := w.ScriptForOutput( - context.Background(), output, - ) + script, err := w.ScriptForOutput(context.Background(), output) require.NoError(t, err) // Check that the script is correct. - require.Equal(t, pkScript, witnessProgram) - require.Nil(t, sigScript) + require.Equal(t, pkScript, script.WitnessProgram) + require.Nil(t, script.RedeemScript) } diff --git a/wallet/signer.go b/wallet/signer.go index af94cf5500..8149e41081 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -21,7 +21,12 @@ import ( func (w *Wallet) ScriptForOutputDeprecated(output *wire.TxOut) ( waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) { - return w.ScriptForOutput(context.Background(), *output) + script, err := w.ScriptForOutput(context.Background(), *output) + if err != nil { + return nil, nil, nil, err + } + + return script.Addr, script.WitnessProgram, script.RedeemScript, nil } // PrivKeyTweaker is a function type that can be used to pass in a callback for From 76458d79664bee80a8785ad542b8961056bc21f1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 18 Sep 2025 18:40:28 +0800 Subject: [PATCH 055/691] wallet: use `t.Cleanup` instead of `defer cleanup()` --- wallet/account_manager_test.go | 20 ++++++++++---------- wallet/address_manager_test.go | 14 +++++++------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index bdd4ef05f6..4a087376ed 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -31,7 +31,7 @@ func TestNewAccount(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // We'll start by creating a new account under the BIP0084 scope. We // expect this to succeed. @@ -71,7 +71,7 @@ func TestListAccounts(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // We'll start by creating a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -128,7 +128,7 @@ func TestListAccountsByScope(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // We'll create two new accounts, one under the BIP0084 scope and one // under the BIP0049 scope. @@ -186,7 +186,7 @@ func TestListAccountsByName(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // We'll create two new accounts, one under the BIP0084 scope and one // under the BIP0049 scope. @@ -243,7 +243,7 @@ func TestGetAccount(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -281,7 +281,7 @@ func TestRenameAccount(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -332,7 +332,7 @@ func TestBalance(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -420,7 +420,7 @@ func TestImportAccount(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // We'll start by creating a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -749,7 +749,7 @@ func TestFetchAccountBalances(t *testing.T) { t.Parallel() w, cleanup := setupTestCase(t) - defer cleanup() + t.Cleanup(cleanup) if tc.setup != nil { tc.setup(t, w) @@ -781,7 +781,7 @@ func TestListAccountsWithBalances(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // We'll create two new accounts under the BIP0084 scope to have a // predictable state. diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 22914827d4..618d478b3f 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -83,7 +83,7 @@ func TestNewAddress(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // Define a set of test cases to cover different address types and // scenarios. @@ -178,7 +178,7 @@ func TestGetUnusedAddress(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // Get a new address to start with. addr, err := w.NewAddress( @@ -261,7 +261,7 @@ func TestAddressInfo(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // Get a new external address to test with. extAddr, err := w.NewAddress( @@ -305,7 +305,7 @@ func TestListAddresses(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // Get a new address and give it a balance. addr, err := w.NewAddress( @@ -370,7 +370,7 @@ func TestImportPublicKey(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // Create a new public key to import. privKey, err := btcec.NewPrivateKey() @@ -401,7 +401,7 @@ func TestImportTaprootScript(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // Create a new tapscript to import. privKey, err := btcec.NewPrivateKey() @@ -448,7 +448,7 @@ func TestScriptForOutput(t *testing.T) { // Create a new test wallet. w, cleanup := testWallet(t) - defer cleanup() + t.Cleanup(cleanup) // Create a new p2wkh address and output. addr, err := w.NewAddress( From ff895262c3eeb7dc290f2f0c2b556745a3e6aaa5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 29 Sep 2025 16:41:40 +0800 Subject: [PATCH 056/691] wallet: disable `wrapcheck` in account manager and `ireturn` Fixing them would cause too many conflicts in the following PRs - we will revisit once the `Store` implementation begins. --- .golangci.yml | 3 +++ wallet/address_manager.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 4a4324f61f..3155fed1f9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,9 @@ run: linters: default: all disable: + # TODO(yy): Re-enable this linter once the refactoring series is done. + - ireturn + # Global variables are used in many places throughout the code base. - gochecknoglobals diff --git a/wallet/address_manager.go b/wallet/address_manager.go index c02e5232bf..1012cb3a99 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -2,6 +2,12 @@ // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. +// Package wallet provides the AddressManager interface for generating and +// inspecting wallet addresses and scripts. +// +// TODO(yy): bring wrapcheck back when implementing the `Store` interface. +// +//nolint:wrapcheck package wallet import ( From ad6fc347295e6f18bef29f7b4aa886f9ce1d126d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 30 Aug 2025 18:31:50 +0800 Subject: [PATCH 057/691] multi: fix existing linter errors --- rpc/rpcserver/server.go | 4 +++- wallet/address_manager_test.go | 4 +++- wallet/import_test.go | 7 +++++-- wallet/interface.go | 10 +++++----- wallet/psbt.go | 9 ++++++--- wallet/signer.go | 12 ++++++++---- wallet/wallet.go | 24 ++++++++++++++++++------ 7 files changed, 48 insertions(+), 22 deletions(-) diff --git a/rpc/rpcserver/server.go b/rpc/rpcserver/server.go index ab91d8b323..c1e893a2bd 100644 --- a/rpc/rpcserver/server.go +++ b/rpc/rpcserver/server.go @@ -234,7 +234,9 @@ func (s *walletServer) NextAddress(ctx context.Context, req *pb.NextAddressReque ) switch req.Kind { case pb.NextAddressRequest_BIP0044_EXTERNAL: - addr, err = s.wallet.NewAddressDeprecated(req.GetAccount(), waddrmgr.KeyScopeBIP0044) + addr, err = s.wallet.NewAddressDeprecated( + req.GetAccount(), waddrmgr.KeyScopeBIP0044, + ) case pb.NextAddressRequest_BIP0044_INTERNAL: addr, err = s.wallet.NewChangeAddress(req.Account, waddrmgr.KeyScopeBIP0044) default: diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 618d478b3f..68a3f24a86 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -164,7 +164,9 @@ func TestNewAddress(t *testing.T) { // Verify that the address is correctly marked as // internal or external. - addrInfo, err := w.AddressInfo(context.Background(), addr) + addrInfo, err := w.AddressInfo( + context.Background(), addr, + ) require.NoError(t, err) require.Equal(t, tc.change, addrInfo.Internal()) }) diff --git a/wallet/import_test.go b/wallet/import_test.go index 88d0271362..5e81f9eca6 100644 --- a/wallet/import_test.go +++ b/wallet/import_test.go @@ -243,7 +243,9 @@ func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, require.Equal(t, uint32(0), acct2.ImportedKeyCount) // Test address derivation. - extAddr, err := w.NewAddressDeprecated(acct1.AccountNumber, tc.expectedScope) + extAddr, err := w.NewAddressDeprecated( + acct1.AccountNumber, tc.expectedScope, + ) require.NoError(t, err) require.Equal(t, tc.expectedAddr, extAddr.String()) intAddr, err := w.NewChangeAddress(acct1.AccountNumber, tc.expectedScope) @@ -257,7 +259,8 @@ func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, require.Equal(t, uint32(1), acct1.ExternalKeyCount) require.Equal(t, uint32(0), acct1.ImportedKeyCount) - // Make sure we can't get private keys for the imported accounts. + // Make sure we can't get private keys for the imported + // accounts. _, err = w.DumpWIFPrivateKey(intAddr) require.True(t, waddrmgr.IsError(err, waddrmgr.ErrWatchingOnly)) diff --git a/wallet/interface.go b/wallet/interface.go index 4aa347b165..0f8128f0dc 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -329,9 +329,9 @@ type Interface interface { DeriveFromKeyPathAddAccount(scope waddrmgr.KeyScope, path waddrmgr.DerivationPath) (*btcec.PrivateKey, error) - // ComputeInputScript generates a complete InputScript for the passed - // transaction with the signature as defined within the passed - // SignDescriptor. + // ComputeInputScript generates a complete InputScript for the + // passed transaction with the signature as defined within the + // passed SignDescriptor. ComputeInputScript(tx *wire.MsgTx, output *wire.TxOut, inputIndex int, sigHashes *txscript.TxSigHashes, hashType txscript.SigHashType, @@ -341,8 +341,8 @@ type Interface interface { // redeem script for a given UTXO. // // Deprecated: Use AddressManager.ScriptForOutput instead. - ScriptForOutputDeprecated(output *wire.TxOut) (waddrmgr.ManagedPubKeyAddress, - []byte, []byte, error) + ScriptForOutputDeprecated(output *wire.TxOut) ( + waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) } // A compile time check to ensure that Wallet implements the interface. diff --git a/wallet/psbt.go b/wallet/psbt.go index 3a0f604f85..b51d0deb1b 100644 --- a/wallet/psbt.go +++ b/wallet/psbt.go @@ -278,7 +278,9 @@ func (w *Wallet) DecorateInputs(packet *psbt.Packet, failOnUnknown bool) error { return fmt.Errorf("error fetching UTXO: %w", err) } - addr, witnessProgram, _, err := w.ScriptForOutputDeprecated(utxo) + addr, witnessProgram, _, err := w.ScriptForOutputDeprecated( + utxo, + ) if err != nil { return fmt.Errorf("error fetching UTXO script: %w", err) } @@ -300,8 +302,9 @@ func (w *Wallet) DecorateInputs(packet *psbt.Packet, failOnUnknown bool) error { return nil } -// addInputInfoSegWitV0 adds the UTXO and BIP32 derivation info for a SegWit v0 -// PSBT input (p2wkh, np2wkh) from the given wallet information. +// addInputInfoSegWitV0 adds the UTXO and BIP32 derivation info for a +// SegWit v0 PSBT input (p2wkh, np2wkh) from the given wallet +// information. func addInputInfoSegWitV0(in *psbt.PInput, prevTx *wire.MsgTx, utxo *wire.TxOut, derivationInfo *psbt.Bip32Derivation, addr waddrmgr.ManagedAddress, witnessProgram []byte) { diff --git a/wallet/signer.go b/wallet/signer.go index 8149e41081..6f40ee0ed4 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -34,15 +34,19 @@ func (w *Wallet) ScriptForOutputDeprecated(output *wire.TxOut) ( type PrivKeyTweaker func(*btcec.PrivateKey) (*btcec.PrivateKey, error) // ComputeInputScript generates a complete InputScript for the passed -// transaction with the signature as defined within the passed SignDescriptor. -// This method is capable of generating the proper input script for both -// regular p2wkh output and p2wkh outputs nested within a regular p2sh output. +// transaction with the signature as defined within the passed +// SignDescriptor. This method is capable of generating the proper input +// script for both regular p2wkh output and p2wkh outputs nested within a +// regular p2sh output. func (w *Wallet) ComputeInputScript(tx *wire.MsgTx, output *wire.TxOut, inputIndex int, sigHashes *txscript.TxSigHashes, hashType txscript.SigHashType, tweaker PrivKeyTweaker) (wire.TxWitness, []byte, error) { - walletAddr, witnessProgram, sigScript, err := w.ScriptForOutputDeprecated(output) + walletAddr, witnessProgram, sigScript, err := + w.ScriptForOutputDeprecated( + output, + ) if err != nil { return nil, nil, err } diff --git a/wallet/wallet.go b/wallet/wallet.go index 68ae16aa06..b813b672bc 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -90,6 +90,11 @@ var ( // watch-only mode where we can select coins but not sign any inputs. ErrTxUnsigned = errors.New("watch-only wallet, transaction not signed") + // ErrNoAssocPrivateKey is returned when a private key is requested for + // an address that has no associated private key. + ErrNoAssocPrivateKey = errors.New("address does not have an " + + "associated private key") + // Namespace bucket keys. waddrmgrNamespaceKey = []byte("waddrmgr") wtxmgrNamespaceKey = []byte("wtxmgr") @@ -1565,6 +1570,7 @@ func (w *Wallet) Locked() bool { // // TODO: To prevent the above scenario, perhaps closures should be passed // to the walletLocker goroutine and disallow callers from explicitly + // handling the locking mechanism. func (w *Wallet) holdUnlock() (heldUnlock, error) { req := make(chan heldUnlock) @@ -1913,17 +1919,20 @@ func (w *Wallet) PrivKeyForAddress(a btcutil.Address) (*btcec.PrivateKey, error) err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - managedAddr, err := w.addrStore.Address(addrmgrNs, a) + addr, err := w.addrStore.Address(addrmgrNs, a) if err != nil { return err } - managedPubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) + + managedPubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) if !ok { - return errors.New("address does not have an associated private key") + return ErrNoAssocPrivateKey } + privKey, err = managedPubKeyAddr.PrivKey() return err }) + return privKey, err } @@ -1956,8 +1965,11 @@ func (w *Wallet) AccountOfAddress(a btcutil.Address) (uint32, error) { return account, err } -// AddressInfoDeprecated returns detailed information regarding a wallet address. -func (w *Wallet) AddressInfoDeprecated(a btcutil.Address) (waddrmgr.ManagedAddress, error) { +// AddressInfoDeprecated returns detailed information regarding a wallet +// address. +func (w *Wallet) AddressInfoDeprecated(a btcutil.Address) ( + waddrmgr.ManagedAddress, error) { + var managedAddress waddrmgr.ManagedAddress err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) @@ -4058,8 +4070,8 @@ func (w *Wallet) DeriveFromKeyPath(scope waddrmgr.KeyScope, return err } - privKey, err = mpka.PrivKey() + privKey, err = mpka.PrivKey() return err }) if err != nil { From b9ab540d482b17baa4a709f400ae05a29b6835d9 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 6 Oct 2025 10:00:52 +0000 Subject: [PATCH 058/691] wallet: restore removed utils from unintended historical rebase removal --- wallet/account_manager_benchmark_test.go | 48 ++++++------ wallet/benchmark_helpers_test.go | 98 ++++++++++++++---------- 2 files changed, 83 insertions(+), 63 deletions(-) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index 5cc2c91193..e706ef3372 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -13,7 +13,7 @@ import ( // multiple dataset sizes. Test names start with dataset size to group API // comparisons for benchstat analysis. func BenchmarkListAccountsByScopeAPI(b *testing.B) { - benchmarkSizes := generateBenchmarkSizes( + benchmarkSizes, namingInfo := generateBenchmarkSizes( benchmarkConfig{ accountGrowth: linearGrowth, utxoGrowth: exponentialGrowth, @@ -24,7 +24,7 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} for _, size := range benchmarkSizes { - b.Run(size.name()+"/0-Before", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -42,7 +42,7 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { } }) - b.Run(size.name()+"/1-After", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -69,7 +69,7 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { // sizes. Test names start with dataset size to group API comparisons for // benchstat analysis. func BenchmarkListAccountsAPI(b *testing.B) { - benchmarkSizes := generateBenchmarkSizes( + benchmarkSizes, namingInfo := generateBenchmarkSizes( benchmarkConfig{ accountGrowth: linearGrowth, utxoGrowth: exponentialGrowth, @@ -80,7 +80,7 @@ func BenchmarkListAccountsAPI(b *testing.B) { scopes := waddrmgr.DefaultKeyScopes for _, size := range benchmarkSizes { - b.Run(size.name()+"/0-Before", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -98,7 +98,7 @@ func BenchmarkListAccountsAPI(b *testing.B) { } }) - b.Run(size.name()+"/1-After", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -123,7 +123,7 @@ func BenchmarkListAccountsAPI(b *testing.B) { // multiple dataset sizes. Test names start with dataset size to group API // comparisons for benchstat analysis. func BenchmarkListAccountsByNameAPI(b *testing.B) { - benchmarkSizes := generateBenchmarkSizes( + benchmarkSizes, namingInfo := generateBenchmarkSizes( benchmarkConfig{ accountGrowth: linearGrowth, utxoGrowth: exponentialGrowth, @@ -136,7 +136,7 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { for _, size := range benchmarkSizes { accountName, _ := generateAccountName(size.numAccounts, scopes) - b.Run(size.name()+"/0-Before", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -156,7 +156,7 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { } }) - b.Run(size.name()+"/1-After", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -183,7 +183,7 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { // names start with dataset size to group API comparisons for benchstat // analysis. func BenchmarkNewAccountAPI(b *testing.B) { - benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: linearGrowth, utxoGrowth: constantGrowth, maxIterations: 10, @@ -192,7 +192,7 @@ func BenchmarkNewAccountAPI(b *testing.B) { scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} for _, size := range benchmarkSizes { - b.Run(size.name()+"/0-Before", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -219,7 +219,7 @@ func BenchmarkNewAccountAPI(b *testing.B) { } }) - b.Run(size.name()+"/1-After", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -254,7 +254,7 @@ func BenchmarkNewAccountAPI(b *testing.B) { // using identical account lookups across multiple dataset sizes. Test names // start with dataset size to group API comparisons for benchstat analysis. func BenchmarkGetAccountAPI(b *testing.B) { - benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: exponentialGrowth, utxoGrowth: exponentialGrowth, maxIterations: 14, @@ -265,7 +265,7 @@ func BenchmarkGetAccountAPI(b *testing.B) { for _, size := range benchmarkSizes { accountName, _ := generateAccountName(size.numAccounts, scopes) - b.Run(size.name()+"/0-Before", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -285,7 +285,7 @@ func BenchmarkGetAccountAPI(b *testing.B) { } }) - b.Run(size.name()+"/1-After", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -312,7 +312,7 @@ func BenchmarkGetAccountAPI(b *testing.B) { // dataset sizes. Test names start with dataset size to group API comparisons // for benchstat analysis. func BenchmarkRenameAccountAPI(b *testing.B) { - benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: exponentialGrowth, utxoGrowth: constantGrowth, maxIterations: 11, @@ -326,7 +326,7 @@ func BenchmarkRenameAccountAPI(b *testing.B) { ) newName := accountName + "-renamed" - b.Run(size.name()+"/0-Before", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -357,7 +357,7 @@ func BenchmarkRenameAccountAPI(b *testing.B) { } }) - b.Run(size.name()+"/1-After", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -396,7 +396,7 @@ func BenchmarkRenameAccountAPI(b *testing.B) { // using identical balance lookups across multiple dataset sizes. Test names // start with dataset size to group API comparisons for benchstat analysis. func BenchmarkGetBalanceAPI(b *testing.B) { - benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: linearGrowth, utxoGrowth: exponentialGrowth, maxIterations: 14, @@ -408,7 +408,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { for _, size := range benchmarkSizes { accountName, _ := generateAccountName(size.numAccounts, scopes) - b.Run(size.name()+"/0-Before", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -429,7 +429,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { } }) - b.Run(size.name()+"/1-After", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -457,7 +457,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { // across multiple dataset sizes. Test names start with dataset size to group // API comparisons for benchstat analysis. func BenchmarkImportAccountAPI(b *testing.B) { - benchmarkSizes := generateBenchmarkSizes(benchmarkConfig{ + benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: linearGrowth, utxoGrowth: constantGrowth, maxIterations: 10, @@ -471,7 +471,7 @@ func BenchmarkImportAccountAPI(b *testing.B) { b, size.numAccounts, ) - b.Run(size.name()+"/0-Before", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, @@ -501,7 +501,7 @@ func BenchmarkImportAccountAPI(b *testing.B) { } }) - b.Run(size.name()+"/1-After", func(b *testing.B) { + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index a4ca70538b..b8b3bf6bd2 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -28,6 +28,11 @@ var errAccountNotFound = errors.New("account not found") // etc.). type growthFunc func(i int) int +// constantGrowth returns a constant value regardless of iteration. +func constantGrowth(i int) int { + return 5 +} + // linearGrowth scales the parameter value linearly. func linearGrowth(i int) int { return 5 + (i * 5) @@ -38,42 +43,43 @@ func exponentialGrowth(i int) int { return 1 << i } -// constantGrowth scales the parameter value to a constant value. -func constantGrowth(i int) int { - return 5 -} - -// benchmarkDataSize represents different test data sizes for stress testing. +// benchmarkDataSize represents the test data size for a single benchmark +// iteration. type benchmarkDataSize struct { // numAccounts is the number of accounts to create. numAccounts int // numUTXOs is the number of UTXOs to create. numUTXOs int +} +// benchmarkNamingInfo holds metadata for generating benchmark names. +type benchmarkNamingInfo struct { // maxAccounts is the maximum number of accounts in the benchmark - // series. + // series. That would helpful in determining the dynamic padding for the + // account digits maxAccounts int - // maxUTXOs is the maximum number of UTXOs in the benchmark series. + // maxUTXOs is the maximum number of UTXOs in the benchmark series. That + // would helpful in determining the dynamic padding for the UTXO digits. maxUTXOs int } -// name returns a dynamically generated benchmark name based on accounts and -// UTXOs. Uses dynamic padding based on maximum values for proper sorting in -// visualization tools. If numUTXOs is 0, it's omitted from the name. -func (b benchmarkDataSize) name() string { - accountDigits := len(strconv.Itoa(b.maxAccounts)) +// name returns a dynamically generated benchmark name based on accounts, +// UTXOs, and addresses. Uses dynamic padding based on maximum values for +// proper sorting in visualization tools. If numUTXOs is 0, it's omitted +// from the name. +func (b benchmarkDataSize) name(namingInfo benchmarkNamingInfo) string { + accountDigits := len(strconv.Itoa(namingInfo.maxAccounts)) - if b.numUTXOs == 0 { - return fmt.Sprintf("%0*d-Accounts", accountDigits, - b.numAccounts) - } + name := fmt.Sprintf("%0*d-Accounts", accountDigits, b.numAccounts) - utxoDigits := len(strconv.Itoa(b.maxUTXOs)) + if b.numUTXOs > 0 { + utxoDigits := len(strconv.Itoa(namingInfo.maxUTXOs)) + name += fmt.Sprintf("-%0*d-UTXOs", utxoDigits, b.numUTXOs) + } - return fmt.Sprintf("%0*d-Accounts-%0*d-UTXOs", - accountDigits, b.numAccounts, utxoDigits, b.numUTXOs) + return name } // benchmarkConfig holds configuration for benchmark wallet setup. @@ -92,23 +98,28 @@ type benchmarkConfig struct { } // generateBenchmarkSizes creates benchmark data sizes programmatically. -func generateBenchmarkSizes(config benchmarkConfig) []benchmarkDataSize { +func generateBenchmarkSizes( + config benchmarkConfig) ([]benchmarkDataSize, benchmarkNamingInfo) { + var sizes []benchmarkDataSize // Calculate maximum values for proper padding. maxAccounts := config.accountGrowth(config.maxIterations) maxUTXOs := config.utxoGrowth(config.maxIterations) + namingInfo := benchmarkNamingInfo{ + maxAccounts: maxAccounts, + maxUTXOs: maxUTXOs, + } + for i := config.startIndex; i <= config.maxIterations; i++ { sizes = append(sizes, benchmarkDataSize{ numAccounts: config.accountGrowth(i), numUTXOs: config.utxoGrowth(i), - maxAccounts: maxAccounts, - maxUTXOs: maxUTXOs, }) } - return sizes + return sizes, namingInfo } // benchmarkWalletConfig holds configuration for benchmark wallet setup. @@ -132,17 +143,16 @@ func setupBenchmarkWallet(tb testing.TB, config benchmarkWalletConfig) *Wallet { tb.Helper() // Since testWallet requires a *testing.T, we can't pass the benchmark's - // *testing.B. Instead, we create a dummy *testing.T and manually fail + // *testing.B. Instead, we create a setup *testing.T and manually fail // the benchmark if the setup fails. - dummyT := &testing.T{} - w, cleanup := testWallet(dummyT) + setupT := &testing.T{} + w, cleanup := testWallet(setupT) tb.Cleanup(cleanup) - require.False(tb, dummyT.Failed(), "testWallet setup failed") + require.False(tb, setupT.Failed(), "testWallet setup failed") addresses := createTestAccounts( tb, w, config.scopes, config.numAccounts, ) - if !config.skipUTXOs && config.numUTXOs > 0 { createTestUTXOs(tb, w, addresses, config.numUTXOs) } @@ -173,7 +183,7 @@ func createTestAccounts(tb testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, err := createAccountsInScope( w, tx, scope, scopeAccounts, i*accountsPerScope, - allAddresses, + &allAddresses, ) if err != nil { return err @@ -192,7 +202,7 @@ func createTestAccounts(tb testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, // naming across scopes. func createAccountsInScope(w *Wallet, tx walletdb.ReadWriteTx, scope waddrmgr.KeyScope, numAccounts, offset int, - allAddresses []waddrmgr.ManagedAddress) error { + allAddresses *[]waddrmgr.ManagedAddress) error { manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { @@ -217,7 +227,7 @@ func createAccountsInScope(w *Wallet, tx walletdb.ReadWriteTx, return err } - allAddresses = append(allAddresses, addrs...) + *allAddresses = append(*allAddresses, addrs...) } return nil @@ -232,6 +242,7 @@ func createTestUTXOs(tb testing.TB, w *Wallet, err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) msgTx := TstTx.MsgTx() for i := 0; i < numUTXOs && i < len(addresses); i++ { @@ -285,6 +296,13 @@ func createTestUTXOs(tb testing.TB, w *Wallet, if err != nil { return err } + + err = w.addrStore.MarkUsed( + addrmgrNs, addr.Address(), + ) + if err != nil { + return err + } } return nil @@ -318,36 +336,38 @@ func generateAccountName(numAccounts int, // generateTestExtendedKey generates a test extended public key for benchmarking // ImportAccount operations. It uses a deterministic seed based on the -// iteration index to ensure consistent results across benchmark runs. +// seed index to ensure consistent and unique results across benchmark runs. func generateTestExtendedKey(tb testing.TB, - i int) (*hdkeychain.ExtendedKey, uint32, waddrmgr.AddressType) { + seedIndex int) (*hdkeychain.ExtendedKey, uint32, waddrmgr.AddressType) { tb.Helper() - // Use a simple deterministic seed based on iteration index. + // Use a simple deterministic seed based on seed index. seed := make([]byte, 32) for j := range seed { - seed[j] = byte(i + j) + seed[j] = byte(seedIndex + j) } // Create master key from seed. masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.TestNet3Params) require.NoError(tb, err) - // Derive account key for BIP0084 (m/84'/1'/i'). + // Derive account key for BIP0084 (m/84'/1'/seedIndex'). purpose, err := masterKey.Derive(hdkeychain.HardenedKeyStart + 84) require.NoError(tb, err) coin, err := purpose.Derive(hdkeychain.HardenedKeyStart + 1) require.NoError(tb, err) - account, err := coin.Derive(hdkeychain.HardenedKeyStart + uint32(i)) + account, err := coin.Derive( + hdkeychain.HardenedKeyStart + uint32(seedIndex), + ) require.NoError(tb, err) accountPubKey, err := account.Neuter() require.NoError(tb, err) - return accountPubKey, uint32(i), waddrmgr.WitnessPubKey + return accountPubKey, uint32(seedIndex), waddrmgr.WitnessPubKey } // listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same From fac7a3d4e8be6b1d50475c847d0a85ebcf25ffe5 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 6 Oct 2025 10:34:01 +0000 Subject: [PATCH 059/691] wallet: introduce address growth for benchmarking --- wallet/account_manager_benchmark_test.go | 120 ++++++++++++++--------- wallet/benchmark_helpers_test.go | 51 +++++++--- 2 files changed, 107 insertions(+), 64 deletions(-) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index e706ef3372..022d7b2beb 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -16,6 +16,7 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { benchmarkSizes, namingInfo := generateBenchmarkSizes( benchmarkConfig{ accountGrowth: linearGrowth, + addressGrowth: constantGrowth, utxoGrowth: exponentialGrowth, maxIterations: 14, startIndex: 0, @@ -27,9 +28,10 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -45,9 +47,10 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -72,6 +75,7 @@ func BenchmarkListAccountsAPI(b *testing.B) { benchmarkSizes, namingInfo := generateBenchmarkSizes( benchmarkConfig{ accountGrowth: linearGrowth, + addressGrowth: constantGrowth, utxoGrowth: exponentialGrowth, maxIterations: 14, startIndex: 0, @@ -83,9 +87,10 @@ func BenchmarkListAccountsAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -101,9 +106,10 @@ func BenchmarkListAccountsAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -126,6 +132,7 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { benchmarkSizes, namingInfo := generateBenchmarkSizes( benchmarkConfig{ accountGrowth: linearGrowth, + addressGrowth: constantGrowth, utxoGrowth: exponentialGrowth, maxIterations: 14, startIndex: 0, @@ -139,9 +146,10 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -159,9 +167,10 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -185,6 +194,7 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { func BenchmarkNewAccountAPI(b *testing.B) { benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: linearGrowth, + addressGrowth: constantGrowth, utxoGrowth: constantGrowth, maxIterations: 10, startIndex: 0, @@ -195,9 +205,10 @@ func BenchmarkNewAccountAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - skipUTXOs: true, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -222,9 +233,10 @@ func BenchmarkNewAccountAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - skipUTXOs: true, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -256,6 +268,7 @@ func BenchmarkNewAccountAPI(b *testing.B) { func BenchmarkGetAccountAPI(b *testing.B) { benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: exponentialGrowth, + addressGrowth: constantGrowth, utxoGrowth: exponentialGrowth, maxIterations: 14, startIndex: 0, @@ -268,9 +281,10 @@ func BenchmarkGetAccountAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -288,9 +302,10 @@ func BenchmarkGetAccountAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -314,6 +329,7 @@ func BenchmarkGetAccountAPI(b *testing.B) { func BenchmarkRenameAccountAPI(b *testing.B) { benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: exponentialGrowth, + addressGrowth: constantGrowth, utxoGrowth: constantGrowth, maxIterations: 11, startIndex: 0, @@ -329,9 +345,10 @@ func BenchmarkRenameAccountAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - skipUTXOs: true, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -360,9 +377,10 @@ func BenchmarkRenameAccountAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - skipUTXOs: true, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -398,6 +416,7 @@ func BenchmarkRenameAccountAPI(b *testing.B) { func BenchmarkGetBalanceAPI(b *testing.B) { benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: linearGrowth, + addressGrowth: constantGrowth, utxoGrowth: exponentialGrowth, maxIterations: 14, startIndex: 0, @@ -411,9 +430,10 @@ func BenchmarkGetBalanceAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -432,9 +452,10 @@ func BenchmarkGetBalanceAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - numUTXOs: size.numUTXOs, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -459,6 +480,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { func BenchmarkImportAccountAPI(b *testing.B) { benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ accountGrowth: linearGrowth, + addressGrowth: constantGrowth, utxoGrowth: constantGrowth, maxIterations: 10, startIndex: 0, @@ -474,9 +496,10 @@ func BenchmarkImportAccountAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - skipUTXOs: true, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) @@ -504,9 +527,10 @@ func BenchmarkImportAccountAPI(b *testing.B) { b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ - scopes: scopes, - numAccounts: size.numAccounts, - skipUTXOs: true, + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, }, ) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index b8b3bf6bd2..5dc92e5163 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -51,6 +51,9 @@ type benchmarkDataSize struct { // numUTXOs is the number of UTXOs to create. numUTXOs int + + // numAddresses is the number of addresses to create. + numAddresses int } // benchmarkNamingInfo holds metadata for generating benchmark names. @@ -63,17 +66,27 @@ type benchmarkNamingInfo struct { // maxUTXOs is the maximum number of UTXOs in the benchmark series. That // would helpful in determining the dynamic padding for the UTXO digits. maxUTXOs int + + // maxAddresses is the maximum number of addresses in the benchmark + // series. That would helpful in determining the dynamic padding for the + // address digits. + maxAddresses int } // name returns a dynamically generated benchmark name based on accounts, // UTXOs, and addresses. Uses dynamic padding based on maximum values for -// proper sorting in visualization tools. If numUTXOs is 0, it's omitted -// from the name. +// proper sorting in visualization tools. func (b benchmarkDataSize) name(namingInfo benchmarkNamingInfo) string { accountDigits := len(strconv.Itoa(namingInfo.maxAccounts)) name := fmt.Sprintf("%0*d-Accounts", accountDigits, b.numAccounts) + if b.numAddresses > 0 { + addressDigits := len(fmt.Sprintf("%d", namingInfo.maxAddresses)) + name += fmt.Sprintf("-%0*d-Addresses", addressDigits, + b.numAddresses) + } + if b.numUTXOs > 0 { utxoDigits := len(strconv.Itoa(namingInfo.maxUTXOs)) name += fmt.Sprintf("-%0*d-UTXOs", utxoDigits, b.numUTXOs) @@ -90,6 +103,9 @@ type benchmarkConfig struct { // utxoGrowth is the function to use to grow the number of UTXOs. utxoGrowth growthFunc + // addressGrowth is the function to use to grow the number of addresses. + addressGrowth growthFunc + // maxIterations is the maximum number of iterations to run. maxIterations int @@ -106,16 +122,19 @@ func generateBenchmarkSizes( // Calculate maximum values for proper padding. maxAccounts := config.accountGrowth(config.maxIterations) maxUTXOs := config.utxoGrowth(config.maxIterations) + maxAddresses := config.addressGrowth(config.maxIterations) namingInfo := benchmarkNamingInfo{ - maxAccounts: maxAccounts, - maxUTXOs: maxUTXOs, + maxAccounts: maxAccounts, + maxUTXOs: maxUTXOs, + maxAddresses: maxAddresses, } for i := config.startIndex; i <= config.maxIterations; i++ { sizes = append(sizes, benchmarkDataSize{ - numAccounts: config.accountGrowth(i), - numUTXOs: config.utxoGrowth(i), + numAccounts: config.accountGrowth(i), + numUTXOs: config.utxoGrowth(i), + numAddresses: config.addressGrowth(i), }) } @@ -133,8 +152,8 @@ type benchmarkWalletConfig struct { // numUTXOs is the number of UTXOs to create. numUTXOs int - // skipUTXOs skips UTXO creation for account-only benchmarks. - skipUTXOs bool + // numAddresses is the number of addresses to create. + numAddresses int } // setupBenchmarkWallet creates a wallet with test data based on the provided @@ -152,10 +171,10 @@ func setupBenchmarkWallet(tb testing.TB, config benchmarkWalletConfig) *Wallet { addresses := createTestAccounts( tb, w, config.scopes, config.numAccounts, + config.numAddresses, ) - if !config.skipUTXOs && config.numUTXOs > 0 { - createTestUTXOs(tb, w, addresses, config.numUTXOs) - } + + createTestUTXOs(tb, w, addresses, config.numUTXOs) return w } @@ -163,7 +182,7 @@ func setupBenchmarkWallet(tb testing.TB, config benchmarkWalletConfig) *Wallet { // createTestAccounts creates test accounts across the specified key scopes // and returns all generated addresses. func createTestAccounts(tb testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, - numAccounts int) []waddrmgr.ManagedAddress { + numAccounts, numAddresses int) []waddrmgr.ManagedAddress { tb.Helper() @@ -182,8 +201,8 @@ func createTestAccounts(tb testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, } err := createAccountsInScope( - w, tx, scope, scopeAccounts, i*accountsPerScope, - &allAddresses, + w, tx, scope, scopeAccounts, numAddresses, + i*accountsPerScope, &allAddresses, ) if err != nil { return err @@ -201,7 +220,7 @@ func createTestAccounts(tb testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, // createAccountsInScope creates accounts within a specific scope with unique // naming across scopes. func createAccountsInScope(w *Wallet, tx walletdb.ReadWriteTx, - scope waddrmgr.KeyScope, numAccounts, offset int, + scope waddrmgr.KeyScope, numAccounts, numAddresses, offset int, allAddresses *[]waddrmgr.ManagedAddress) error { manager, err := w.addrStore.FetchScopedKeyManager(scope) @@ -221,7 +240,7 @@ func createAccountsInScope(w *Wallet, tx walletdb.ReadWriteTx, } addrs, err := manager.NextExternalAddresses( - addrmgrNs, account, 5, + addrmgrNs, account, uint32(numAddresses), ) if err != nil { return err From 4ca21e30e39de373b4b0765272a96653177d288f Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 6 Oct 2025 10:59:55 +0000 Subject: [PATCH 060/691] wallet: benchmark `ListAddresses` API --- wallet/address_manager_benchmark_test.go | 74 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 30 +++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 wallet/address_manager_benchmark_test.go diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go new file mode 100644 index 0000000000..410d7660e1 --- /dev/null +++ b/wallet/address_manager_benchmark_test.go @@ -0,0 +1,74 @@ +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// BenchmarkListAddressesAPI benchmarks ListAddresses API and a deprecated +// variant of it using same key scope and identical test data across multiple +// dataset sizes. Test names start with dataset size to group API comparisons +// for benchstat analysis. +func BenchmarkListAddressesAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: exponentialGrowth, + addressGrowth: exponentialGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + addrType := waddrmgr.PubKeyHash + + for _, size := range benchmarkSizes { + accountName, accountNumber := generateAccountName( + size.numAccounts, scopes, + ) + + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := listAddressesDeprecated( + w, accountNumber, + ) + require.NoError(b, err) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := w.ListAddresses( + b.Context(), accountName, addrType, + ) + require.NoError(b, err) + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 5dc92e5163..55bf62536f 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -82,7 +82,7 @@ func (b benchmarkDataSize) name(namingInfo benchmarkNamingInfo) string { name := fmt.Sprintf("%0*d-Accounts", accountDigits, b.numAccounts) if b.numAddresses > 0 { - addressDigits := len(fmt.Sprintf("%d", namingInfo.maxAddresses)) + addressDigits := len(strconv.Itoa(namingInfo.maxAddresses)) name += fmt.Sprintf("-%0*d-Addresses", addressDigits, b.numAddresses) } @@ -504,3 +504,31 @@ func getBalanceDeprecated(w *Wallet, scope waddrmgr.KeyScope, return 0, fmt.Errorf("%w: %s", errAccountNotFound, accountName) } + +// listAddressesDeprecated wraps the deprecated AccountAddresses and +// TotalReceivedForAddr APIs to satisfy the same contract as ListAddresses by +// calling the old APIs and aggregating the results with balances. +func listAddressesDeprecated(w *Wallet, + accountID uint32) ([]AddressProperty, error) { + + addresses, err := w.AccountAddresses(accountID) + if err != nil { + return nil, err + } + + allProperties := make([]AddressProperty, 0, len(addresses)) + + for _, addr := range addresses { + balance, err := w.TotalReceivedForAddr(addr, 0) + if err != nil { + return nil, err + } + + allProperties = append(allProperties, AddressProperty{ + Address: addr, + Balance: balance, + }) + } + + return allProperties, nil +} From deba8560fa632832485556ad89d6cd18c728e046 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 6 Oct 2025 11:06:14 +0000 Subject: [PATCH 061/691] wallet: benchmark `AddressInfo` API --- wallet/address_manager_benchmark_test.go | 61 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 12 +++++ 2 files changed, 73 insertions(+) diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index 410d7660e1..f00e083375 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -72,3 +72,64 @@ func BenchmarkListAddressesAPI(b *testing.B) { }) } } + +// BenchmarkAddressInfoAPI benchmarks AddressInfo API and its deprecated +// variant using same key scope and identical test data across multiple +// dataset sizes. Test names start with dataset size to group API comparisons +// for benchstat analysis. +func BenchmarkAddressInfoAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: constantGrowth, + addressGrowth: exponentialGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + for _, size := range benchmarkSizes { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testAddr := getTestAddress(b, w, size.numAccounts) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := w.AddressInfoDeprecated(testAddr) + require.NoError(b, err) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testAddr := getTestAddress(b, w, size.numAccounts) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := w.AddressInfo(b.Context(), testAddr) + require.NoError(b, err) + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 55bf62536f..d058b0a0b8 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -389,6 +389,18 @@ func generateTestExtendedKey(tb testing.TB, return accountPubKey, uint32(seedIndex), waddrmgr.WitnessPubKey } +// getMedianTestAddress returns a median address from a median account for +// benchmarking purposes. +func getTestAddress(tb testing.TB, w *Wallet, numAccounts int) btcutil.Address { + tb.Helper() + + medianAccount := uint32(numAccounts / 2) + addresses, err := w.AccountAddresses(medianAccount) + require.NoError(tb, err) + + return addresses[len(addresses)/2] +} + // listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same // contract as ListAccounts by calling Accounts API across all active key scopes // and aggregating the results. From 14c23693e1a995ee4ab41239f70bf37f028f450e Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 6 Oct 2025 11:13:51 +0000 Subject: [PATCH 062/691] wallet: benchmark `GetUnusedAddress` API --- wallet/address_manager_benchmark_test.go | 77 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 20 ++++++ 2 files changed, 97 insertions(+) diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index f00e083375..249b8140ea 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -133,3 +133,80 @@ func BenchmarkAddressInfoAPI(b *testing.B) { }) } } + +// BenchmarkGetUnusedAddressAPI benchmarks GetUnusedAddress API and its +// deprecated variant NewAddressDeprecated using same key scope and identical +// address datasets across multiple dataset sizes. Test names start with dataset +// size to group API comparisons for benchstat analysis. The benchmark +// demonstrates the trade-off between performance (O(1) vs O(n)) and safety +// (preventing address reuse and BIP44 gap limit violations). +func BenchmarkGetUnusedAddressAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: constantGrowth, + addressGrowth: exponentialGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + addrType := waddrmgr.PubKeyHash + + for _, size := range benchmarkSizes { + accountName, accountNumber := generateAccountName( + size.numAccounts, scopes, + ) + + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + addr, err := w.NewAddressDeprecated( + accountNumber, scopes[0], + ) + require.NoError(b, err) + + // Mark the address as used to make the + // benchmark iteration idempotent. + markAddressAsUsed(b, w, addr) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + addr, err := w.GetUnusedAddress( + b.Context(), accountName, addrType, + false, + ) + require.NoError(b, err) + + // Mark the address as used to make the + // benchmark iteration idempotent. + markAddressAsUsed(b, w, addr) + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index d058b0a0b8..07008d26eb 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -401,6 +401,26 @@ func getTestAddress(tb testing.TB, w *Wallet, numAccounts int) btcutil.Address { return addresses[len(addresses)/2] } +// markAddressAsUsed marks an address as used in the wallet database. This is +// useful for making benchmark iterations idempotent. +func markAddressAsUsed(b *testing.B, w *Wallet, addr btcutil.Address) { + b.Helper() + + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + manager, err := w.addrStore.FetchScopedKeyManager( + waddrmgr.KeyScopeBIP0044, + ) + if err != nil { + return err + } + + return manager.MarkUsed(addrmgrNs, addr) + }) + require.NoError(b, err) +} + // listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same // contract as ListAccounts by calling Accounts API across all active key scopes // and aggregating the results. From c1e93f24b49ece4a26ae554dae30c3f606eca8b0 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 6 Oct 2025 11:24:12 +0000 Subject: [PATCH 063/691] wallet: benchmark `NewAddress` API --- wallet/address_manager_benchmark_test.go | 68 ++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index 249b8140ea..dd1def4637 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -210,3 +210,71 @@ func BenchmarkGetUnusedAddressAPI(b *testing.B) { }) } } + +// BenchmarkNewAddressAPI benchmarks NewAddress API and its deprecated variant +// NewAddressDeprecated using same key scope and identical address datasets +// across multiple dataset sizes. Test names start with dataset size to group +// API comparisons for benchstat analysis. The benchmark demonstrates that the +// new API maintains performance parity with the deprecated API. +func BenchmarkNewAddressAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: constantGrowth, + addressGrowth: exponentialGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + addrType := waddrmgr.PubKeyHash + + for _, size := range benchmarkSizes { + accountName, accountNumber := generateAccountName( + size.numAccounts, scopes, + ) + + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := w.NewAddressDeprecated( + accountNumber, scopes[0], + ) + require.NoError(b, err) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := w.NewAddress( + b.Context(), accountName, addrType, + false, + ) + require.NoError(b, err) + } + }) + } +} From 1e6344762680610a7a916c84a6d3ab229697317d Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 6 Oct 2025 11:29:37 +0000 Subject: [PATCH 064/691] wallet: benchmark `ImportPublicKey` API --- wallet/address_manager_benchmark_test.go | 87 ++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index dd1def4637..cdc5aaf93e 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -278,3 +278,90 @@ func BenchmarkNewAddressAPI(b *testing.B) { }) } } + +// BenchmarkImportPublicKeyAPI benchmarks ImportPublicKey API and its deprecated +// variant ImportPublicKeyDeprecated using identical public key datasets across +// multiple dataset sizes. Test names start with dataset size to group API +// comparisons for benchstat analysis. The benchmark demonstrates that the new +// API maintains performance parity with the deprecated API. +func BenchmarkImportPublicKeyAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: constantGrowth, + addressGrowth: linearGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + addrType := waddrmgr.WitnessPubKey + + for _, size := range benchmarkSizes { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + iterCount := 0 + for b.Loop() { + // Generate a unique key for each iteration to + // avoid in-memory cache collision and for an + // idempotent benchmark iteration test. + seedIndex := size.numAccounts + iterCount + key, _, _ := generateTestExtendedKey( + b, seedIndex, + ) + pubKey, err := key.ECPubKey() + require.NoError(b, err) + + err = w.ImportPublicKeyDeprecated( + pubKey, addrType, + ) + require.NoError(b, err) + + iterCount++ + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + iterCount := 0 + for b.Loop() { + // Generate a unique key for each iteration to + // avoid in-memory cache collision and for an + // idempotent benchmark iteration test. + seedIndex := size.numAccounts + iterCount + key, _, _ := generateTestExtendedKey( + b, seedIndex, + ) + pubKey, err := key.ECPubKey() + require.NoError(b, err) + + err = w.ImportPublicKey( + b.Context(), pubKey, addrType, + ) + require.NoError(b, err) + + iterCount++ + } + }) + } +} From 1e1e90ab53283d4ff1d816b52e34d61c828de8e8 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 6 Oct 2025 13:13:52 +0000 Subject: [PATCH 065/691] wallet: benchmark `ImportTaprootScript` API --- wallet/address_manager_benchmark_test.go | 96 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 27 +++++++ 2 files changed, 123 insertions(+) diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index cdc5aaf93e..15f718eebd 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -365,3 +365,99 @@ func BenchmarkImportPublicKeyAPI(b *testing.B) { }) } } + +// BenchmarkImportTaprootScriptAPI benchmarks ImportTaprootScript API and its +// deprecated variant ImportTaprootScriptDeprecated using identical tapscript +// datasets across multiple dataset sizes. Test names start with dataset size +// to group API comparisons for benchstat analysis. The benchmark demonstrates +// that the new API maintains performance parity with the deprecated API. +func BenchmarkImportTaprootScriptAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: constantGrowth, + addressGrowth: linearGrowth, + maxIterations: 10, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0086} + witnessVersion := 1 + isSecretScript := false + + for _, size := range benchmarkSizes { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + iterCount := 0 + for b.Loop() { + // Generate a unique tapscript for each + // iteration to avoid in-memory cache collision + // and for an idempotent benchmark iteration + // test. + seedIndex := size.numAccounts + iterCount + key, _, _ := generateTestExtendedKey( + b, seedIndex, + ) + pubKey, err := key.ECPubKey() + require.NoError(b, err) + + tapscript := generateTestTapscript(b, pubKey) + + syncedTo := w.addrStore.SyncedTo() + _, err = w.ImportTaprootScriptDeprecated( + scopes[0], &tapscript, &syncedTo, + byte(witnessVersion), isSecretScript, + ) + require.NoError(b, err) + + iterCount++ + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + iterCount := 0 + for b.Loop() { + // Generate a unique tapscript for each + // iteration to avoid in-memory cache collision + // and for an idempotent benchmark iteration + // test. + seedIndex := size.numAccounts + iterCount + key, _, _ := generateTestExtendedKey( + b, seedIndex, + ) + pubKey, err := key.ECPubKey() + require.NoError(b, err) + + tapscript := generateTestTapscript(b, pubKey) + + _, err = w.ImportTaprootScript( + b.Context(), tapscript, + ) + require.NoError(b, err) + + iterCount++ + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 07008d26eb..c73e924f66 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" @@ -421,6 +422,32 @@ func markAddressAsUsed(b *testing.B, w *Wallet, addr btcutil.Address) { require.NoError(b, err) } +// generateTestTapscript generates a test tapscript for benchmarking purposes. +// It creates a simple script that checks a signature against the provided +// public key, wraps it in a tap leaf, and returns a complete Tapscript +// structure ready for import. +func generateTestTapscript(tb testing.TB, + pubKey *btcec.PublicKey) waddrmgr.Tapscript { + + tb.Helper() + + script, err := txscript.NewScriptBuilder(). + AddData(pubKey.SerializeCompressed()). + AddOp(txscript.OP_CHECKSIG). + Script() + require.NoError(tb, err) + + leaf := txscript.NewTapLeaf(txscript.BaseLeafVersion, script) + + return waddrmgr.Tapscript{ + Type: waddrmgr.TapscriptTypeFullTree, + ControlBlock: &txscript.ControlBlock{ + InternalKey: pubKey, + }, + Leaves: []txscript.TapLeaf{leaf}, + } +} + // listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same // contract as ListAccounts by calling Accounts API across all active key scopes // and aggregating the results. From bb932ffefdddf49ed0cc876caa822b27d88a14f0 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 6 Oct 2025 13:38:11 +0000 Subject: [PATCH 066/691] wallet: benchmark `ScriptForOutput` API --- wallet/address_manager_benchmark_test.go | 68 ++++++++++++++++++++++++ wallet/benchmark_helpers_test.go | 14 +++++ 2 files changed, 82 insertions(+) diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index 15f718eebd..edb1b1b701 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -461,3 +461,71 @@ func BenchmarkImportTaprootScriptAPI(b *testing.B) { }) } } + +// BenchmarkScriptForOutputAPI benchmarks ScriptForOutput API and its deprecated +// variant ScriptForOutputDeprecated using identical TxOut datasets across +// multiple dataset sizes. Test names start with dataset size to group API +// comparisons for benchstat analysis. The benchmark demonstrates that the new +// API maintains performance parity with the deprecated API. +func BenchmarkScriptForOutputAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: constantGrowth, + utxoGrowth: constantGrowth, + addressGrowth: exponentialGrowth, + maxIterations: 10, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + for _, size := range benchmarkSizes { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testAddr := getTestAddress(b, w, size.numAccounts) + testTxOut := generateTestTxOut(b, testAddr) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, _, _, err := w.ScriptForOutputDeprecated( + &testTxOut, + ) + require.NoError(b, err) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testAddr := getTestAddress(b, w, size.numAccounts) + testTxOut := generateTestTxOut(b, testAddr) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := w.ScriptForOutput( + b.Context(), testTxOut, + ) + require.NoError(b, err) + } + }) + } +} diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index c73e924f66..0bee7805f5 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -448,6 +448,20 @@ func generateTestTapscript(tb testing.TB, } } +// generateTestTxOut generates a test TxOut for benchmarking purposes. +// It creates a TxOut with the provided address as the PkScript. +func generateTestTxOut(tb testing.TB, addr btcutil.Address) wire.TxOut { + tb.Helper() + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(tb, err) + + return wire.TxOut{ + Value: 1e8, + PkScript: pkScript, + } +} + // listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same // contract as ListAccounts by calling Accounts API across all active key scopes // and aggregating the results. From 1919338513c14ff76745645784bdce01a3a0ec8b Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Fri, 10 Oct 2025 03:09:24 +0000 Subject: [PATCH 067/691] wallet: fix linting issues --- wallet/address_manager_benchmark_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index edb1b1b701..2d45cf09f1 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -310,6 +310,7 @@ func BenchmarkImportPublicKeyAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + iterCount := 0 for b.Loop() { // Generate a unique key for each iteration to @@ -343,6 +344,7 @@ func BenchmarkImportPublicKeyAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + iterCount := 0 for b.Loop() { // Generate a unique key for each iteration to @@ -398,6 +400,7 @@ func BenchmarkImportTaprootScriptAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + iterCount := 0 for b.Loop() { // Generate a unique tapscript for each @@ -436,6 +439,7 @@ func BenchmarkImportTaprootScriptAPI(b *testing.B) { b.ReportAllocs() b.ResetTimer() + iterCount := 0 for b.Loop() { // Generate a unique tapscript for each From 59d3cf6487027835e13fc9c419a5e4a9e8256ab4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 9 Sep 2025 22:58:11 +0800 Subject: [PATCH 068/691] build: use replace for wtxmgr module --- go.mod | 5 +++++ go.sum | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 370f6965da..62b66d202e 100644 --- a/go.mod +++ b/go.mod @@ -58,3 +58,8 @@ require ( // If you change this please run `make lint` to see where else it needs to be // updated as well. go 1.24.6 + +// We use a replace directive here for our internal wtxmgr module. This ensures +// that we can freely move between tagged releases and development commits of +// this module without needing to constantly update the go.mod file. +replace github.com/btcsuite/btcwallet/wtxmgr => ./wtxmgr diff --git a/go.sum b/go.sum index 02645e0d08..9267a4680d 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,6 @@ github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5 h1:93o5Xz9dYepBP4RMFUc9RGIFX github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5/go.mod h1:lQ+e9HxZ85QP7r3kdxItkiMSloSLg1PEGis5o5CXUQw= github.com/btcsuite/btcwallet/walletdb v1.5.1 h1:HgMhDNCrtEFPC+8q0ei5DQ5U9Tl4RCspA22DEKXlopI= github.com/btcsuite/btcwallet/walletdb v1.5.1/go.mod h1:jk/hvpLFINF0C1kfTn0bfx2GbnFT+Nvnj6eblZALfjs= -github.com/btcsuite/btcwallet/wtxmgr v1.5.6 h1:Zwvr/rrJYdOLqdBCSr4eICEstnEA+NBUvjIWLkrXaYI= -github.com/btcsuite/btcwallet/wtxmgr v1.5.6/go.mod h1:lzVbDkk/jRao2ib5kge46aLZW1yFc8RFNycdYpnsmZA= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= From 3ec106e755e98b6bc19abff0459b2efd58ac0f3f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 1 Sep 2025 22:18:00 +0800 Subject: [PATCH 069/691] wallet: add `UtxoManager` interface --- wallet/utxo_manager.go | 90 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 wallet/utxo_manager.go diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go new file mode 100644 index 0000000000..c942a5b1fc --- /dev/null +++ b/wallet/utxo_manager.go @@ -0,0 +1,90 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +// Package wallet provides a bitcoin wallet implementation that is centered +// around the concept of a UtxoManager, which is responsible for managing the +// wallet's UTXO set. +// +// TODO(yy): bring wrapcheck back when implementing the `Store` interface. +// +//nolint:wrapcheck +package wallet + +import ( + "context" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" +) + +// Utxo provides a detailed overview of an unspent transaction output. +type Utxo struct { + // OutPoint is the transaction output identifier. + OutPoint wire.OutPoint + + // Amount is the value of the output. + Amount btcutil.Amount + + // PkScript is the public key script for the output. + PkScript []byte + + // Confirmations is the number of confirmations the output has. + Confirmations int32 + + // Spendable indicates whether the output is considered spendable. + Spendable bool + + // Address is the address associated with the output. + Address btcutil.Address + + // Account is the name of the account that owns the output. + Account string + + // AddressType is the type of the address. + AddressType waddrmgr.AddressType + + // Locked indicates whether the output is locked. + Locked bool +} + +// UtxoQuery holds the set of options for a ListUnspent query. +type UtxoQuery struct { + // Account specifies the account to query UTXOs for. If empty, + // UTXOs from all accounts are returned. + Account string + + // MinConfs is the minimum number of confirmations a UTXO must have. + MinConfs int32 + + // MaxConfs is the maximum number of confirmations a UTXO can have. + MaxConfs int32 +} + +// UtxoManager provides an interface for querying and managing the wallet's +// UTXO set. +type UtxoManager interface { + // ListUnspent returns a slice of all unspent transaction outputs that + // match the query. + ListUnspent(ctx context.Context, query UtxoQuery) ([]*Utxo, error) + + // GetUtxoInfo returns the output information for a given outpoint. + GetUtxoInfo(ctx context.Context, prevOut *wire.OutPoint) (*Utxo, error) + + // LeaseOutput locks an output for a given duration, preventing it from + // being used in transactions. + LeaseOutput(ctx context.Context, id wtxmgr.LockID, + op wire.OutPoint, duration time.Duration) (time.Time, error) + + // ReleaseOutput unlocks a previously leased output, making it available + // for use. + ReleaseOutput(ctx context.Context, id wtxmgr.LockID, + op wire.OutPoint) error + + // ListLeasedOutputs returns a list of all currently leased outputs. + ListLeasedOutputs(ctx context.Context) ([]*ListLeasedOutputResult, error) +} + From d5d8ddf7e1bc261054b56b5276e1159cccaa42f5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 1 Sep 2025 23:33:35 +0800 Subject: [PATCH 070/691] wallet: rename `ListUnspent` to `ListUnspentDeprecated` --- rpc/legacyrpc/methods.go | 2 +- wallet/createtx_test.go | 2 +- wallet/interface.go | 6 ++++-- wallet/wallet.go | 8 ++++++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/rpc/legacyrpc/methods.go b/rpc/legacyrpc/methods.go index 2fe2393d30..cbbb1f5d4b 100644 --- a/rpc/legacyrpc/methods.go +++ b/rpc/legacyrpc/methods.go @@ -1331,7 +1331,7 @@ func listUnspent(icmd interface{}, w *wallet.Wallet) (interface{}, error) { } } - return w.ListUnspent(int32(*cmd.MinConf), int32(*cmd.MaxConf), "") + return w.ListUnspentDeprecated(int32(*cmd.MinConf), int32(*cmd.MaxConf), "") //nolint:gosec,staticcheck } // lockUnspent handles the lockunspent command. diff --git a/wallet/createtx_test.go b/wallet/createtx_test.go index 4a82c2d444..6968630c03 100644 --- a/wallet/createtx_test.go +++ b/wallet/createtx_test.go @@ -484,7 +484,7 @@ func TestSelectUtxosTxoToOutpoint(t *testing.T) { addUtxo(t, w, incomingTx) // We expect 4 unspent UTXOs. - unspent, err := w.ListUnspent(0, 80, "") + unspent, err := w.ListUnspentDeprecated(0, 80, "") require.NoError(t, err) require.Len(t, unspent, 4, "expected 4 unspent UTXOs") diff --git a/wallet/interface.go b/wallet/interface.go index 0f8128f0dc..d621a9dae3 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -208,9 +208,11 @@ type Interface interface { CalculateAccountBalances(account uint32, requiredConfirmations int32) ( Balances, error) - // ListUnspent returns all unspent transaction outputs for a given + // ListUnspentDeprecated returns all unspent transaction outputs for a given // account and confirmation requirement. - ListUnspent(minconf, maxconf int32, accountName string) ( + // + // Deprecated: Use UtxoManager.ListUnspent instead. + ListUnspentDeprecated(minconf, maxconf int32, accountName string) ( []*btcjson.ListUnspentResult, error) // FetchOutpointInfo returns the output information for a given diff --git a/wallet/wallet.go b/wallet/wallet.go index b813b672bc..bc11a9c203 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -2755,12 +2755,16 @@ func (s creditSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } -// ListUnspent returns a slice of objects representing the unspent wallet +// ListUnspentDeprecated returns a slice of objects representing the unspent wallet // transactions fitting the given criteria. The confirmations will be more than // minconf, less than maxconf and if addresses is populated only the addresses // contained within it will be considered. If we know nothing about a // transaction an empty array will be returned. -func (w *Wallet) ListUnspent(minconf, maxconf int32, +// +// Deprecated: Use UtxoManager.ListUnspent instead. +// +//nolint:funlen +func (w *Wallet) ListUnspentDeprecated(minconf, maxconf int32, accountName string) ([]*btcjson.ListUnspentResult, error) { var results []*btcjson.ListUnspentResult From 7140488fd2a820c57ed104d008739e8a75d7386f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 1 Sep 2025 23:35:02 +0800 Subject: [PATCH 071/691] wallet: rename `LeaseOutput` to `LeaseOutputDeprecated` --- wallet/interface.go | 6 ++++-- wallet/wallet.go | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/wallet/interface.go b/wallet/interface.go index d621a9dae3..8fb367aca7 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -233,7 +233,7 @@ type Interface interface { // and should not be used as an input for created transactions. LockedOutpoint(op wire.OutPoint) bool - // LeaseOutput locks an output to the given ID, preventing it from + // LeaseOutputDeprecated locks an output to the given ID, preventing it from // being available for coin selection. The absolute time of the lock's // expiration is returned. The expiration of the lock can be extended by // successive invocations of this call. @@ -249,7 +249,9 @@ type Interface interface { // // NOTE: This differs from LockOutpoint in that outputs are locked for // a limited amount of time and their locks are persisted to disk. - LeaseOutput(id wtxmgr.LockID, op wire.OutPoint, + // + // Deprecated: Use UtxoManager.LeaseOutput instead. + LeaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint, duration time.Duration) (time.Time, error) // ReleaseOutput unlocks an output, allowing it to be available for diff --git a/wallet/wallet.go b/wallet/wallet.go index bc11a9c203..e4d70e88f6 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -3074,7 +3074,7 @@ func (w *Wallet) LockedOutpoints() []btcjson.TransactionInput { return locked } -// LeaseOutput locks an output to the given ID, preventing it from being +// LeaseOutputDeprecated locks an output to the given ID, preventing it from being // available for coin selection. The absolute time of the lock's expiration is // returned. The expiration of the lock can be extended by successive // invocations of this call. @@ -3089,7 +3089,9 @@ func (w *Wallet) LockedOutpoints() []btcjson.TransactionInput { // // NOTE: This differs from LockOutpoint in that outputs are locked for a limited // amount of time and their locks are persisted to disk. -func (w *Wallet) LeaseOutput(id wtxmgr.LockID, op wire.OutPoint, +// +// Deprecated: Use UtxoManager.LeaseOutput instead. +func (w *Wallet) LeaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint, duration time.Duration) (time.Time, error) { var expiry time.Time From 26cd4f98bba2868028d7436c94f37f7540ce2039 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 1 Sep 2025 23:36:18 +0800 Subject: [PATCH 072/691] wallet: rename `ReleaseOutput` to `ReleaseOutputDeprecated` --- wallet/interface.go | 6 ++++-- wallet/wallet.go | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/wallet/interface.go b/wallet/interface.go index 8fb367aca7..af25260c6d 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -254,10 +254,12 @@ type Interface interface { LeaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint, duration time.Duration) (time.Time, error) - // ReleaseOutput unlocks an output, allowing it to be available for + // ReleaseOutputDeprecated unlocks an output, allowing it to be available for // coin selection if it remains unspent. The ID should match the one // used to originally lock the output. - ReleaseOutput(id wtxmgr.LockID, op wire.OutPoint) error + // + // Deprecated: Use UtxoManager.ReleaseOutput instead. + ReleaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint) error // ListLeasedOutputs returns a list of all currently leased outputs. ListLeasedOutputs() ([]*ListLeasedOutputResult, error) diff --git a/wallet/wallet.go b/wallet/wallet.go index e4d70e88f6..51dcf963b2 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -3105,10 +3105,12 @@ func (w *Wallet) LeaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint, return expiry, err } -// ReleaseOutput unlocks an output, allowing it to be available for coin +// ReleaseOutputDeprecated unlocks an output, allowing it to be available for coin // selection if it remains unspent. The ID should match the one used to // originally lock the output. -func (w *Wallet) ReleaseOutput(id wtxmgr.LockID, op wire.OutPoint) error { +// +// Deprecated: Use UtxoManager.ReleaseOutput instead. +func (w *Wallet) ReleaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint) error { return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) return w.txStore.UnlockOutput(ns, id, op) From 236963c233a005dbdd2b43615712633b9b56f84f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 1 Sep 2025 23:37:34 +0800 Subject: [PATCH 073/691] wallet: rename `ListLeasedOutputs` to `ListLeasedOutputsDeprecated` --- wallet/interface.go | 6 ++++-- wallet/wallet.go | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/wallet/interface.go b/wallet/interface.go index af25260c6d..99d25f7b12 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -261,8 +261,10 @@ type Interface interface { // Deprecated: Use UtxoManager.ReleaseOutput instead. ReleaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint) error - // ListLeasedOutputs returns a list of all currently leased outputs. - ListLeasedOutputs() ([]*ListLeasedOutputResult, error) + // ListLeasedOutputsDeprecated returns a list of all currently leased outputs. + // + // Deprecated: Use UtxoManager.ListLeasedOutputs instead. + ListLeasedOutputsDeprecated() ([]*ListLeasedOutputResult, error) // CreateSimpleTx creates a new transaction to the specified outputs, // automatically performing coin selection and creating a change output diff --git a/wallet/wallet.go b/wallet/wallet.go index 51dcf963b2..71177678ba 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -2909,9 +2909,11 @@ type ListLeasedOutputResult struct { PkScript []byte } -// ListLeasedOutputs returns a list of objects representing the currently locked +// ListLeasedOutputsDeprecated returns a list of objects representing the currently locked // utxos. -func (w *Wallet) ListLeasedOutputs() ([]*ListLeasedOutputResult, error) { +// +// Deprecated: Use UtxoManager.ListLeasedOutputs instead. +func (w *Wallet) ListLeasedOutputsDeprecated() ([]*ListLeasedOutputResult, error) { var results []*ListLeasedOutputResult err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(wtxmgrNamespaceKey) From bcc023f7be7252d6c3d890c654212a83e32291f7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 4 Sep 2025 07:18:06 +0800 Subject: [PATCH 074/691] wallet+wtxmgr: add `TxStore` interface --- wallet/wallet.go | 6 +- wallet/wallet_test.go | 14 ++-- wtxmgr/interface.go | 163 ++++++++++++++++++++++++++++++++++++++++++ wtxmgr/query.go | 2 +- wtxmgr/tx.go | 6 +- wtxmgr/tx_test.go | 2 +- 6 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 wtxmgr/interface.go diff --git a/wallet/wallet.go b/wallet/wallet.go index 71177678ba..9aa2d533bc 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -138,7 +138,7 @@ type Wallet struct { // Data stores db walletdb.DB addrStore *waddrmgr.Manager - txStore *wtxmgr.Store + txStore wtxmgr.TxStore chainClient chain.Interface chainClientLock sync.Mutex @@ -1883,7 +1883,7 @@ func (w *Wallet) LabelTransaction(hash chainhash.Hash, label string, return ErrUnknownTransaction } - _, err = wtxmgr.FetchTxLabel(txmgrNs, hash) + _, err = w.txStore.FetchTxLabel(txmgrNs, hash) return err }) @@ -4398,7 +4398,7 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, } w.NtfnServer = newNotificationServer(w) - w.txStore.NotifyUnspent = func(hash *chainhash.Hash, index uint32) { + txMgr.NotifyUnspent = func(hash *chainhash.Hash, index uint32) { w.NtfnServer.notifyUnspentOutput(0, hash, index) } diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index ca513d58ab..512afdc53c 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -241,7 +241,7 @@ func TestGetTransaction(t *testing.T) { expectedHeight int32 // Store function. - f func(*wtxmgr.Store, walletdb.ReadWriteBucket) (*wtxmgr.Store, error) + f func(wtxmgr.TxStore, walletdb.ReadWriteBucket) (wtxmgr.TxStore, error) // The error we expect to be returned. expectedErr error @@ -251,8 +251,8 @@ func TestGetTransaction(t *testing.T) { txid: *TstTxHash, expectedHeight: -1, // We write txdetail for the tx to disk. - f: func(s *wtxmgr.Store, ns walletdb.ReadWriteBucket) ( - *wtxmgr.Store, error) { + f: func(s wtxmgr.TxStore, ns walletdb.ReadWriteBucket) ( + wtxmgr.TxStore, error) { err = s.InsertTx(ns, rec, nil) return s, err @@ -263,8 +263,8 @@ func TestGetTransaction(t *testing.T) { name: "existing mined transaction", txid: *TstTxHash, // We write txdetail for the tx to disk. - f: func(s *wtxmgr.Store, ns walletdb.ReadWriteBucket) ( - *wtxmgr.Store, error) { + f: func(s wtxmgr.TxStore, ns walletdb.ReadWriteBucket) ( + wtxmgr.TxStore, error) { err = s.InsertTx(ns, rec, TstMinedSignedTxBlockDetails) return s, err @@ -276,8 +276,8 @@ func TestGetTransaction(t *testing.T) { name: "non-existing transaction", txid: *TstTxHash, // Write no txdetail to disk. - f: func(s *wtxmgr.Store, ns walletdb.ReadWriteBucket) ( - *wtxmgr.Store, error) { + f: func(s wtxmgr.TxStore, ns walletdb.ReadWriteBucket) ( + wtxmgr.TxStore, error) { return s, nil }, diff --git a/wtxmgr/interface.go b/wtxmgr/interface.go new file mode 100644 index 0000000000..5017240941 --- /dev/null +++ b/wtxmgr/interface.go @@ -0,0 +1,163 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wtxmgr + +import ( + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/walletdb" +) + +// TODO(yy): The TxStore interface is a temporary solution to decouple the +// wallet from the wtxmgr. It is not a good example of a well-designed +// interface. It has the following issues: +// +// 1. Violation of the Interface Segregation Principle (ISP): +// The current TxStore interface is a "fat" interface, containing over 15 +// methods that span a wide range of responsibilities, from simple balance +// lookups to administrative tasks like database rollbacks. A component that +// only needs to read transaction details is forced to depend on the entire +// interface, including methods for writing data and performing +// administrative actions. This creates an unnecessarily large dependency +// surface. +// +// 2. Lack of Cohesion and CRUD-like Grouping: +// The methods in TxStore are not grouped by the domain entity they operate +// on. A more intuitive design would follow a classic Create, Read, Update, +// Delete (CRUD) pattern for each major entity (transactions, UTXOs, +// labels). The flat structure of the interface makes it harder to +// understand the available operations for a specific entity. For example, +// PutTxLabel, FetchTxLabel, and TxDetails are all at the same level, despite +// operating on different aspects of a transaction. +// +// 3. Leaky Abstractions: +// The interface methods currently require the caller (the wallet package) +// to pass in walletdb.ReadWriteBucket or walletdb.ReadBucket handles. This +// leaks the implementation detail that the store is built on walletdb. The +// wallet should not need to know about the underlying database technology +// or manage database transactions for the wtxmgr. This also violates the +// "Pull Complexity Downwards" principle, as the TxStore should be +// responsible for its own data access logic. +// +// 4. Missing context.Context Propagation: +// None of the interface methods accept a context.Context. This is a +// critical omission. Without a context, we cannot enforce timeouts, +// propagate cancellation signals, or ensure the graceful shutdown of +// long-running database queries. +// +// TxStore is an interface that describes a transaction store. +type TxStore interface { + // Balance returns the spendable wallet balance (total value of all + // unspent transaction outputs) given a minimum of minConf confirmations, + // calculated at a current chain height of curHeight. Coinbase outputs + // are only included in the balance if maturity has been reached. + Balance(ns walletdb.ReadBucket, minConf int32, + syncHeight int32) (btcutil.Amount, error) + + // DeleteExpiredLockedOutputs iterates through all existing locked + // outputs and deletes those which have already expired. + DeleteExpiredLockedOutputs(ns walletdb.ReadWriteBucket) error + + // InsertTx records a transaction as belonging to a wallet's transaction + // history. If block is nil, the transaction is considered unspent, and + // the transaction's index must be unset. + InsertTx(ns walletdb.ReadWriteBucket, rec *TxRecord, + block *BlockMeta) error + + // InsertTxCheckIfExists records a transaction as belonging to a wallet's + // transaction history. If block is nil, the transaction is considered + // unspent, and the transaction's index must be unset. It will return + // true if the transaction was already recorded prior to the call. + InsertTxCheckIfExists(ns walletdb.ReadWriteBucket, rec *TxRecord, + block *BlockMeta) (bool, error) + + // AddCredit marks a transaction record as containing a transaction + // output spendable by wallet. The output is added unspent, and is + // marked spent when a new transaction spending the output is inserted + // into the store. + AddCredit(ns walletdb.ReadWriteBucket, rec *TxRecord, + block *BlockMeta, index uint32, change bool) error + + // ListLockedOutputs returns a list of objects representing the currently + // locked utxos. + ListLockedOutputs(ns walletdb.ReadBucket) ([]*LockedOutput, error) + + // LockOutput locks an output to the given ID, preventing it from being + // available for coin selection. The absolute time of the lock's + // expiration is returned. The expiration of the lock can be extended by + // successive invocations of this call. + LockOutput(ns walletdb.ReadWriteBucket, id LockID, op wire.OutPoint, + duration time.Duration) (time.Time, error) + + // OutputsToWatch returns a list of outputs to monitor during the + // wallet's startup. The returned items are similar to UnspentOutputs, + // exccept the locked outputs and unmined credits are also returned + // here. In addition, we only set the field `OutPoint` and `PkScript` + // for the `Credit`, as these are the only fields used during the + // rescan. + OutputsToWatch(ns walletdb.ReadBucket) ([]Credit, error) + + // PutTxLabel validates transaction labels and writes them to disk if + // they are non-zero and within the label length limit. + PutTxLabel(ns walletdb.ReadWriteBucket, txid chainhash.Hash, + label string) error + + // RangeTransactions runs the function f on all transaction details + // between blocks on the best chain over the height range [begin,end]. + // The special height -1 may be used to also include unmined + // transactions. If the end height comes before the begin height, blocks + // are iterated in reverse order and unmined transactions (if any) are + // processed first. + RangeTransactions(ns walletdb.ReadBucket, begin, end int32, + f func([]TxDetails) (bool, error)) error + + // Rollback removes all blocks at height onwards, moving any transactions + // within each block to the unconfirmed pool. + Rollback(ns walletdb.ReadWriteBucket, height int32) error + + // TxDetails looks up all recorded details regarding a transaction with + // some hash. In case of a hash collision, the most recent transaction + // with a matching hash is returned. + TxDetails(ns walletdb.ReadBucket, + txHash *chainhash.Hash) (*TxDetails, error) + + // UniqueTxDetails looks up all recorded details for a transaction + // recorded mined in some particular block, or an unmined transaction if + // block is nil. + UniqueTxDetails(ns walletdb.ReadBucket, txHash *chainhash.Hash, + block *Block) (*TxDetails, error) + + // UnlockOutput unlocks an output, allowing it to be available for coin + // selection if it remains unspent. The ID should match the one used to + // originally lock the output. + UnlockOutput(ns walletdb.ReadWriteBucket, id LockID, + op wire.OutPoint) error + + // UnspentOutputs returns all unspent received transaction outputs. + // The order is undefined. + UnspentOutputs(ns walletdb.ReadBucket) ([]Credit, error) + + // FetchTxLabel reads a transaction label from the tx labels bucket. If + // a label with 0 length was written, we return an error, since this is + // unexpected. + FetchTxLabel(ns walletdb.ReadBucket, + txid chainhash.Hash) (string, error) + + // UnminedTxs returns the underlying transactions for all unmined + // transactions which are not known to have been mined in a block. + // Transactions are guaranteed to be sorted by their dependency order. + UnminedTxs(ns walletdb.ReadBucket) ([]*wire.MsgTx, error) + + // UnminedTxHashes returns the hashes of all transactions not known to + // have been mined in a block. + UnminedTxHashes(ns walletdb.ReadBucket) ([]*chainhash.Hash, error) + + // RemoveUnminedTx attempts to remove an unmined transaction from the + // transaction store. + RemoveUnminedTx(ns walletdb.ReadWriteBucket, rec *TxRecord) error +} diff --git a/wtxmgr/query.go b/wtxmgr/query.go index 6919330103..383438870f 100644 --- a/wtxmgr/query.go +++ b/wtxmgr/query.go @@ -184,7 +184,7 @@ func (s *Store) unminedTxDetails(ns walletdb.ReadBucket, txHash *chainhash.Hash, func (s *Store) TxLabel(ns walletdb.ReadBucket, txHash chainhash.Hash) (string, error) { - label, err := FetchTxLabel(ns, txHash) + label, err := s.FetchTxLabel(ns, txHash) switch err { // If there are no saved labels yet (the bucket has not been created) or // there is not a label for this particular tx, we ignore the error. diff --git a/wtxmgr/tx.go b/wtxmgr/tx.go index 575a87e739..588238a587 100644 --- a/wtxmgr/tx.go +++ b/wtxmgr/tx.go @@ -198,6 +198,10 @@ type Store struct { NotifyUnspent func(hash *chainhash.Hash, index uint32) } +// A compile-time assertion to ensure that Store implements the TxStore +// interface. +var _ TxStore = (*Store)(nil) + // Open opens the wallet transaction store from a walletdb namespace. If the // store does not exist, ErrNoExist is returned. `lockDuration` represents how // long outputs are locked for. @@ -1232,7 +1236,7 @@ func PutTxLabel(labelBucket walletdb.ReadWriteBucket, txid chainhash.Hash, // FetchTxLabel reads a transaction label from the tx labels bucket. If a label // with 0 length was written, we return an error, since this is unexpected. -func FetchTxLabel(ns walletdb.ReadBucket, txid chainhash.Hash) (string, error) { +func (s *Store) FetchTxLabel(ns walletdb.ReadBucket, txid chainhash.Hash) (string, error) { labelBucket := ns.NestedReadBucket(bucketTxLabels) if labelBucket == nil { return "", ErrNoLabelBucket diff --git a/wtxmgr/tx_test.go b/wtxmgr/tx_test.go index fcce56c727..8cb84c5c2b 100644 --- a/wtxmgr/tx_test.go +++ b/wtxmgr/tx_test.go @@ -2383,7 +2383,7 @@ func TestTxLabel(t *testing.T) { err := walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { var err error - label, err = FetchTxLabel(getBucket(tx), labelTx) + label, err = store.FetchTxLabel(getBucket(tx), labelTx) return err }) From 12a93e3b539c969649135da8c49cdb01fb16dc8b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 4 Sep 2025 09:46:30 +0800 Subject: [PATCH 075/691] waddrmgr+wallet: add `AddrStore` interface --- waddrmgr/interface.go | 177 ++++++++++++++++++++++++++++++++++++++++++ wallet/chainntfns.go | 2 +- wallet/createtx.go | 3 +- wallet/interface.go | 2 +- wallet/wallet.go | 9 ++- 5 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 waddrmgr/interface.go diff --git a/waddrmgr/interface.go b/waddrmgr/interface.go new file mode 100644 index 0000000000..4e91024ebf --- /dev/null +++ b/waddrmgr/interface.go @@ -0,0 +1,177 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package waddrmgr + +import ( + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcwallet/walletdb" +) + +// TODO(yy) This file provides a set of interfaces that abstract the +// functionality of the waddrmgr package. The interfaces are designed to be +// composable, allowing for a clean separation of concerns and making it easier +// to test and maintain the codebase. +// +// The AddrStore interface is the top-level interface that composes all the +// other interfaces. It is responsible for managing its own database +// transactions, which means that the walletdb.ReadWriteBucket and +// walletdb.ReadBucket arguments are not present in the interface methods. +// +// The breakdown of the interfaces is as follows: +// +// ChainState: Manages the wallet's sync state with the blockchain. +// KeyScopeManager: Manages key scopes. +// AddressManager: Manages addresses. +// AccountManager: Manages accounts. +// CryptoManager: Manages the encrypted state of the wallet. +// WatchOnlyManager: Manages watch-only functionality. +// +// The current AddrStore interface has several design flaws that should be +// addressed in a future refactoring: +// +// 1. Leaky Abstraction & Lack of Encapsulation: +// - Problem: Nearly every method in the interface requires the caller (the +// `wallet` package) to pass in a `walletdb.ReadWriteBucket` or +// `walletdb.ReadBucket`. +// - Why it's an issue: This is a classic "leaky abstraction." The +// `AddrStore` is supposed to abstract away the details of address +// management, but it's forcing its consumer to know about and manage its +// internal database structure and transactions. The `wallet` package +// should not be responsible for starting a database transaction just to +// call a method on the `AddrStore`. The `AddrStore` should manage its own +// persistence internally. +// +// 2. Violation of the Interface Segregation Principle (ISP): +// - Problem: The `AddrStore` is a "fat" interface. It includes dozens of +// methods covering many distinct areas of responsibility: cryptographic +// operations (`Lock`, `Unlock`), chain synchronization (`SyncedTo`), key +// management (`NewScopedKeyManager`), and address lookups (`Address`). +// - Why it's an issue: Consumers of the interface are forced to depend on +// methods they don't use. For example, a component that only needs to +// look up an address (`Address`) is also coupled to methods for changing +// passphrases. This leads to unnecessary dependencies and makes the code +// harder to test, as mocks become massive and unwieldy. +// +// 3. Violation of the Single Responsibility Principle (SRP): +// - Problem: The interface combines multiple, distinct responsibilities +// into one unit. It acts as a key manager, an address book, a crypto +// manager, and a chain state tracker all at once. +// - Why it's an issue: This makes the `AddrStore` difficult to reason about +// and maintain. A change in how we manage chain state, for example, could +// require modifying an interface that is also responsible for +// cryptography. These concerns should be separate. + +// AddrStore is an interface that describes a wallet address store. +// +//nolint:interfacebloat +type AddrStore interface { + // Birthday returns the birthday of the address store. + Birthday() time.Time + + // SetSyncedTo marks the address manager to be in sync with the + // recently-seen block described by the blockstamp. + SetSyncedTo(ns walletdb.ReadWriteBucket, bs *BlockStamp) error + + // SetBirthdayBlock sets the birthday block, or earliest time a key could + // have been used, for the manager. + SetBirthdayBlock(ns walletdb.ReadWriteBucket, block BlockStamp, + verified bool) error + + // SyncedTo returns details about the block height and hash that the + // address manager is synced through at the very least. + SyncedTo() BlockStamp + + // BlockHash returns the block hash at a particular block height. + BlockHash(ns walletdb.ReadBucket, height int32) (*chainhash.Hash, error) + + // ActiveScopedKeyManagers returns a slice of all the active scoped key + // managers currently known by the root key manager. + ActiveScopedKeyManagers() []*ScopedKeyManager + + // FetchScopedKeyManager attempts to fetch an active scoped manager + // according to its registered scope. + FetchScopedKeyManager(scope KeyScope) (*ScopedKeyManager, error) + + // Address returns a managed address given the passed address if it is + // known to the address manager. + Address(ns walletdb.ReadBucket, + address btcutil.Address) (ManagedAddress, error) + + // AddrAccount returns the account to which the given address belongs. + AddrAccount(ns walletdb.ReadBucket, + address btcutil.Address) (*ScopedKeyManager, uint32, error) + + // ForEachRelevantActiveAddress invokes the given closure on each active + // address relevant to the wallet. + ForEachRelevantActiveAddress(ns walletdb.ReadBucket, + fn func(addr btcutil.Address) error) error + + // Unlock derives the master private key from the specified passphrase. + Unlock(ns walletdb.ReadBucket, passphrase []byte) error + + // Lock performs a best try effort to remove and zero all secret keys + // associated with the address manager. + Lock() error + + // IsLocked returns whether or not the address managed is locked. + IsLocked() bool + + // ChangePassphrase changes either the public or private passphrase to + // the provided value depending on the private flag. + ChangePassphrase(ns walletdb.ReadWriteBucket, oldPass, newPass []byte, + private bool, scryptOptions *ScryptOptions) error + + // WatchOnly returns true if the root manager is in watch only mode, and + // false otherwise. + WatchOnly() bool + + // MarkUsed updates the used flag for the provided address. + MarkUsed(ns walletdb.ReadWriteBucket, address btcutil.Address) error + + // BirthdayBlock returns the birthday block of the address store. + BirthdayBlock(ns walletdb.ReadBucket) (BlockStamp, bool, error) + + // IsWatchOnlyAccount determines if the account with the given key scope + // is set up as watch-only. + IsWatchOnlyAccount(ns walletdb.ReadBucket, keyScope KeyScope, + account uint32) (bool, error) + + // NewScopedKeyManager creates a new scoped key manager from the root + // manager. + NewScopedKeyManager(ns walletdb.ReadWriteBucket, + scope KeyScope, addrSchema ScopeAddrSchema) (*ScopedKeyManager, error) + + // SetBirthday sets the birthday of the address store. + SetBirthday(ns walletdb.ReadWriteBucket, birthday time.Time) error + + // ForEachAccountAddress calls the given function with each address of + // the given account stored in the manager, breaking early on error. + ForEachAccountAddress(ns walletdb.ReadBucket, account uint32, + fn func(maddr ManagedAddress) error) error + + // LookupAccount returns the corresponding key scope and account number + // for the account with the given name. + LookupAccount(ns walletdb.ReadBucket, + name string) (KeyScope, uint32, error) + + // ForEachActiveAddress calls the given function with each active address + // stored in the manager, breaking early on error. + ForEachActiveAddress(ns walletdb.ReadBucket, + fn func(addr btcutil.Address) error) error + + // ConvertToWatchingOnly converts the current address manager to a locked + // watching-only address manager. + ConvertToWatchingOnly(ns walletdb.ReadWriteBucket) error + + // ChainParams returns the chain parameters for this address manager. + ChainParams() *chaincfg.Params + + // Close cleanly shuts down the manager. + Close() +} diff --git a/wallet/chainntfns.go b/wallet/chainntfns.go index 2e96dbb09e..d9f2294b35 100644 --- a/wallet/chainntfns.go +++ b/wallet/chainntfns.go @@ -457,7 +457,7 @@ type birthdayStore interface { // manager that satisfies the birthdayStore interface. type walletBirthdayStore struct { db walletdb.DB - manager *waddrmgr.Manager + manager waddrmgr.AddrStore } var _ birthdayStore = (*walletBirthdayStore)(nil) diff --git a/wallet/createtx.go b/wallet/createtx.go index 4bedee1956..9606a173f0 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -87,7 +87,8 @@ func constantInputSource(eligible []wtxmgr.Credit) txauthor.InputSource { // secretSource is an implementation of txauthor.SecretSource for the wallet's // address manager. type secretSource struct { - *waddrmgr.Manager + waddrmgr.AddrStore + addrmgrNs walletdb.ReadBucket } diff --git a/wallet/interface.go b/wallet/interface.go index 99d25f7b12..d04bf682e8 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -81,7 +81,7 @@ type Interface interface { NotificationServer() *NotificationServer // AddrManager returns the internal address manager. - AddrManager() *waddrmgr.Manager + AddrManager() waddrmgr.AddrStore // Accounts returns all accounts for a particular scope. Accounts(scope waddrmgr.KeyScope) (*AccountsResult, error) diff --git a/wallet/wallet.go b/wallet/wallet.go index 9aa2d533bc..4cdee21705 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -137,7 +137,7 @@ type Wallet struct { // Data stores db walletdb.DB - addrStore *waddrmgr.Manager + addrStore waddrmgr.AddrStore txStore wtxmgr.TxStore chainClient chain.Interface @@ -2131,8 +2131,9 @@ func RecvCategory(details *wtxmgr.TxDetails, syncHeight int32, net *chaincfg.Par // for a listtransactions RPC. // // TODO: This should be moved to the legacyrpc package. -func listTransactions(tx walletdb.ReadTx, details *wtxmgr.TxDetails, addrMgr *waddrmgr.Manager, - syncHeight int32, net *chaincfg.Params) []btcjson.ListTransactionsResult { +func listTransactions(tx walletdb.ReadTx, details *wtxmgr.TxDetails, + addrMgr waddrmgr.AddrStore, syncHeight int32, + net *chaincfg.Params) []btcjson.ListTransactionsResult { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) @@ -4169,7 +4170,7 @@ func (w *Wallet) SyncedTo() waddrmgr.BlockStamp { // AddrManager returns the internal address manager. // // TODO(yy): Refactor it in lnd and remove the method. -func (w *Wallet) AddrManager() *waddrmgr.Manager { +func (w *Wallet) AddrManager() waddrmgr.AddrStore { return w.addrStore } From 696ce5acb5a29bca0c36e77b6a4055cde58684d1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 4 Sep 2025 14:32:39 +0800 Subject: [PATCH 076/691] waddrmgr+wallet: add `AccountStore` interface This allows us to write better unit tests and will be removed in an upcoming refactoring. --- waddrmgr/interface.go | 130 +++++++++++++++++++++++++++++++++++-- waddrmgr/manager.go | 10 +-- waddrmgr/manager_test.go | 74 ++++++++++++++++----- waddrmgr/scoped_manager.go | 4 ++ wallet/account_manager.go | 2 +- wallet/address_manager.go | 2 +- wallet/interface.go | 2 +- wallet/recovery.go | 2 +- wallet/wallet.go | 14 ++-- 9 files changed, 204 insertions(+), 36 deletions(-) diff --git a/waddrmgr/interface.go b/waddrmgr/interface.go index 4e91024ebf..5346684050 100644 --- a/waddrmgr/interface.go +++ b/waddrmgr/interface.go @@ -7,7 +7,9 @@ package waddrmgr import ( "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcwallet/walletdb" @@ -92,11 +94,11 @@ type AddrStore interface { // ActiveScopedKeyManagers returns a slice of all the active scoped key // managers currently known by the root key manager. - ActiveScopedKeyManagers() []*ScopedKeyManager + ActiveScopedKeyManagers() []AccountStore // FetchScopedKeyManager attempts to fetch an active scoped manager // according to its registered scope. - FetchScopedKeyManager(scope KeyScope) (*ScopedKeyManager, error) + FetchScopedKeyManager(scope KeyScope) (AccountStore, error) // Address returns a managed address given the passed address if it is // known to the address manager. @@ -105,7 +107,7 @@ type AddrStore interface { // AddrAccount returns the account to which the given address belongs. AddrAccount(ns walletdb.ReadBucket, - address btcutil.Address) (*ScopedKeyManager, uint32, error) + address btcutil.Address) (AccountStore, uint32, error) // ForEachRelevantActiveAddress invokes the given closure on each active // address relevant to the wallet. @@ -145,7 +147,7 @@ type AddrStore interface { // NewScopedKeyManager creates a new scoped key manager from the root // manager. NewScopedKeyManager(ns walletdb.ReadWriteBucket, - scope KeyScope, addrSchema ScopeAddrSchema) (*ScopedKeyManager, error) + scope KeyScope, addrSchema ScopeAddrSchema) (AccountStore, error) // SetBirthday sets the birthday of the address store. SetBirthday(ns walletdb.ReadWriteBucket, birthday time.Time) error @@ -175,3 +177,123 @@ type AddrStore interface { // Close cleanly shuts down the manager. Close() } + +// AccountStore is an interface that describes a scoped key manager. +// +// TODO(yy): remove this interface and hide the details inside AddrStore. +// +//nolint:interfacebloat +type AccountStore interface { + // Scope returns the key scope of the manager. + Scope() KeyScope + + // AccountProperties returns the properties of an account, including + // address indexes and name. + AccountProperties(ns walletdb.ReadBucket, + account uint32) (*AccountProperties, error) + + // LastExternalAddress returns the last external address for an account. + LastExternalAddress(ns walletdb.ReadBucket, + account uint32) (ManagedAddress, error) + + // LastInternalAddress returns the last internal address for an account. + LastInternalAddress(ns walletdb.ReadBucket, + account uint32) (ManagedAddress, error) + + // ForEachAccountAddress calls the given function with each address of + // the given account stored in the manager, breaking early on error. + ForEachAccountAddress(ns walletdb.ReadBucket, account uint32, + fn func(maddr ManagedAddress) error) error + + // LookupAccount returns the account number for the given account name. + LookupAccount(ns walletdb.ReadBucket, name string) (uint32, error) + + // AccountName returns the name of an account. + AccountName(ns walletdb.ReadBucket, account uint32) (string, error) + + // ExtendExternalAddresses extends the external addresses for an account. + ExtendExternalAddresses(ns walletdb.ReadWriteBucket, account uint32, + count uint32) error + + // ExtendInternalAddresses extends the internal addresses for an account. + ExtendInternalAddresses(ns walletdb.ReadWriteBucket, account uint32, + count uint32) error + + // MarkUsed updates the used flag for the provided address. + MarkUsed(ns walletdb.ReadWriteBucket, address btcutil.Address) error + + // DeriveFromKeyPath derives a key from the given key path. + DeriveFromKeyPath(ns walletdb.ReadBucket, + path DerivationPath) (ManagedAddress, error) + + // CanAddAccount returns an error if a new account cannot be created. + CanAddAccount() error + + // NewAccount creates a new account. + NewAccount(ns walletdb.ReadWriteBucket, name string) (uint32, error) + + // LastAccount returns the last account number. + LastAccount(ns walletdb.ReadBucket) (uint32, error) + + // RenameAccount renames an account. + RenameAccount(ns walletdb.ReadWriteBucket, account uint32, + name string) error + + // NextExternalAddresses returns the next external addresses for an + // account. + NextExternalAddresses(ns walletdb.ReadWriteBucket, account uint32, + count uint32) ([]ManagedAddress, error) + + // NextInternalAddresses returns the next internal addresses for an + // account. + NextInternalAddresses(ns walletdb.ReadWriteBucket, account uint32, + count uint32) ([]ManagedAddress, error) + + // NewAddress creates a new address for an account. + NewAddress(ns walletdb.ReadWriteBucket, account string, + internal bool) (btcutil.Address, error) + + // ImportPublicKey imports a public key. + ImportPublicKey(ns walletdb.ReadWriteBucket, pubKey *btcec.PublicKey, + bs *BlockStamp) (ManagedAddress, error) + + // ImportTaprootScript imports a taproot script. + ImportTaprootScript(ns walletdb.ReadWriteBucket, + script *Tapscript, bs *BlockStamp, privKeyType byte, + isInternal bool) (ManagedTaprootScriptAddress, error) + + // ForEachAccount calls the given function with each account stored in + // the manager, breaking early on error. + ForEachAccount(ns walletdb.ReadBucket, + fn func(account uint32) error) error + + // IsWatchOnlyAccount determines if the account is watch-only. + IsWatchOnlyAccount(ns walletdb.ReadBucket, account uint32) (bool, error) + + // NewAccountWatchingOnly creates a new watch-only account. + NewAccountWatchingOnly(ns walletdb.ReadWriteBucket, name string, + pubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, + addrSchema *ScopeAddrSchema) (uint32, error) + + // InvalidateAccountCache invalidates the account cache. + InvalidateAccountCache(account uint32) + + // ImportPrivateKey imports a private key. + ImportPrivateKey(ns walletdb.ReadWriteBucket, wif *btcutil.WIF, + bs *BlockStamp) (ManagedPubKeyAddress, error) + + // AddrAccount returns the account for a given address. + AddrAccount(ns walletdb.ReadBucket, + address btcutil.Address) (uint32, error) + + // DeriveFromKeyPathCache derives a key from the given key path, using + // the cache. + DeriveFromKeyPathCache(kp DerivationPath) (*btcec.PrivateKey, error) + + // NewRawAccount creates a new account with a raw account number. + NewRawAccount(ns walletdb.ReadWriteBucket, number uint32) error + + // ImportScript imports a script. + ImportScript(ns walletdb.ReadWriteBucket, script []byte, + bs *BlockStamp) (ManagedScriptAddress, error) +} diff --git a/waddrmgr/manager.go b/waddrmgr/manager.go index 36a6f4ad62..1e301445af 100644 --- a/waddrmgr/manager.go +++ b/waddrmgr/manager.go @@ -503,7 +503,7 @@ func (m *Manager) Close() { // TODO(roasbeef): addrtype of raw key means it'll look in scripts to possibly // mark as gucci? func (m *Manager) NewScopedKeyManager(ns walletdb.ReadWriteBucket, - scope KeyScope, addrSchema ScopeAddrSchema) (*ScopedKeyManager, error) { + scope KeyScope, addrSchema ScopeAddrSchema) (AccountStore, error) { m.mtx.Lock() defer m.mtx.Unlock() @@ -626,7 +626,7 @@ func (m *Manager) NewScopedKeyManager(ns walletdb.ReadWriteBucket, // its registered scope. If the manger is found, then a nil error is returned // along with the active scoped manager. Otherwise, a nil manager and a non-nil // error will be returned. -func (m *Manager) FetchScopedKeyManager(scope KeyScope) (*ScopedKeyManager, error) { +func (m *Manager) FetchScopedKeyManager(scope KeyScope) (AccountStore, error) { m.mtx.RLock() defer m.mtx.RUnlock() @@ -641,11 +641,11 @@ func (m *Manager) FetchScopedKeyManager(scope KeyScope) (*ScopedKeyManager, erro // ActiveScopedKeyManagers returns a slice of all the active scoped key // managers currently known by the root key manager. -func (m *Manager) ActiveScopedKeyManagers() []*ScopedKeyManager { +func (m *Manager) ActiveScopedKeyManagers() []AccountStore { m.mtx.RLock() defer m.mtx.RUnlock() - scopedManagers := make([]*ScopedKeyManager, 0, len(m.scopedManagers)) + scopedManagers := make([]AccountStore, 0, len(m.scopedManagers)) for _, smgr := range m.scopedManagers { scopedManagers = append(scopedManagers, smgr) } @@ -751,7 +751,7 @@ func (m *Manager) MarkUsed(ns walletdb.ReadWriteBucket, address btcutil.Address) // AddrAccount returns the account to which the given address belongs. We also // return the scoped manager that owns the addr+account combo. func (m *Manager) AddrAccount(ns walletdb.ReadBucket, - address btcutil.Address) (*ScopedKeyManager, uint32, error) { + address btcutil.Address) (AccountStore, uint32, error) { m.mtx.RLock() defer m.mtx.RUnlock() diff --git a/waddrmgr/manager_test.go b/waddrmgr/manager_test.go index 64e2ee9f95..2945067ce1 100644 --- a/waddrmgr/manager_test.go +++ b/waddrmgr/manager_test.go @@ -1831,11 +1831,15 @@ func testConvertWatchingOnly(tc *testContext) bool { // Run all of the manager API tests against the converted manager and // close it. We'll also retrieve the default scope (BIP0044) from the // manager in order to use. - scopedMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) + sMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) if err != nil { tc.t.Errorf("unable to fetch bip 44 scope %v", err) return false } + + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(tc.t, ok) + testManagerAPI(&testContext{ t: tc.t, caseName: tc.caseName, @@ -1861,12 +1865,15 @@ func testConvertWatchingOnly(tc *testContext) bool { } defer mgr.Close() - scopedMgr, err = mgr.FetchScopedKeyManager(KeyScopeBIP0044) + sMgr, err = mgr.FetchScopedKeyManager(KeyScopeBIP0044) if err != nil { tc.t.Errorf("unable to fetch bip 44 scope %v", err) return false } + scopedMgr, ok = sMgr.(*ScopedKeyManager) + require.True(tc.t, ok) + testManagerAPI(&testContext{ t: tc.t, caseName: tc.caseName, @@ -2036,11 +2043,14 @@ func testManagerCase(t *testing.T, caseName string, return } - scopedMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) + sMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) if err != nil { t.Fatalf("(%s) unable to fetch default scope: %v", caseName, err) } + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(t, ok) + if caseCreatedWatchingOnly { accountKey := deriveTestAccountKey(t) if accountKey == nil { @@ -2095,10 +2105,13 @@ func testManagerCase(t *testing.T, caseName string, } defer mgr.Close() - scopedMgr, err = mgr.FetchScopedKeyManager(KeyScopeBIP0044) + sMgr, err = mgr.FetchScopedKeyManager(KeyScopeBIP0044) if err != nil { t.Fatalf("(%s) unable to fetch default scope: %v", caseName, err) } + + scopedMgr, ok = sMgr.(*ScopedKeyManager) + require.True(t, ok) tc := &testContext{ t: t, caseName: caseName, @@ -2361,7 +2374,10 @@ func TestScopedKeyManagerManagement(t *testing.T) { t.Fatalf("unable to fetch scope %v: %v", scope, err) } - externalAddr, err := sMgr.NextExternalAddresses( + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(t, ok) + + externalAddr, err := scopedMgr.NextExternalAddresses( ns, DefaultAccountNum, 1, ) if err != nil { @@ -2376,7 +2392,7 @@ func TestScopedKeyManagerManagement(t *testing.T) { ScopeAddrMap[scope].ExternalAddrType) } - internalAddr, err := sMgr.NextInternalAddresses( + internalAddr, err := scopedMgr.NextInternalAddresses( ns, DefaultAccountNum, 1, ) if err != nil { @@ -2408,11 +2424,12 @@ func TestScopedKeyManagerManagement(t *testing.T) { ExternalAddrType: NestedWitnessPubKey, InternalAddrType: WitnessPubKey, } - var scopedMgr *ScopedKeyManager + + var sMgr AccountStore err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - scopedMgr, err = mgr.NewScopedKeyManager(ns, testScope, addrSchema) + sMgr, err = mgr.NewScopedKeyManager(ns, testScope, addrSchema) if err != nil { return err } @@ -2423,6 +2440,9 @@ func TestScopedKeyManagerManagement(t *testing.T) { t.Fatalf("unable to read db: %v", err) } + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(t, ok) + // The manager was just created, we should be able to look it up within // the root manager. if _, err := mgr.FetchScopedKeyManager(testScope); err != nil { @@ -2467,7 +2487,8 @@ func TestScopedKeyManagerManagement(t *testing.T) { t.Fatalf("addr type mismatch: expected %v, got %v", NestedWitnessPubKey, externalAddr[0].AddrType()) } - _, ok := externalAddr[0].Address().(*btcutil.AddressScriptHash) + + _, ok = externalAddr[0].Address().(*btcutil.AddressScriptHash) if !ok { t.Fatalf("wrong type: %T", externalAddr[0].Address()) } @@ -2503,11 +2524,14 @@ func TestScopedKeyManagerManagement(t *testing.T) { // We should be able to retrieve the new scoped manager that we just // created. - scopedMgr, err = mgr.FetchScopedKeyManager(testScope) + sMgr, err = mgr.FetchScopedKeyManager(testScope) if err != nil { t.Fatalf("attempt to read created mgr failed: %v", err) } + scopedMgr, ok = sMgr.(*ScopedKeyManager) + require.True(t, ok) + // If we fetch the last generated external address, it should map // exactly to the address that we just generated. var lastAddr ManagedAddress @@ -2692,11 +2716,14 @@ func TestNewRawAccount(t *testing.T) { // Now that we have the manager created, we'll fetch one of the default // scopes for usage within this test. - scopedMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + sMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) if err != nil { t.Fatalf("unable to fetch scope %v: %v", KeyScopeBIP0084, err) } + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(t, ok) + // With the scoped manager retrieved, we'll attempt to create a new raw // account by number. const accountNum = 1000 @@ -2752,11 +2779,14 @@ func TestNewRawAccountWatchingOnly(t *testing.T) { // Now that we have the manager created, we'll fetch one of the default // scopes for usage within this test. - scopedMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) + sMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) if err != nil { t.Fatalf("unable to fetch scope %v: %v", KeyScopeBIP0044, err) } + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(t, ok) + accountKey := deriveTestAccountKey(t) if accountKey == nil { return @@ -2813,11 +2843,14 @@ func TestNewRawAccountHybrid(t *testing.T) { // Now that we have the manager created, we'll fetch one of the default // scopes for usage within this test. - scopedMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) + sMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) if err != nil { t.Fatalf("unable to fetch scope %v: %v", KeyScopeBIP0044, err) } + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(t, ok) + accountKey := deriveTestAccountKey(t) if accountKey == nil { return @@ -2936,11 +2969,14 @@ func TestDeriveFromKeyPathCache(t *testing.T) { // Now that we have the manager created, we'll fetch one of the default // scopes for usage within this test. - scopedMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) + sMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) require.NoError( t, err, "unable to fetch scope %v: %v", KeyScopeBIP0044, err, ) + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(t, ok) + keyPath := DerivationPath{ InternalAccount: 0, Account: hdkeychain.HardenedKeyStart, @@ -3034,11 +3070,14 @@ func TestTaprootPubKeyDerivation(t *testing.T) { // Now that we have the manager created, we'll fetch one of the default // scopes for usage within this test. - scopedMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0086) + sMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0086) require.NoError( t, err, "unable to fetch scope %v: %v", KeyScopeBIP0086, err, ) + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(t, ok) + externalPath := DerivationPath{ InternalAccount: 0, Account: hdkeychain.HardenedKeyStart, @@ -3241,12 +3280,15 @@ func TestManagedAddressValidation(t *testing.T) { scope) t.Run(testName, func(t *testing.T) { - scopedMgr, err := mgr.FetchScopedKeyManager(scope) + sMgr, err := mgr.FetchScopedKeyManager(scope) require.NoError( t, err, "unable to fetch scope %v: %v", KeyScopeBIP0086, err, ) + scopedMgr, ok := sMgr.(*ScopedKeyManager) + require.True(t, ok) + var addr ManagedAddress // With the scoped managed we created above, diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 6523c9f18b..152e95cc42 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -302,6 +302,10 @@ type ScopedKeyManager struct { mtx sync.RWMutex } +// A compile-time assertion to ensure that ScopedKeyManager implements the +// AccountStore interface. +var _ AccountStore = (*ScopedKeyManager)(nil) + // Scope returns the exact KeyScope of this scoped key manager. func (s *ScopedKeyManager) Scope() KeyScope { return s.scope diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 56245949fd..177054e8b3 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -775,7 +775,7 @@ func (w *Wallet) fetchAccountBalances(tx walletdb.ReadTx, // that we can efficiently gather all necessary data in distinct phases, rather // than mixing database reads and balance calculations in a less efficient // manner. -func listAccountsWithBalances(scopeMgr *waddrmgr.ScopedKeyManager, +func listAccountsWithBalances(scopeMgr waddrmgr.AccountStore, addrmgrNs walletdb.ReadBucket, accountBalances map[uint32]btcutil.Amount) ([]AccountResult, error) { diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 1012cb3a99..dad78eab75 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -260,7 +260,7 @@ func (w *Wallet) keyScopeFromAddrType( // wraps the database transaction and the call to the scoped key manager's // NewAddress method. A mutex is used to protect the in-memory state of the // address manager from concurrent access during address creation. -func (w *Wallet) newAddress(manager *waddrmgr.ScopedKeyManager, +func (w *Wallet) newAddress(manager waddrmgr.AccountStore, accountName string, change bool) (btcutil.Address, error) { // The address manager uses OnCommit on the walletdb tx to update the diff --git a/wallet/interface.go b/wallet/interface.go index d04bf682e8..0042e3852b 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -146,7 +146,7 @@ type Interface interface { // AddScopeManager adds a new scope manager to the wallet. AddScopeManager(scope waddrmgr.KeyScope, addrSchema waddrmgr.ScopeAddrSchema) ( - *waddrmgr.ScopedKeyManager, error) + waddrmgr.AccountStore, error) // CurrentAddress returns the current, most recently generated address // for a given account and scope. If the current address has been used, diff --git a/wallet/recovery.go b/wallet/recovery.go index aa90dbc285..b49074d01d 100644 --- a/wallet/recovery.go +++ b/wallet/recovery.go @@ -57,7 +57,7 @@ func NewRecoveryManager(recoveryWindow, batchSize uint32, // horizons properly start from the last found address of a prior recovery // attempt. func (rm *RecoveryManager) Resurrect(ns walletdb.ReadBucket, - scopedMgrs map[waddrmgr.KeyScope]*waddrmgr.ScopedKeyManager, + scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, credits []wtxmgr.Credit) error { // First, for each scope that we are recovering, rederive all of the diff --git a/wallet/wallet.go b/wallet/wallet.go index 4cdee21705..d293aa03e0 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -736,7 +736,7 @@ func (w *Wallet) recovery(chainClient chain.Interface, // recovery, we would only do so for the default scopes, but due to a // bug in which the wallet would create change addresses outside of the // default scopes, it's necessary to attempt all registered key scopes. - scopedMgrs := make(map[waddrmgr.KeyScope]*waddrmgr.ScopedKeyManager) + scopedMgrs := make(map[waddrmgr.KeyScope]waddrmgr.AccountStore) for _, scopedMgr := range w.addrStore.ActiveScopedKeyManagers() { scopedMgrs[scopedMgr.Scope()] = scopedMgr } @@ -870,7 +870,7 @@ func (w *Wallet) recoverScopedAddresses( ns walletdb.ReadWriteBucket, batch []wtxmgr.BlockMeta, recoveryState *RecoveryState, - scopedMgrs map[waddrmgr.KeyScope]*waddrmgr.ScopedKeyManager) error { + scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore) error { // If there are no blocks in the batch, we are done. if len(batch) == 0 { @@ -970,7 +970,7 @@ expandHorizons: // horizon will be properly extended such that our lookahead always includes the // proper number of valid child keys. func expandScopeHorizons(ns walletdb.ReadWriteBucket, - scopedMgr *waddrmgr.ScopedKeyManager, + scopedMgr waddrmgr.AccountStore, scopeState *ScopeRecoveryState) error { // Compute the current external horizon and the number of addresses we @@ -1059,7 +1059,7 @@ func internalKeyPath(index uint32) waddrmgr.DerivationPath { // newFilterBlocksRequest constructs FilterBlocksRequests using our current // block range, scoped managers, and recovery state. func newFilterBlocksRequest(batch []wtxmgr.BlockMeta, - scopedMgrs map[waddrmgr.KeyScope]*waddrmgr.ScopedKeyManager, + scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, recoveryState *RecoveryState) *chain.FilterBlocksRequest { filterReq := &chain.FilterBlocksRequest{ @@ -1097,7 +1097,7 @@ func newFilterBlocksRequest(batch []wtxmgr.BlockMeta, // match the highest found child index for each branch. func extendFoundAddresses(ns walletdb.ReadWriteBucket, filterResp *chain.FilterBlocksResponse, - scopedMgrs map[waddrmgr.KeyScope]*waddrmgr.ScopedKeyManager, + scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, recoveryState *RecoveryState) error { // Mark all recovered external addresses as used. This will be done only @@ -3983,9 +3983,9 @@ func (w *Wallet) BirthdayBlock() (*waddrmgr.BlockStamp, error) { // AddScopeManager creates a new scoped key manager from the root manager. func (w *Wallet) AddScopeManager(scope waddrmgr.KeyScope, addrSchema waddrmgr.ScopeAddrSchema) ( - *waddrmgr.ScopedKeyManager, error) { + waddrmgr.AccountStore, error) { - var scopedManager *waddrmgr.ScopedKeyManager + var scopedManager waddrmgr.AccountStore err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) From 3f34b6a7dec0f6fba5450bbfe82f16a4284f8b6b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 4 Sep 2025 14:33:30 +0800 Subject: [PATCH 077/691] wallet: add mockers to be used in the unit tests --- wallet/mock_test.go | 745 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 wallet/mock_test.go diff --git a/wallet/mock_test.go b/wallet/mock_test.go new file mode 100644 index 0000000000..abe7656dfd --- /dev/null +++ b/wallet/mock_test.go @@ -0,0 +1,745 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +// This file contains a mock implementation of the wtxmgr.TxStore interface. +// It is used in various tests to isolate wallet logic from the underlying +// database. + +package wallet + +import ( + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" +) + +// mockTxStore is a mock implementation of the wtxmgr.TxStore interface. +type mockTxStore struct { + mock.Mock +} + +// A compile-time assertion to ensure that mockTxStore implements the TxStore +// interface. +var _ wtxmgr.TxStore = (*mockTxStore)(nil) + +// Balance implements the wtxmgr.TxStore interface. +func (m *mockTxStore) Balance(ns walletdb.ReadBucket, minConf int32, + syncHeight int32) (btcutil.Amount, error) { + + args := m.Called(ns, minConf, syncHeight) + if args.Get(0) == nil { + return btcutil.Amount(0), args.Error(1) + } + + return args.Get(0).(btcutil.Amount), args.Error(1) +} + +// DeleteExpiredLockedOutputs implements the wtxmgr.TxStore interface. +func (m *mockTxStore) DeleteExpiredLockedOutputs( + ns walletdb.ReadWriteBucket) error { + + args := m.Called(ns) + return args.Error(0) +} + +// InsertTx implements the wtxmgr.TxStore interface. +func (m *mockTxStore) InsertTx(ns walletdb.ReadWriteBucket, + rec *wtxmgr.TxRecord, block *wtxmgr.BlockMeta) error { + + args := m.Called(ns, rec, block) + return args.Error(0) +} + +// InsertTxCheckIfExists implements the wtxmgr.TxStore interface. +func (m *mockTxStore) InsertTxCheckIfExists(ns walletdb.ReadWriteBucket, + rec *wtxmgr.TxRecord, block *wtxmgr.BlockMeta) (bool, error) { + + args := m.Called(ns, rec, block) + return args.Bool(0), args.Error(1) +} + +// AddCredit implements the wtxmgr.TxStore interface. +func (m *mockTxStore) AddCredit(ns walletdb.ReadWriteBucket, + rec *wtxmgr.TxRecord, block *wtxmgr.BlockMeta, index uint32, + change bool) error { + + args := m.Called(ns, rec, block, index, change) + return args.Error(0) +} + +// ListLockedOutputs implements the wtxmgr.TxStore interface. +func (m *mockTxStore) ListLockedOutputs( + ns walletdb.ReadBucket) ([]*wtxmgr.LockedOutput, error) { + + args := m.Called(ns) + return args.Get(0).([]*wtxmgr.LockedOutput), args.Error(1) +} + +// LockOutput implements the wtxmgr.TxStore interface. +func (m *mockTxStore) LockOutput(ns walletdb.ReadWriteBucket, id wtxmgr.LockID, + op wire.OutPoint, duration time.Duration) (time.Time, error) { + + args := m.Called(ns, id, op, duration) + if args.Get(0) == nil { + return time.Time{}, args.Error(1) + } + + return args.Get(0).(time.Time), args.Error(1) +} + +// OutputsToWatch implements the wtxmgr.TxStore interface. +func (m *mockTxStore) OutputsToWatch( + ns walletdb.ReadBucket) ([]wtxmgr.Credit, error) { + + args := m.Called(ns) + return args.Get(0).([]wtxmgr.Credit), args.Error(1) +} + +// PutTxLabel implements the wtxmgr.TxStore interface. +func (m *mockTxStore) PutTxLabel(ns walletdb.ReadWriteBucket, + txid chainhash.Hash, label string) error { + + args := m.Called(ns, txid, label) + return args.Error(0) +} + +// RangeTransactions implements the wtxmgr.TxStore interface. +func (m *mockTxStore) RangeTransactions(ns walletdb.ReadBucket, begin, + end int32, f func([]wtxmgr.TxDetails) (bool, error)) error { + + args := m.Called(ns, begin, end, f) + return args.Error(0) +} + +// Rollback implements the wtxmgr.TxStore interface. +func (m *mockTxStore) Rollback(ns walletdb.ReadWriteBucket, height int32) error { + args := m.Called(ns, height) + return args.Error(0) +} + +// TxDetails implements the wtxmgr.TxStore interface. +func (m *mockTxStore) TxDetails(ns walletdb.ReadBucket, + txHash *chainhash.Hash) (*wtxmgr.TxDetails, error) { + + args := m.Called(ns, txHash) + details, _ := args.Get(0).(*wtxmgr.TxDetails) + + return details, args.Error(1) +} + +// UniqueTxDetails implements the wtxmgr.TxStore interface. +func (m *mockTxStore) UniqueTxDetails(ns walletdb.ReadBucket, + txHash *chainhash.Hash, + block *wtxmgr.Block) (*wtxmgr.TxDetails, error) { + + args := m.Called(ns, txHash, block) + details, _ := args.Get(0).(*wtxmgr.TxDetails) + + return details, args.Error(1) +} + +// UnlockOutput implements the wtxmgr.TxStore interface. +func (m *mockTxStore) UnlockOutput(ns walletdb.ReadWriteBucket, + id wtxmgr.LockID, op wire.OutPoint) error { + + args := m.Called(ns, id, op) + return args.Error(0) +} + +// UnspentOutputs implements the wtxmgr.TxStore interface. +func (m *mockTxStore) UnspentOutputs( + ns walletdb.ReadBucket) ([]wtxmgr.Credit, error) { + + args := m.Called(ns) + return args.Get(0).([]wtxmgr.Credit), args.Error(1) +} + +// GetUtxo implements the wtxmgr.TxStore interface. +func (m *mockTxStore) GetUtxo(ns walletdb.ReadBucket, + outpoint wire.OutPoint) (*wtxmgr.Credit, error) { + + args := m.Called(ns, outpoint) + credit, _ := args.Get(0).(*wtxmgr.Credit) + + return credit, args.Error(1) +} + +// FetchTxLabel implements the wtxmgr.TxStore interface. +func (m *mockTxStore) FetchTxLabel(ns walletdb.ReadBucket, + txid chainhash.Hash) (string, error) { + + args := m.Called(ns, txid) + return args.String(0), args.Error(1) +} + +// UnminedTxs implements the wtxmgr.TxStore interface. +func (m *mockTxStore) UnminedTxs( + ns walletdb.ReadBucket) ([]*wire.MsgTx, error) { + + args := m.Called(ns) + return args.Get(0).([]*wire.MsgTx), args.Error(1) +} + +// UnminedTxHashes implements the wtxmgr.TxStore interface. +func (m *mockTxStore) UnminedTxHashes( + ns walletdb.ReadBucket) ([]*chainhash.Hash, error) { + + args := m.Called(ns) + return args.Get(0).([]*chainhash.Hash), args.Error(1) +} + +// RemoveUnminedTx implements the wtxmgr.TxStore interface. +func (m *mockTxStore) RemoveUnminedTx(ns walletdb.ReadWriteBucket, + rec *wtxmgr.TxRecord) error { + + args := m.Called(ns, rec) + return args.Error(0) +} + +// mockAddrStore is a mock implementation of the waddrmgr.AddrStore interface. +type mockAddrStore struct { + mock.Mock +} + +// Birthday returns the birthday of the address store. +func (m *mockAddrStore) Birthday() time.Time { + args := m.Called() + return args.Get(0).(time.Time) +} + +// SetSyncedTo marks the address manager to be in sync with the +// recently-seen block described by the blockstamp. +func (m *mockAddrStore) SetSyncedTo(ns walletdb.ReadWriteBucket, + bs *waddrmgr.BlockStamp) error { + + args := m.Called(ns, bs) + return args.Error(0) +} + +// SetBirthdayBlock sets the birthday block, or earliest time a key could +// have been used, for the manager. +func (m *mockAddrStore) SetBirthdayBlock(ns walletdb.ReadWriteBucket, + block waddrmgr.BlockStamp, verified bool) error { + + args := m.Called(ns, block, verified) + return args.Error(0) +} + +// SyncedTo returns details about the block height and hash that the +// address manager is synced through at the very least. +func (m *mockAddrStore) SyncedTo() waddrmgr.BlockStamp { + args := m.Called() + return args.Get(0).(waddrmgr.BlockStamp) +} + +// BlockHash returns the block hash at a particular block height. +func (m *mockAddrStore) BlockHash(ns walletdb.ReadBucket, + height int32) (*chainhash.Hash, error) { + + args := m.Called(ns, height) + return args.Get(0).(*chainhash.Hash), args.Error(1) +} + +// ActiveScopedKeyManagers returns a slice of all the active scoped key +// managers currently known by the root key manager. +func (m *mockAddrStore) ActiveScopedKeyManagers() []waddrmgr.AccountStore { + args := m.Called() + return args.Get(0).([]waddrmgr.AccountStore) +} + +// FetchScopedKeyManager attempts to fetch an active scoped manager +// according to its registered scope. +func (m *mockAddrStore) FetchScopedKeyManager( + scope waddrmgr.KeyScope) (waddrmgr.AccountStore, error) { + + args := m.Called(scope) + return args.Get(0).(waddrmgr.AccountStore), args.Error(1) +} + +// Address returns a managed address given the passed address if it is +// known to the address manager. +func (m *mockAddrStore) Address(ns walletdb.ReadBucket, + address btcutil.Address) (waddrmgr.ManagedAddress, error) { + + args := m.Called(ns, address) + return args.Get(0).(waddrmgr.ManagedAddress), args.Error(1) +} + +// AddrAccount returns the account to which the given address belongs. +func (m *mockAddrStore) AddrAccount(ns walletdb.ReadBucket, + address btcutil.Address) (waddrmgr.AccountStore, uint32, error) { + + args := m.Called(ns, address) + + return args.Get(0).(waddrmgr.AccountStore), + args.Get(1).(uint32), args.Error(2) +} + +// AddressDetails determines whether the wallet has access to the private +// keys required to sign for a given address, and returns other address +// details. +func (m *mockAddrStore) AddressDetails(ns walletdb.ReadBucket, + addr btcutil.Address) (bool, string, waddrmgr.AddressType) { + + args := m.Called(ns, addr) + return args.Bool(0), args.String(1), args.Get(2).(waddrmgr.AddressType) +} + +// ForEachRelevantActiveAddress invokes the given closure on each active +// address relevant to the wallet. +func (m *mockAddrStore) ForEachRelevantActiveAddress(ns walletdb.ReadBucket, + fn func(addr btcutil.Address) error) error { + + args := m.Called(ns, fn) + return args.Error(0) +} + +// Unlock derives the master private key from the specified passphrase. +func (m *mockAddrStore) Unlock(ns walletdb.ReadBucket, + passphrase []byte) error { + + args := m.Called(ns, passphrase) + return args.Error(0) +} + +// Lock performs a best try effort to remove and zero all secret keys +// associated with the address manager. +func (m *mockAddrStore) Lock() error { + args := m.Called() + return args.Error(0) +} + +// IsLocked returns whether or not the address managed is locked. +func (m *mockAddrStore) IsLocked() bool { + args := m.Called() + return args.Bool(0) +} + +// ChangePassphrase changes either the public or private passphrase to +// the provided value depending on the private flag. +func (m *mockAddrStore) ChangePassphrase(ns walletdb.ReadWriteBucket, + oldPass, newPass []byte, private bool, + scryptOptions *waddrmgr.ScryptOptions) error { + + args := m.Called(ns, oldPass, newPass, private, scryptOptions) + return args.Error(0) +} + +// WatchOnly returns true if the root manager is in watch only mode, and +// false otherwise. +func (m *mockAddrStore) WatchOnly() bool { + args := m.Called() + return args.Bool(0) +} + +// MarkUsed updates the used flag for the provided address. +func (m *mockAddrStore) MarkUsed(ns walletdb.ReadWriteBucket, + address btcutil.Address) error { + + args := m.Called(ns, address) + return args.Error(0) +} + +// BirthdayBlock returns the birthday block of the address store. +func (m *mockAddrStore) BirthdayBlock( + ns walletdb.ReadBucket) (waddrmgr.BlockStamp, bool, error) { + + args := m.Called(ns) + return args.Get(0).(waddrmgr.BlockStamp), args.Bool(1), args.Error(2) +} + +// IsWatchOnlyAccount determines if the account with the given key scope +// is set up as watch-only. +func (m *mockAddrStore) IsWatchOnlyAccount(ns walletdb.ReadBucket, + keyScope waddrmgr.KeyScope, account uint32) (bool, error) { + + args := m.Called(ns, keyScope, account) + return args.Bool(0), args.Error(1) +} + +// NewScopedKeyManager creates a new scoped key manager from the root +// manager. +func (m *mockAddrStore) NewScopedKeyManager(ns walletdb.ReadWriteBucket, + scope waddrmgr.KeyScope, + addrSchema waddrmgr.ScopeAddrSchema) (waddrmgr.AccountStore, error) { + + args := m.Called(ns, scope, addrSchema) + return args.Get(0).(waddrmgr.AccountStore), args.Error(1) +} + +// SetBirthday sets the birthday of the address store. +func (m *mockAddrStore) SetBirthday(ns walletdb.ReadWriteBucket, + birthday time.Time) error { + + args := m.Called(ns, birthday) + return args.Error(0) +} + +// ForEachAccountAddress calls the given function with each address of +// the given account stored in the manager, breaking early on error. +func (m *mockAddrStore) ForEachAccountAddress(ns walletdb.ReadBucket, + account uint32, fn func(maddr waddrmgr.ManagedAddress) error) error { + + args := m.Called(ns, account, fn) + return args.Error(0) +} + +// LookupAccount returns the corresponding key scope and account number +// for the account with the given name. +func (m *mockAddrStore) LookupAccount(ns walletdb.ReadBucket, + name string) (waddrmgr.KeyScope, uint32, error) { + + args := m.Called(ns, name) + return args.Get(0).(waddrmgr.KeyScope), args.Get(1).(uint32), args.Error(2) +} + +// ForEachActiveAddress calls the given function with each active address +// stored in the manager, breaking early on error. +func (m *mockAddrStore) ForEachActiveAddress(ns walletdb.ReadBucket, + fn func(addr btcutil.Address) error) error { + + args := m.Called(ns, fn) + return args.Error(0) +} + +// ConvertToWatchingOnly converts the current address manager to a locked +// watching-only address manager. +func (m *mockAddrStore) ConvertToWatchingOnly( + ns walletdb.ReadWriteBucket) error { + + args := m.Called(ns) + return args.Error(0) +} + +// ChainParams returns the chain parameters for this address manager. +func (m *mockAddrStore) ChainParams() *chaincfg.Params { + args := m.Called() + return args.Get(0).(*chaincfg.Params) +} + +// Close cleanly shuts down the manager. +func (m *mockAddrStore) Close() { + m.Called() +} + +// mockAccountStore is a mock implementation of the waddrmgr.AccountStore +// interface. +type mockAccountStore struct { + mock.Mock +} + +// A compile-time assertion to ensure that mockAccountStore implements the +// AccountStore interface. +var _ waddrmgr.AccountStore = (*mockAccountStore)(nil) + +// Scope implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) Scope() waddrmgr.KeyScope { + args := m.Called() + return args.Get(0).(waddrmgr.KeyScope) +} + +// AccountProperties implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) AccountProperties(ns walletdb.ReadBucket, + account uint32) (*waddrmgr.AccountProperties, error) { + + args := m.Called(ns, account) + return args.Get(0).(*waddrmgr.AccountProperties), args.Error(1) +} + +// LastExternalAddress implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) LastExternalAddress(ns walletdb.ReadBucket, + account uint32) (waddrmgr.ManagedAddress, error) { + + args := m.Called(ns, account) + return args.Get(0).(waddrmgr.ManagedAddress), args.Error(1) +} + +// LastInternalAddress implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) LastInternalAddress(ns walletdb.ReadBucket, + account uint32) (waddrmgr.ManagedAddress, error) { + + args := m.Called(ns, account) + return args.Get(0).(waddrmgr.ManagedAddress), args.Error(1) +} + +// ForEachAccountAddress implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ForEachAccountAddress(ns walletdb.ReadBucket, + account uint32, fn func(maddr waddrmgr.ManagedAddress) error) error { + + args := m.Called(ns, account, fn) + return args.Error(0) +} + +// LookupAccount implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) LookupAccount(ns walletdb.ReadBucket, + name string) (uint32, error) { + + args := m.Called(ns, name) + return args.Get(0).(uint32), args.Error(1) +} + +// AccountName implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) AccountName(ns walletdb.ReadBucket, + account uint32) (string, error) { + + args := m.Called(ns, account) + return args.String(0), args.Error(1) +} + +// ExtendExternalAddresses implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ExtendExternalAddresses(ns walletdb.ReadWriteBucket, + account uint32, count uint32) error { + + args := m.Called(ns, account, count) + return args.Error(0) +} + +// ExtendInternalAddresses implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ExtendInternalAddresses(ns walletdb.ReadWriteBucket, + account uint32, count uint32) error { + + args := m.Called(ns, account, count) + return args.Error(0) +} + +// MarkUsed implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) MarkUsed(ns walletdb.ReadWriteBucket, + address btcutil.Address) error { + + args := m.Called(ns, address) + return args.Error(0) +} + +// DeriveFromKeyPath implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) DeriveFromKeyPath(ns walletdb.ReadBucket, + path waddrmgr.DerivationPath) (waddrmgr.ManagedAddress, error) { + + args := m.Called(ns, path) + return args.Get(0).(waddrmgr.ManagedAddress), args.Error(1) +} + +// CanAddAccount implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) CanAddAccount() error { + args := m.Called() + return args.Error(0) +} + +// NewAccount implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) NewAccount(ns walletdb.ReadWriteBucket, + name string) (uint32, error) { + + args := m.Called(ns, name) + return args.Get(0).(uint32), args.Error(1) +} + +// LastAccount implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) LastAccount(ns walletdb.ReadBucket) (uint32, error) { + args := m.Called(ns) + return args.Get(0).(uint32), args.Error(1) +} + +// RenameAccount implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) RenameAccount(ns walletdb.ReadWriteBucket, + account uint32, name string) error { + + args := m.Called(ns, account, name) + return args.Error(0) +} + +// NextExternalAddresses implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) NextExternalAddresses(ns walletdb.ReadWriteBucket, + account uint32, count uint32) ([]waddrmgr.ManagedAddress, error) { + + args := m.Called(ns, account, count) + return args.Get(0).([]waddrmgr.ManagedAddress), args.Error(1) +} + +// NextInternalAddresses implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) NextInternalAddresses(ns walletdb.ReadWriteBucket, + account uint32, count uint32) ([]waddrmgr.ManagedAddress, error) { + + args := m.Called(ns, account, count) + return args.Get(0).([]waddrmgr.ManagedAddress), args.Error(1) +} + +// NewAddress implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) NewAddress(ns walletdb.ReadWriteBucket, + account string, internal bool) (btcutil.Address, error) { + + args := m.Called(ns, account, internal) + return args.Get(0).(btcutil.Address), args.Error(1) +} + +// ImportPublicKey implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ImportPublicKey(ns walletdb.ReadWriteBucket, + pubKey *btcec.PublicKey, + bs *waddrmgr.BlockStamp) (waddrmgr.ManagedAddress, error) { + + args := m.Called(ns, pubKey, bs) + return args.Get(0).(waddrmgr.ManagedAddress), args.Error(1) +} + +// ImportTaprootScript implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ImportTaprootScript(ns walletdb.ReadWriteBucket, + script *waddrmgr.Tapscript, bs *waddrmgr.BlockStamp, privKeyType byte, + isInternal bool) (waddrmgr.ManagedTaprootScriptAddress, error) { + + args := m.Called(ns, script, bs, privKeyType, isInternal) + return args.Get(0).(waddrmgr.ManagedTaprootScriptAddress), args.Error(1) +} + +// ForEachAccount implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ForEachAccount(ns walletdb.ReadBucket, + fn func(account uint32) error) error { + + args := m.Called(ns, fn) + return args.Error(0) +} + +// IsWatchOnlyAccount implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) IsWatchOnlyAccount(ns walletdb.ReadBucket, + account uint32) (bool, error) { + + args := m.Called(ns, account) + return args.Bool(0), args.Error(1) +} + +// NewAccountWatchingOnly implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) NewAccountWatchingOnly(ns walletdb.ReadWriteBucket, + name string, pubKey *hdkeychain.ExtendedKey, + masterKeyFingerprint uint32, + addrSchema *waddrmgr.ScopeAddrSchema) (uint32, error) { + + args := m.Called(ns, name, pubKey, masterKeyFingerprint, addrSchema) + return args.Get(0).(uint32), args.Error(1) +} + +// InvalidateAccountCache implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) InvalidateAccountCache(account uint32) { + m.Called(account) +} + +// ImportPrivateKey implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ImportPrivateKey(ns walletdb.ReadWriteBucket, + wif *btcutil.WIF, + bs *waddrmgr.BlockStamp) (waddrmgr.ManagedPubKeyAddress, error) { + + args := m.Called(ns, wif, bs) + return args.Get(0).(waddrmgr.ManagedPubKeyAddress), args.Error(1) +} + +// AddrAccount implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) AddrAccount(ns walletdb.ReadBucket, + address btcutil.Address) (uint32, error) { + + args := m.Called(ns, address) + return args.Get(0).(uint32), args.Error(1) +} + +// DeriveFromKeyPathCache implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) DeriveFromKeyPathCache( + kp waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { + + args := m.Called(kp) + return args.Get(0).(*btcec.PrivateKey), args.Error(1) +} + +// NewRawAccount implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) NewRawAccount(ns walletdb.ReadWriteBucket, + number uint32) error { + + args := m.Called(ns, number) + return args.Error(0) +} + +// NewRawAccountWatchingOnly implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) NewRawAccountWatchingOnly(ns walletdb.ReadWriteBucket, + number uint32, pubKey *hdkeychain.ExtendedKey, + masterKeyFingerprint uint32, + addrSchema *waddrmgr.ScopeAddrSchema) error { + + args := m.Called(ns, number, pubKey, masterKeyFingerprint, addrSchema) + return args.Error(0) +} + +// ImportScript implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ImportScript(ns walletdb.ReadWriteBucket, + script []byte, bs *waddrmgr.BlockStamp) (waddrmgr.ManagedScriptAddress, error) { + + args := m.Called(ns, script, bs) + return args.Get(0).(waddrmgr.ManagedScriptAddress), args.Error(1) +} + +// mockManagedAddress is a mock implementation of the waddrmgr.ManagedAddress +// interface. +type mockManagedAddress struct { + mock.Mock +} + +// A compile-time assertion to ensure that mockManagedAddress implements the +// ManagedAddress interface. +var _ waddrmgr.ManagedAddress = (*mockManagedAddress)(nil) + +// Address implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedAddress) Address() btcutil.Address { + args := m.Called() + return args.Get(0).(btcutil.Address) +} + +// AddrHash implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedAddress) AddrHash() []byte { + args := m.Called() + return args.Get(0).([]byte) +} + +// Imported implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedAddress) Imported() bool { + args := m.Called() + return args.Bool(0) +} + +// Internal implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedAddress) Internal() bool { + args := m.Called() + return args.Bool(0) +} + +// Compressed implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedAddress) Compressed() bool { + args := m.Called() + return args.Bool(0) +} + +// Used implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedAddress) Used(ns walletdb.ReadBucket) bool { + args := m.Called(ns) + return args.Bool(0) +} + +// AddrType implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedAddress) AddrType() waddrmgr.AddressType { + args := m.Called() + return args.Get(0).(waddrmgr.AddressType) +} + +// InternalAccount implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedAddress) InternalAccount() uint32 { + args := m.Called() + return args.Get(0).(uint32) +} + +// DerivationInfo implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedAddress) DerivationInfo() (waddrmgr.KeyScope, waddrmgr.DerivationPath, bool) { + args := m.Called() + return args.Get(0).(waddrmgr.KeyScope), args.Get(1).(waddrmgr.DerivationPath), args.Bool(2) +} From addab0fb8ecacdeff3108950dd255eeb1b49b5ae Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 4 Sep 2025 18:00:19 +0800 Subject: [PATCH 078/691] wallet: fix chain params used in the tests --- wallet/chainntfns_test.go | 2 +- wallet/example_test.go | 5 ++--- wallet/import_test.go | 21 ++++++++++----------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/wallet/chainntfns_test.go b/wallet/chainntfns_test.go index 8d6239028a..540cee2e95 100644 --- a/wallet/chainntfns_test.go +++ b/wallet/chainntfns_test.go @@ -22,7 +22,7 @@ const ( var ( // chainParams are the chain parameters used throughout the wallet // tests. - chainParams = chaincfg.MainNetParams + chainParams = chaincfg.RegressionNetParams ) // mockChainConn is a mock in-memory implementation of the chainConn interface diff --git a/wallet/example_test.go b/wallet/example_test.go index 905793789a..6992c0dc34 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -5,7 +5,6 @@ import ( "time" "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" ) @@ -28,7 +27,7 @@ func testWallet(t *testing.T) (*Wallet, func()) { privPass := []byte("world") loader := NewLoader( - &chaincfg.TestNet3Params, dir, true, defaultDBTimeout, 250, + &chainParams, dir, true, defaultDBTimeout, 250, WithWalletSyncRetryInterval(10*time.Millisecond), ) w, err := loader.CreateNewWallet(pubPass, privPass, seed, time.Now()) @@ -51,7 +50,7 @@ func testWalletWatchingOnly(t *testing.T) (*Wallet, func()) { pubPass := []byte("hello") loader := NewLoader( - &chaincfg.TestNet3Params, dir, true, defaultDBTimeout, 250, + &chainParams, dir, true, defaultDBTimeout, 250, WithWalletSyncRetryInterval(10*time.Millisecond), ) w, err := loader.CreateNewWatchingOnlyWallet(pubPass, time.Now()) diff --git a/wallet/import_test.go b/wallet/import_test.go index 5e81f9eca6..68cbbe9878 100644 --- a/wallet/import_test.go +++ b/wallet/import_test.go @@ -7,7 +7,6 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/stretchr/testify/require" @@ -36,7 +35,7 @@ func deriveAcctPubKey(t *testing.T, root *hdkeychain.ExtendedKey, // non-standard methods. We need to convert them to standard, neuter, // then convert them back with the target extended public key version. pubVersionBytes := make([]byte, 4) - copy(pubVersionBytes, chaincfg.TestNet3Params.HDPublicKeyID[:]) + copy(pubVersionBytes, chainParams.HDPublicKeyID[:]) switch { case strings.HasPrefix(root.String(), "uprv"): binary.BigEndian.PutUint32(pubVersionBytes, uint32( @@ -50,7 +49,7 @@ func deriveAcctPubKey(t *testing.T, root *hdkeychain.ExtendedKey, } currentKey, err = currentKey.CloneWithVersion( - chaincfg.TestNet3Params.HDPrivateKeyID[:], + chainParams.HDPrivateKeyID[:], ) require.NoError(t, err) currentKey, err = currentKey.Neuter() @@ -90,8 +89,8 @@ var ( accountIndex: 777, addrType: waddrmgr.WitnessPubKey, expectedScope: waddrmgr.KeyScopeBIP0084, - expectedAddr: "tb1qllxcutkzsukf8u8c8stkp464j0esu9xq7qju8x", - expectedChangeAddr: "tb1qu6jmqglrthscptjqj3egx54wy8xqvzn5hslgw7", + expectedAddr: "bcrt1qllxcutkzsukf8u8c8stkp464j0esu9xquft3s0", + expectedChangeAddr: "bcrt1qu6jmqglrthscptjqj3egx54wy8xqvzn54ex9eh", }, { name: "traditional bip49", masterPriv: "uprv8tXDerPXZ1QsVp8y6GAMSMYxPQgWi3LSY8qS5ZH9x1YRu" + @@ -111,7 +110,7 @@ var ( addrType: waddrmgr.WitnessPubKey, expectedScope: waddrmgr.KeyScopeBIP0049Plus, expectedAddr: "2NBCJ9WzGXZqpLpXGq3Hacybj3c4eHRcqgh", - expectedChangeAddr: "tb1qeqn05w2hfq6axpdprhs4y7x65gxkkvfvyxqk4u", + expectedChangeAddr: "bcrt1qeqn05w2hfq6axpdprhs4y7x65gxkkvfvx0emz4", }, { name: "bip84", masterPriv: "vprv9DMUxX4ShgxMM7L5vcwyeSeTZNpxefKwTFMerxB3L1vJ" + @@ -120,8 +119,8 @@ var ( accountIndex: 1, addrType: waddrmgr.WitnessPubKey, expectedScope: waddrmgr.KeyScopeBIP0084, - expectedAddr: "tb1q5vepvcl0z8xj7kps4rsux722r4dvfwlhk6j532", - expectedChangeAddr: "tb1qlwe2kgxcsa8x4huu79yff4rze0l5mwafg5c7xd", + expectedAddr: "bcrt1q5vepvcl0z8xj7kps4rsux722r4dvfwlh5ntexr", + expectedChangeAddr: "bcrt1qlwe2kgxcsa8x4huu79yff4rze0l5mwaf2apn3y", }} ) @@ -269,7 +268,7 @@ func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, case waddrmgr.NestedWitnessPubKey: witnessAddr, err := btcutil.NewAddressWitnessPubKeyHash( btcutil.Hash160(acct3ExternalPub.SerializeCompressed()), - &chaincfg.TestNet3Params, + w.chainParams, ) require.NoError(t, err) @@ -277,14 +276,14 @@ func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, require.NoError(t, err) intAddr, err = btcutil.NewAddressScriptHash( - witnessProg, &chaincfg.TestNet3Params, + witnessProg, w.chainParams, ) require.NoError(t, err) case waddrmgr.WitnessPubKey: intAddr, err = btcutil.NewAddressWitnessPubKeyHash( btcutil.Hash160(acct3ExternalPub.SerializeCompressed()), - &chaincfg.TestNet3Params, + w.chainParams, ) require.NoError(t, err) From e1ee70c577754c1ac34013811167bb076a7cc1f8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 3 Sep 2025 03:45:16 +0800 Subject: [PATCH 079/691] waddrmgr+wallet: implement `ListUnspent` This commit introduces a new, more efficient `ListUnspent` method in the wallet package, backed by a refactoring in the waddrmgr. The primary motivation is to create a more accurate, performant, and architecturally sound way of querying the wallet's UTXO set. The previous approach, exemplified by `lnd`'s `ListUnspentWitness`, required the client to fetch a list of UTXOs and then infer critical information like the address type from the raw public key script. This was inefficient and error-prone, as it relied on assumptions about script types (e.g., assuming all P2SH outputs were nested P2WKH). This new implementation is superior for several reasons: 1. **Accuracy:** It retrieves the address type and other details directly from `waddrmgr`, which is the source of truth. This eliminates guesswork and prevents potential bugs caused by incorrect script interpretation. 2. **Efficiency:** The new `waddrmgr.AddressDetails` method (a refactor of the old `IsSpendable`) consolidates multiple database lookups. It now fetches the account, spendability, and address type in a single pass, addressing the N+1 query problem and reducing database load. 3. **Improved Design:** The logic is now correctly placed within the wallet itself, not the client. The wallet provides a rich, accurate `Utxo` object, allowing consumers like `lnd` to simplify their code and focus on their core logic instead of wallet-internal details. --- waddrmgr/interface.go | 6 ++ waddrmgr/manager.go | 50 +++++++++ wallet/example_test.go | 63 ++++++++++++ wallet/utxo_manager.go | 177 +++++++++++++++++++++++++++++++- wallet/utxo_manager_test.go | 195 ++++++++++++++++++++++++++++++++++++ wtxmgr/tx.go | 28 ++++++ 6 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 wallet/utxo_manager_test.go diff --git a/waddrmgr/interface.go b/waddrmgr/interface.go index 5346684050..079dc09fb1 100644 --- a/waddrmgr/interface.go +++ b/waddrmgr/interface.go @@ -109,6 +109,12 @@ type AddrStore interface { AddrAccount(ns walletdb.ReadBucket, address btcutil.Address) (AccountStore, uint32, error) + // AddressDetails determines whether the wallet has access to the + // private keys required to sign for a given address, and returns other + // address details. + AddressDetails(ns walletdb.ReadBucket, + addr btcutil.Address) (bool, string, AddressType) + // ForEachRelevantActiveAddress invokes the given closure on each active // address relevant to the wallet. ForEachRelevantActiveAddress(ns walletdb.ReadBucket, diff --git a/waddrmgr/manager.go b/waddrmgr/manager.go index 1e301445af..b29cf07498 100644 --- a/waddrmgr/manager.go +++ b/waddrmgr/manager.go @@ -55,6 +55,9 @@ const ( // used to refer to (and only to) the default account. defaultAccountName = "default" + // unknownAccountName is the string returned when an account is unknown. + unknownAccountName = "unknown" + // The hierarchy described by BIP0043 is: // m/'/* // This is further extended by BIP0044 to: @@ -778,6 +781,53 @@ func (m *Manager) AddrAccount(ns walletdb.ReadBucket, return nil, 0, managerError(ErrAddressNotFound, str, nil) } +// AddressDetails determines whether the wallet has access to the private keys +// required to sign for a given address, and returns other address details. +func (m *Manager) AddressDetails(ns walletdb.ReadBucket, + addr btcutil.Address) (bool, string, AddressType) { + + managedAddr, err := m.Address(ns, addr) + if err != nil { + // If we don't know the address, we can't spend it. + return false, unknownAccountName, 0 + } + + addrType := managedAddr.AddrType() + + // A global watch-only wallet can't spend anything. + if m.WatchOnly() { + return false, unknownAccountName, addrType + } + + // Imported addresses are considered unspendable by policy. + if managedAddr.Imported() { + return false, ImportedAddrAccountName, addrType + } + + // Check if the specific account for this address is watch-only. + scopedMgr, account, err := m.AddrAccount(ns, addr) + if err != nil { + return false, unknownAccountName, addrType + } + + accountName, err := scopedMgr.AccountName(ns, account) + if err != nil { + return false, unknownAccountName, addrType + } + + isWatchOnlyAccount, err := scopedMgr.IsWatchOnlyAccount(ns, account) + if err != nil { + return false, accountName, addrType + } + + if isWatchOnlyAccount { + return false, accountName, addrType + } + + // If all checks pass, the address is spendable. + return true, accountName, addrType +} + // ForEachActiveAccountAddress calls the given function with each active // address of the given account stored in the manager, across all active // scopes, breaking early on error. diff --git a/wallet/example_test.go b/wallet/example_test.go index 6992c0dc34..0f7ff731c4 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -7,6 +7,8 @@ import ( "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) // defaultDBTimeout specifies the timeout value when opening the wallet @@ -43,6 +45,67 @@ func testWallet(t *testing.T) (*Wallet, func()) { return w, func() {} } +// mockers is a struct that holds all the mocked interfaces that can be +// used to test the wallet. +type mockers struct { + chainClient *mockChainClient + addrStore *mockAddrStore + txStore *mockTxStore +} + +// testWalletWithMocks creates a test wallet and unlocks it. In contrast to +// testWallet, this function mocks out all the wallet's dependencies so that +// we can test the wallet's logic in isolation. +func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { + t.Helper() + // Set up a wallet. + dir := t.TempDir() + + seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes) + require.NoError(t, err) + + pubPass := []byte("hello") + privPass := []byte("world") + + loader := NewLoader( + &chainParams, dir, true, defaultDBTimeout, 250, + WithWalletSyncRetryInterval(10*time.Millisecond), + ) + w, err := loader.CreateNewWallet(pubPass, privPass, seed, time.Now()) + require.NoError(t, err) + + chainClient := &mockChainClient{} + txStore := &mockTxStore{} + addrStore := &mockAddrStore{} + + addrStore.On("IsLocked").Return(false) + addrStore.On("Unlock", mock.Anything, mock.Anything).Return(nil) + + w.chainClient = chainClient + w.txStore = txStore + w.addrStore = addrStore + + err = w.Unlock(privPass, time.After(60*time.Minute)) + require.NoError(t, err) + + // Create the mockers struct so it can be used by the tests to mock + // methods. + m := &mockers{ + chainClient: chainClient, + txStore: txStore, + addrStore: addrStore, + } + + // When the test finishes, we need to assert the mocked methods are + // called or not called as expected. + t.Cleanup(func() { + txStore.AssertExpectations(t) + addrStore.AssertExpectations(t) + }) + + return w, m +} + // testWalletWatchingOnly creates a test watch only wallet and unlocks it. func testWalletWatchingOnly(t *testing.T) (*Wallet, func()) { // Set up a wallet. diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index c942a5b1fc..60b0d4fb7a 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -13,11 +13,13 @@ package wallet import ( "context" + "sort" "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -68,7 +70,8 @@ type UtxoQuery struct { // UTXO set. type UtxoManager interface { // ListUnspent returns a slice of all unspent transaction outputs that - // match the query. + // match the query. The returned UTXOs are sorted by amount in + // ascending order. ListUnspent(ctx context.Context, query UtxoQuery) ([]*Utxo, error) // GetUtxoInfo returns the output information for a given outpoint. @@ -88,3 +91,175 @@ type UtxoManager interface { ListLeasedOutputs(ctx context.Context) ([]*ListLeasedOutputResult, error) } +// ListUnspent returns a slice of unspent transaction outputs that match the +// query. +// +// This method provides a comprehensive view of the wallet's UTXO set, allowing +// for filtering by account and confirmation status. The results are enriched +// with detailed information about each UTXO, such as its address, account, +// and spendability. +// +// How it works: +// The method performs a full scan of all UTXOs in the wallet's transaction +// store (`wtxmgr`). For each UTXO, it applies the specified filters (account, +// confirmations). If a UTXO matches, the method then performs an additional +// lookup in the address manager (`waddrmgr`) to enrich the UTXO data with +// details like the owning account name, address type, and spendability. This +// process of fetching a list and then performing a lookup for each item is +// known as the "N+1 query problem" and is a known inefficiency (see TODO). +// +// Logical Steps: +// 1. Initiate a single, read-only database transaction to ensure a +// consistent view of the data. +// 2. Fetch all unspent transaction outputs from the `wtxmgr` namespace. +// 3. Sort the outputs in ascending order of value. This is a convention to +// make the list more predictable and potentially useful for coin +// selection algorithms that prefer larger UTXOs. +// 4. Iterate through each UTXO: +// a. Calculate its current confirmation status based on the wallet's +// synced block height. +// b. Apply the `MinConfs` and `MaxConfs` filters from the query. +// c. Extract the address from the UTXO's public key script. For +// multi-address scripts, the first address is used. +// d. Call `waddrmgr.AddressDetails` to get the spendability status, +// account name, and address type in a single, efficient lookup. +// e. Apply the `Account` filter from the query. +// f. If all filters pass, construct the final `Utxo` struct with all +// the combined data. +// 5. Append the `Utxo` to the result slice. +// 6. After iterating through all UTXOs, return the final slice. +// +// Database Actions: +// - This method performs a single read-only database transaction +// (`walletdb.View`). +// - It reads from both the `wtxmgr` (for UTXOs) and `waddrmgr` (for +// address details) namespaces. +// +// Time Complexity: +// - The complexity is O(U * A_l), where U is the total number of unspent +// transaction outputs in the wallet and A_l is the average cost of the +// address and account lookups (`AddressDetails`). This is due to the N+1 +// query problem where each UTXO requires additional lookups. +// +// TODO(yy): The current implementation of ListUnspent fetches all UTXOs +// from the database and then filters them in memory. This is inefficient for +// wallets with a large number of UTXOs. The upcoming SQL schema redesign should +// address the following issues: +// +// 1. **N+1 Query Problem:** The function iterates through all unspent outputs +// and performs separate database lookups for each one to retrieve its full +// details. The database schema should be denormalized to include this data +// directly in the `unspent` value, which would turn the N+1 query into a +// single, efficient bucket scan. +// +// 2. **Lack of Pagination:** The function loads all results into a single +// in-memory slice, which can be memory-intensive for wallets with a large +// UTXO set. A more scalable approach would use an iterator pattern. +// +// NOTE: This is part of the UtxoManager interface implementation. +func (w *Wallet) ListUnspent(_ context.Context, + query UtxoQuery) ([]*Utxo, error) { + + log.Debugf("ListUnspent using query: %v", query) + + syncBlock := w.addrStore.SyncedTo() + currentHeight := syncBlock.Height + + var utxos []*Utxo + + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // First, fetch all unspent transaction outputs from the UTXO + // set. + unspent, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + + // Iterate through each UTXO to apply filters and enrich it with + // address-specific details. + for _, output := range unspent { + // Calculate the current confirmation status based on the + // wallet's synced block height. + confs := int32(0) + if output.Height != -1 { + confs = currentHeight - output.Height + } + + log.Tracef("Checking utxo[%v]: current height=%v, "+ + "confirm height=%v, conf=%v", output.OutPoint, + currentHeight, output.Height, confs) + + // Apply the MinConfs and MaxConfs filters from the + // query. + if confs < query.MinConfs || confs > query.MaxConfs { + continue + } + + // Extract the address from the UTXO's public key script. + // For multi-address scripts, the first address is used. + addr := extractAddrFromPKScript( + output.PkScript, w.chainParams, + ) + if addr == nil { + continue + } + + // Get all the required address-related details. + // + // NOTE: This lookup is the source of the N+1 query + // problem. + spendable, account, addrType := w.addrStore. + AddressDetails(addrmgrNs, addr) + + log.Debugf("Found address: %s from account: %s", + addr.String(), account) + + // Apply the Account filter from the query. + if query.Account != "" && account != query.Account { + continue + } + + // A UTXO is also unspendable if it is an immature + // coinbase output. + if output.FromCoinBase { + maturity := w.chainParams.CoinbaseMaturity + if confs < int32(maturity) { + spendable = false + } + } + + // TODO(yy): This should be a column in the new utxo + // SQL table. + locked := w.LockedOutpoint(output.OutPoint) + + // If all filters pass, construct the final Utxo struct + // with all the combined data. + utxo := &Utxo{ + OutPoint: output.OutPoint, + Amount: output.Amount, + PkScript: output.PkScript, + Confirmations: confs, + Spendable: spendable, + Address: addr, + Account: account, + AddressType: addrType, + Locked: locked, + } + utxos = append(utxos, utxo) + } + + return nil + }) + + // Sort the outputs in ascending order of value. This is a convention + // to make the list more predictable and potentially useful for coin + // selection algorithms that prefer smaller UTXOs. + sort.Slice(utxos, func(i, j int) bool { + return utxos[i].Amount < utxos[j].Amount + }) + + return utxos, err +} diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go new file mode 100644 index 0000000000..f3b4871477 --- /dev/null +++ b/wallet/utxo_manager_test.go @@ -0,0 +1,195 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestListUnspent tests the ListUnspent method with various filters. +func TestListUnspent(t *testing.T) { + t.Parallel() + + // Create a new test wallet with mocks. + w, mocks := testWalletWithMocks(t) + + // Define account names. + account1 := "default" + account2 := "test" + + // Create the addresses that our mocks will return. + privKeyDefault, err := btcec.NewPrivateKey() + require.NoError(t, err) + addrDefault, err := btcutil.NewAddressPubKey( + privKeyDefault.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + + privKeyTest, err := btcec.NewPrivateKey() + require.NoError(t, err) + addrTest, err := btcutil.NewAddressPubKey( + privKeyTest.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + + // Set the current block height to be 100. + currentHeight := int32(100) + mocks.addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{ + Height: currentHeight, + }) + + mocks.addrStore.On("AddressDetails", mock.Anything, addrDefault).Return( + false, account1, waddrmgr.WitnessPubKey, + ) + mocks.addrStore.On("AddressDetails", mock.Anything, addrTest).Return( + false, account2, waddrmgr.NestedWitnessPubKey, + ) + + // Now that the mocks are set up, we can create the pkScripts. + pkScriptDefault, err := txscript.PayToAddrScript(addrDefault) + require.NoError(t, err) + pkScriptTest, err := txscript.PayToAddrScript(addrTest) + require.NoError(t, err) + + const ( + minConf = 2 + maxConf = 6 + ) + + // Create two UTXOs, one for each address. + utxo1 := wtxmgr.Credit{ + OutPoint: wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + }, + Amount: 100000, + PkScript: pkScriptDefault, + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: currentHeight - minConf, + }, + }, + } + utxo2 := wtxmgr.Credit{ + OutPoint: wire.OutPoint{ + Hash: [32]byte{2}, + Index: 0, + }, + Amount: 200000, + PkScript: pkScriptTest, + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: currentHeight - maxConf, + }, + }, + } + + // Mock the UnspentOutputs method to return the two UTXOs. + mocks.txStore.On("UnspentOutputs", mock.Anything).Return( + []wtxmgr.Credit{utxo1, utxo2}, nil, + ) + + testCases := []struct { + name string + query UtxoQuery + expectedCount int + expectedAddrs map[string]bool + }{ + { + name: "no filter", + query: UtxoQuery{MinConfs: 0, MaxConfs: 999999}, + expectedCount: 2, + expectedAddrs: map[string]bool{ + addrDefault.String(): true, + addrTest.String(): true, + }, + }, + { + name: "filter by default account", + query: UtxoQuery{ + Account: account1, + MinConfs: 0, + MaxConfs: 999999, + }, + expectedCount: 1, + expectedAddrs: map[string]bool{ + addrDefault.String(): true, + }, + }, + { + name: "filter by test account", + query: UtxoQuery{ + Account: account2, + MinConfs: 0, + MaxConfs: 999999, + }, + expectedCount: 1, + expectedAddrs: map[string]bool{ + addrTest.String(): true, + }, + }, + { + name: "filter by min confs", + query: UtxoQuery{ + MinConfs: minConf + 1, + MaxConfs: 999999, + }, + expectedCount: 1, + expectedAddrs: map[string]bool{ + addrTest.String(): true, + }, + }, + { + name: "filter by max confs", + query: UtxoQuery{ + MinConfs: 0, + MaxConfs: maxConf - 1, + }, + expectedCount: 1, + expectedAddrs: map[string]bool{ + addrDefault.String(): true, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + utxos, err := w.ListUnspent(t.Context(), tc.query) + require.NoError(t, err) + require.Len(t, utxos, tc.expectedCount) + + // Check that the correct addresses are returned. We do + // this by creating a map of the returned addresses and + // comparing it to the expected map. This ensures that + // all expected addresses are present and there are no + // duplicates. + returnedAddrs := make(map[string]bool) + for _, utxo := range utxos { + returnedAddrs[utxo.Address.String()] = true + } + + require.Equal(t, tc.expectedAddrs, returnedAddrs) + + // Check that the UTXOs are sorted by amount in + // ascending order. + for i := range len(utxos) - 1 { + require.LessOrEqual( + t, utxos[i].Amount, utxos[i+1].Amount, + ) + } + }) + } +} + diff --git a/wtxmgr/tx.go b/wtxmgr/tx.go index 588238a587..c6f2112051 100644 --- a/wtxmgr/tx.go +++ b/wtxmgr/tx.go @@ -809,6 +809,34 @@ func (s *Store) rollback(ns walletdb.ReadWriteBucket, height int32) error { return putMinedBalance(ns, minedBalance) } +// TODO(yy): The fetchCredits method suffers from several architectural and +// performance issues that should be addressed in a future refactoring: +// +// 1. **N+1 Query Problem:** The function iterates through all unspent outputs +// and performs a separate database lookup (`fetchTxRecord`) for each one to +// retrieve its full details. For a wallet with a large number of UTXOs, +// this results in an excessive number of database reads, leading to poor +// performance. +// +// 2. **Inefficient Data Storage:** The root cause of the N+1 problem is that +// the `unspent` bucket only stores a reference to the transaction, not the +// critical data (Amount, PkScript) itself. The schema should be +// denormalized to include this data directly in the `unspent` value, which +// would turn the N+1 query into a single, efficient bucket scan. +// +// 3. **Code Duplication:** The logic for iterating over mined and unmined +// credits is nearly identical, leading to significant code duplication. This +// should be consolidated into a more generic helper function. +// +// 4. **Leaky Abstraction:** The use of multiple boolean flags +// (`includeLocked`, `populateFullDetails`) to control behavior is a sign of +// a leaky abstraction. A better API would provide more specific query +// functions rather than a single, complex function with many toggles. +// +// 5. **Lack of Pagination:** The function loads all results into a single +// in-memory slice, which can be memory-intensive for wallets with a large +// UTXO set. A more scalable approach would use an iterator pattern. +// // fetchCredits retrieves credits from the store based on the provided filters. // It iterates over both mined (unspent) and unmined credits. // From 3ecdb91fd29f48db922abe241d52417929a1ff72 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 4 Sep 2025 04:11:23 +0800 Subject: [PATCH 080/691] wtxmgr: add new method `GetUtxo` This commit also introduces the GetUtxo method, which is a modern, higher-level replacement for the existing FetchOutpointInfo method. * **Return Type**: Returns a *wallet.Utxo. This is a high-level, user-friendly struct that is enriched with information from multiple sources within the wallet. * **Information Richness**: It combines data from the transaction store (wtxmgr) and the address manager (waddrmgr). This means it includes not only the basic transaction data (amount, script) but also wallet-specific details like the account name, the decoded address, address type, and a calculated confirmation count. * **Abstraction Level**: High. It's part of the new UtxoManager interface, which is designed to provide a clean, modern API for interacting with the wallet's UTXO set. * **Error Handling**: Returns a standard wtxmgr.ErrUtxoNotFound when the UTXO is not found. * **Return Type**: Returns a *wire.MsgTx, a *wire.TxOut, and the number of confirmations. These are lower-level, raw types. * **Information Richness**: It only returns information from the transaction store (wtxmgr) and does not provide wallet-specific context like the account name or address type. * **Abstraction Level**: Low. It's a more direct, internal-facing method. GetUtxo is the better method for most use cases as it provides a more complete and user-friendly data structure in a single call, simplifying the logic for consumers of the wallet's API. New code should prefer GetUtxo. --- wallet/utxo_manager.go | 114 ++++++++++++++++++++++++++++++++- wallet/utxo_manager_test.go | 95 ++++++++++++++++++++++++++++ wtxmgr/error.go | 10 ++- wtxmgr/interface.go | 8 +++ wtxmgr/query.go | 122 ++++++++++++++++++++++++++++++++++++ wtxmgr/query_test.go | 66 ++++++++++++++++++- 6 files changed, 411 insertions(+), 4 deletions(-) diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 60b0d4fb7a..44f9c09263 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -74,8 +74,8 @@ type UtxoManager interface { // ascending order. ListUnspent(ctx context.Context, query UtxoQuery) ([]*Utxo, error) - // GetUtxoInfo returns the output information for a given outpoint. - GetUtxoInfo(ctx context.Context, prevOut *wire.OutPoint) (*Utxo, error) + // GetUtxo returns the output information for a given outpoint. + GetUtxo(ctx context.Context, prevOut wire.OutPoint) (*Utxo, error) // LeaseOutput locks an output for a given duration, preventing it from // being used in transactions. @@ -263,3 +263,113 @@ func (w *Wallet) ListUnspent(_ context.Context, return utxos, err } + +// GetUtxo returns the output information for a given outpoint. +// +// This method provides a detailed view of a single UTXO, identified by its +// outpoint. The result is enriched with detailed information about the UTXO, +// such as its address, account, and spendability. +// +// How it works: +// The method performs a direct lookup of the UTXO in the wallet's transaction +// store (`wtxmgr`). If the UTXO is found, it then performs an additional +// lookup in the address manager (`waddrmgr`) to enrich the UTXO data with +// details like the owning account name, address type, and spendability. +// +// Logical Steps: +// 1. Initiate a single, read-only database transaction to ensure a +// consistent view of the data. +// 2. Fetch the unspent transaction output from the `wtxmgr` namespace using +// the provided outpoint. +// 3. If the UTXO is not found, return a `wtxmgr.ErrUtxoNotFound` error. +// 4. Calculate its current confirmation status based on the wallet's +// synced block height. +// 5. Extract the address from the UTXO's public key script. For +// multi-address scripts, the first address is used. +// 6. Call `waddrmgr.AddressDetails` to get the spendability status, +// account name, and address type in a single, efficient lookup. +// 7. Construct the final `Utxo` struct with all the combined data. +// 8. Return the final `Utxo` struct. +// +// Database Actions: +// - This method performs a single read-only database transaction +// (`walletdb.View`). +// - It reads from both the `wtxmgr` (for the UTXO) and `waddrmgr` (for +// address details) namespaces. +// +// Time Complexity: +// - The complexity is O(A_l), where A_l is the average cost of the +// address and account lookups (`AddressDetails`). +// +// TODO(yy): The current implementation of GetUtxo performs separate database +// lookups for the UTXO and its details. The upcoming SQL schema redesign should +// address this issue by denormalizing the data, which would turn the multiple +// lookups into a single, efficient query. +// +// NOTE: This is part of the UtxoManager interface implementation. +func (w *Wallet) GetUtxo(_ context.Context, + prevOut wire.OutPoint) (*Utxo, error) { + + // Calculate the current confirmation status based on the wallet's + // synced block height. + syncBlock := w.addrStore.SyncedTo() + currentHeight := syncBlock.Height + + var utxo *Utxo + + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // First, fetch the unspent transaction output from the UTXO + // set. + output, err := w.txStore.GetUtxo(txmgrNs, prevOut) + if err != nil { + return err + } + + // If the output is not found, return an error. + if output == nil { + return wtxmgr.ErrUtxoNotFound + } + + confs := int32(0) + if output.Height != -1 { + confs = currentHeight - output.Height + } + + // Extract the address from the UTXO's public key script. + // For multi-address scripts, the first address is used. + addr := extractAddrFromPKScript(output.PkScript, w.chainParams) + if addr == nil { + return wtxmgr.ErrUtxoNotFound + } + + // In a single lookup, get all the required + // address-related details: spendability, account name, + // and address type. This avoids the N+1 query problem. + spendable, account, addrType := w.addrStore. + AddressDetails(addrmgrNs, addr) + + // TODO(yy): This should be a column in the new utxo SQL table. + locked := w.LockedOutpoint(output.OutPoint) + + // If all filters pass, construct the final Utxo struct + // with all the combined data. + utxo = &Utxo{ + OutPoint: output.OutPoint, + Amount: output.Amount, + PkScript: output.PkScript, + Confirmations: confs, + Spendable: spendable, + Address: addr, + Account: account, + AddressType: addrType, + Locked: locked, + } + + return nil + }) + + return utxo, err +} diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index f3b4871477..a43fac350d 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -193,3 +193,98 @@ func TestListUnspent(t *testing.T) { } } +// TestGetUtxo tests that the GetUtxo method can successfully retrieve a UTXO. +func TestGetUtxo(t *testing.T) { + t.Parallel() + + // Create a new test wallet with mocks. + w, mocks := testWalletWithMocks(t) + + // Define account names. + account1 := "default" + + // Create the addresses that our mocks will return. + privKeyDefault, err := btcec.NewPrivateKey() + require.NoError(t, err) + addrDefault, err := btcutil.NewAddressPubKey( + privKeyDefault.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + + // Set the current block height to be 100. + currentHeight := int32(100) + mocks.addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{ + Height: currentHeight, + }) + + mocks.addrStore.On("AddressDetails", mock.Anything, addrDefault).Return( + false, account1, waddrmgr.WitnessPubKey, + ) + + // Now that the mocks are set up, we can create the pkScripts. + pkScriptDefault, err := txscript.PayToAddrScript(addrDefault) + require.NoError(t, err) + + // Create a UTXO. + utxo1 := wtxmgr.Credit{ + OutPoint: wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + }, + Amount: 100000, + PkScript: pkScriptDefault, + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: currentHeight - 1, + }, + }, + } + + // Mock the GetUtxo method to return the UTXO. + mocks.txStore.On("GetUtxo", mock.Anything, utxo1.OutPoint).Return( + &utxo1, nil, + ) + + // Construct the expected Utxo. + expectedUtxo := &Utxo{ + OutPoint: utxo1.OutPoint, + Amount: utxo1.Amount, + PkScript: utxo1.PkScript, + Confirmations: 1, + Spendable: false, + Address: addrDefault, + Account: account1, + AddressType: waddrmgr.WitnessPubKey, + } + + // Now, try to get the UTXO and compare it to our expected result. + utxo, err := w.GetUtxo(t.Context(), utxo1.OutPoint) + require.NoError(t, err) + require.Equal(t, expectedUtxo, utxo) +} + +// TestGetUtxo_Err tests the error conditions of the GetUtxo method. +func TestGetUtxo_Err(t *testing.T) { + t.Parallel() + + // Create a new test wallet with mocks. + w, mocks := testWalletWithMocks(t) + + // Set the current block height to be 100. + currentHeight := int32(100) + mocks.addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{ + Height: currentHeight, + }) + + // Test the case where the UTXO is not found. + utxoNotFound := wire.OutPoint{ + Hash: [32]byte{2}, + Index: 0, + } + mocks.txStore.On("GetUtxo", mock.Anything, utxoNotFound).Return( + nil, wtxmgr.ErrUtxoNotFound, + ) + utxo, err := w.GetUtxo(t.Context(), utxoNotFound) + require.ErrorIs(t, err, wtxmgr.ErrUtxoNotFound) + require.Nil(t, utxo) +} \ No newline at end of file diff --git a/wtxmgr/error.go b/wtxmgr/error.go index e1cd1d41e6..5069dd1e28 100644 --- a/wtxmgr/error.go +++ b/wtxmgr/error.go @@ -5,7 +5,10 @@ package wtxmgr -import "fmt" +import ( + "errors" + "fmt" +) // ErrorCode identifies a category of error. type ErrorCode uint8 @@ -51,6 +54,11 @@ const ( ErrUnknownVersion ) +var ( + // ErrUtxoNotFound is returned when a UTXO is not found in the store. + ErrUtxoNotFound = errors.New("utxo not found") +) + var errStrs = [...]string{ ErrDatabase: "ErrDatabase", ErrData: "ErrData", diff --git a/wtxmgr/interface.go b/wtxmgr/interface.go index 5017240941..08b90a8c78 100644 --- a/wtxmgr/interface.go +++ b/wtxmgr/interface.go @@ -148,6 +148,14 @@ type TxStore interface { FetchTxLabel(ns walletdb.ReadBucket, txid chainhash.Hash) (string, error) + // GetUtxo returns the credit for a given outpoint, if it is known to + // the store as a UTXO. It checks for mined (confirmed) UTXOs first, + // and then unmined (unconfirmed) credits. If the UTXO is not found, + // ErrUtxoNotFound is returned. This function does not determine if the + // UTXO is spent by an unmined transaction or locked. + GetUtxo(ns walletdb.ReadBucket, + outpoint wire.OutPoint) (*Credit, error) + // UnminedTxs returns the underlying transactions for all unmined // transactions which are not known to have been mined in a block. // Transactions are guaranteed to be sorted by their dependency order. diff --git a/wtxmgr/query.go b/wtxmgr/query.go index 383438870f..38cf9880c8 100644 --- a/wtxmgr/query.go +++ b/wtxmgr/query.go @@ -8,8 +8,10 @@ package wtxmgr import ( "fmt" + "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/walletdb" ) @@ -456,3 +458,123 @@ func (s *Store) PreviousPkScripts(ns walletdb.ReadBucket, rec *TxRecord, block * return pkScripts, nil } + +// getMinedUtxo constructs a Credit for a mined UTXO from the raw database +// value. +func (s *Store) getMinedUtxo(ns walletdb.ReadBucket, outpoint wire.OutPoint, + unspentVal []byte) (*Credit, error) { + + var block Block + err := readUnspentBlock(unspentVal, &block) + if err != nil { + return nil, err + } + + // We have the block, now fetch the full transaction record. + rec, err := fetchTxRecord(ns, &outpoint.Hash, &block) + if err != nil { + return nil, err + } + + // Ensure the output index is valid for the transaction. + if int(outpoint.Index) >= len(rec.MsgTx.TxOut) { + str := fmt.Sprintf("mined credit %v references "+ + "non-existent output index", outpoint) + return nil, storeError(ErrData, str, nil) + } + txOut := rec.MsgTx.TxOut[outpoint.Index] + + blockTime, err := fetchBlockTime(ns, block.Height) + if err != nil { + return nil, err + } + + credit := &Credit{ + OutPoint: outpoint, + BlockMeta: BlockMeta{ + Block: block, + Time: blockTime, + }, + Amount: btcutil.Amount(txOut.Value), + PkScript: txOut.PkScript, + Received: rec.Received, + FromCoinBase: blockchain.IsCoinBaseTx(&rec.MsgTx), + } + return credit, nil +} + +// getUnminedUtxo constructs a Credit for an unmined UTXO. +func (s *Store) getUnminedUtxo(ns walletdb.ReadBucket, + outpoint wire.OutPoint) (*Credit, error) { + + // The outpoint is an unmined credit. We need to fetch the + // full transaction to get all the credit details. + recVal := existsRawUnmined(ns, outpoint.Hash[:]) + if recVal == nil { + // This would indicate a store inconsistency. + str := fmt.Sprintf("unmined credit %v has no matching "+ + "unmined tx record", outpoint) + return nil, storeError(ErrData, str, nil) + } + + var rec TxRecord + err := readRawTxRecord(&outpoint.Hash, recVal, &rec) + if err != nil { + return nil, err + } + + // Ensure the output index is valid for the transaction. + if int(outpoint.Index) >= len(rec.MsgTx.TxOut) { + str := fmt.Sprintf("unmined credit %v references "+ + "non-existent output index", outpoint) + return nil, storeError(ErrData, str, nil) + } + txOut := rec.MsgTx.TxOut[outpoint.Index] + + credit := &Credit{ + OutPoint: outpoint, + BlockMeta: BlockMeta{ + Block: Block{Height: -1}, + }, + Amount: btcutil.Amount(txOut.Value), + PkScript: txOut.PkScript, + Received: rec.Received, + FromCoinBase: false, // Unmined can't be coinbase. + } + return credit, nil +} + +// TODO(yy): This method is inefficient as it requires multiple database +// lookups (unspent, tx records) to construct the full credit. This +// should be optimized by denormalizing the unspent bucket to include +// the amount and pkScript directly. +// +// TODO(yy): This function only confirms the existence of a UTXO but does +// not guarantee its spendability. A more comprehensive version should +// also check the 'unminedInputs' and 'lockedOutputs' buckets. +// +// GetUtxo returns the credit for a given outpoint, if it is known to the +// store as a UTXO. It checks for mined (confirmed) UTXOs first, and then +// unmined (unconfirmed) credits. If the UTXO is not found, ErrUtxoNotFound is +// returned. This function does not determine if the UTXO is spent by an +// unmined transaction or locked. +func (s *Store) GetUtxo(ns walletdb.ReadBucket, + outpoint wire.OutPoint) (*Credit, error) { + + k := canonicalOutPoint(&outpoint.Hash, outpoint.Index) + + // First, check if the UTXO is a mined and unspent credit. + unspentVal := ns.NestedReadBucket(bucketUnspent).Get(k) + if unspentVal != nil { + return s.getMinedUtxo(ns, outpoint, unspentVal) + } + + // If not found in mined, check if it's an unconfirmed credit. + v := existsRawUnminedCredit(ns, k) + if v != nil { + return s.getUnminedUtxo(ns, outpoint) + } + + // If not found in either bucket, it's not a known UTXO. + return nil, ErrUtxoNotFound +} diff --git a/wtxmgr/query_test.go b/wtxmgr/query_test.go index 2856af5e9a..367d57d423 100644 --- a/wtxmgr/query_test.go +++ b/wtxmgr/query_test.go @@ -16,6 +16,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/walletdb" + "github.com/stretchr/testify/require" ) type queryState struct { @@ -214,7 +215,8 @@ func equalTxs(got, exp *wire.MsgTx) error { // Returns time.Now() with seconds resolution, this is what Store saves. func timeNow() time.Time { - return time.Unix(time.Now().Unix(), 0) + // Truncate to the second to match the precision of the database. + return time.Now().Truncate(time.Second) } // Returns a copy of a TxRecord without the serialized tx. @@ -741,3 +743,65 @@ func TestPreviousPkScripts(t *testing.T) { t.Fatal("Failed after inserting tx D") } } + +// TestGetUtxo tests the GetUtxo method to ensure it correctly retrieves both +// mined and unmined UTXOs, and that it returns the expected error when a UTXO +// cannot be found. +func TestGetUtxo(t *testing.T) { + t.Parallel() + + s, db, err := testStore(t) + require.NoError(t, err) + defer db.Close() + + dbtx, err := db.BeginReadWriteTx() + require.NoError(t, err) + defer dbtx.Commit() + ns := dbtx.ReadWriteBucket(namespaceKey) + + // We'll start by querying for a UTXO that does not exist in the + // store. This should result in a ErrUtxoNotFound error. + op := wire.OutPoint{Hash: chainhash.Hash{}, Index: 0} + cred, err := s.GetUtxo(ns, op) + require.ErrorIs(t, err, ErrUtxoNotFound) + require.Nil(t, cred) + + // Now, we'll add a mined transaction and its credit to the store. This + // will serve as our confirmed UTXO. + b100 := makeBlockMeta(100) + txA := spendOutput(&chainhash.Hash{}, 0, 100e8) + recA, err := NewTxRecordFromMsgTx(txA, timeNow()) + require.NoError(t, err) + + err = s.InsertTx(ns, recA, &b100) + require.NoError(t, err) + err = s.AddCredit(ns, recA, &b100, 0, false) + require.NoError(t, err) + + // We should now be able to query for the mined UTXO and get back the + // correct credit details. + op = wire.OutPoint{Hash: recA.Hash, Index: 0} + cred, err = s.GetUtxo(ns, op) + require.NoError(t, err) + require.NotNil(t, cred) + require.Equal(t, op, cred.OutPoint) + + // We'll do the same for an unmined transaction and its credit. This + // will serve as our unconfirmed UTXO. + txB := spendOutput(&recA.Hash, 0, 50e8) + recB, err := NewTxRecordFromMsgTx(txB, timeNow()) + require.NoError(t, err) + + err = s.InsertTx(ns, recB, nil) + require.NoError(t, err) + err = s.AddCredit(ns, recB, nil, 0, false) + require.NoError(t, err) + + // We should now be able to query for the unmined UTXO and get back + // the correct credit details. + op = wire.OutPoint{Hash: recB.Hash, Index: 0} + cred, err = s.GetUtxo(ns, op) + require.NoError(t, err) + require.NotNil(t, cred) + require.Equal(t, op, cred.OutPoint) +} From 6c90551f61e0c417a299fa49eb37d7c6bfb6798a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 4 Sep 2025 18:42:22 +0800 Subject: [PATCH 081/691] wallet: implement `LeaseOutput` --- wallet/utxo_manager.go | 58 +++++++++++++++++++++++++++++++++++++ wallet/utxo_manager_test.go | 31 +++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 44f9c09263..e98f4397b9 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -373,3 +373,61 @@ func (w *Wallet) GetUtxo(_ context.Context, return utxo, err } + +// LeaseOutput locks an output for a given duration, preventing it from being +// used in transactions. +// +// This method allows a caller to reserve a specific UTXO for a certain period, +// making it unavailable for other operations like coin selection. This is +// useful in scenarios where a transaction is being built and its inputs need to +// be protected from being used by other concurrent operations. +// +// How it works: +// The method delegates the locking operation to the underlying transaction +// store (`wtxmgr`), which maintains a record of all leased outputs. The lease +// is identified by a unique `LockID` and has a specific `duration`. +// +// Logical Steps: +// 1. Initiate a read-write database transaction. +// 2. Call the `wtxmgr.LockOutput` method with the provided `LockID`, +// outpoint, and `duration`. +// 3. The `wtxmgr` checks if the output is known and not already locked by a +// different ID. +// 4. If the checks pass, it records the lock with an expiration time. +// 5. The expiration time is returned to the caller. +// +// Database Actions: +// - This method performs a single read-write database transaction +// (`walletdb.Update`). +// - It writes to the `wtxmgr` namespace to record the output lock. +// +// Time Complexity: +// - The complexity is O(1) as it involves a direct lookup and write in the +// database. +// +// TODO(yy): The current `wtxmgr.LockOutput` implementation does not check if +// the output is already spent by an unmined transaction. This could lead to a +// scenario where a spent output is leased. The implementation should be +// improved to perform this check. +// +// NOTE: This is part of the UtxoManager interface implementation. +func (w *Wallet) LeaseOutput(_ context.Context, id wtxmgr.LockID, + op wire.OutPoint, duration time.Duration) (time.Time, error) { + + var ( + expiration time.Time + err error + ) + + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + + var err error + + expiration, err = w.txStore.LockOutput(txmgrNs, id, op, duration) + + return err + }) + + return expiration, err +} \ No newline at end of file diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index a43fac350d..485c10ad8f 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -6,6 +6,7 @@ package wallet import ( "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -287,4 +288,32 @@ func TestGetUtxo_Err(t *testing.T) { utxo, err := w.GetUtxo(t.Context(), utxoNotFound) require.ErrorIs(t, err, wtxmgr.ErrUtxoNotFound) require.Nil(t, utxo) -} \ No newline at end of file +} + +// TestLeaseOutput tests the LeaseOutput method. +func TestLeaseOutput(t *testing.T) { + t.Parallel() + + // Create a new test wallet with mocks. + w, mocks := testWalletWithMocks(t) + + // Create a UTXO. + utxo := wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + } + + // Mock the LockOutput method to return a fixed expiration time. + expiration := time.Now().Add(time.Hour) + mocks.txStore.On("LockOutput", mock.Anything, mock.Anything, utxo, + mock.Anything).Return(expiration, nil) + + // Now, try to lease the output. + leaseID := wtxmgr.LockID{1} + leaseDuration := time.Hour + actualExpiration, err := w.LeaseOutput( + t.Context(), leaseID, utxo, leaseDuration, + ) + require.NoError(t, err) + require.Equal(t, expiration, actualExpiration) +} From 5e36beef98dffbe5f1d2ba8426f5d220d11948e5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 4 Sep 2025 18:45:03 +0800 Subject: [PATCH 082/691] wallet: implement `ReleaseOutput` --- wallet/utxo_manager.go | 46 ++++++++++++++++++++++++++++++++++++- wallet/utxo_manager_test.go | 24 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index e98f4397b9..8e79ec0b01 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -430,4 +430,48 @@ func (w *Wallet) LeaseOutput(_ context.Context, id wtxmgr.LockID, }) return expiration, err -} \ No newline at end of file +} + +// ReleaseOutput unlocks a previously leased output, making it available for +// use. +// +// This method allows a caller to manually release a lock on a UTXO before its +// expiration time. This is useful when a transaction-building process is +// aborted and the reserved inputs need to be returned to the pool of available +// UTXOs. +// +// How it works: +// The method delegates the unlocking operation to the underlying transaction +// store (`wtxmgr`), which removes the lock record for the specified outpoint. +// +// Logical Steps: +// 1. Initiate a read-write database transaction. +// 2. Call the `wtxmgr.UnlockOutput` method with the provided `LockID` and +// outpoint. +// 3. The `wtxmgr` verifies that the output is indeed locked by the same +// `LockID` before removing the lock. +// +// Database Actions: +// - This method performs a single read-write database transaction +// (`walletdb.Update`). +// - It deletes from the `wtxmgr` namespace to remove the output lock. +// +// Time Complexity: +// - The complexity is O(1) as it involves a direct lookup and delete in the +// database. +// +// TODO(yy): The current `wtxmgr.UnlockOutput` implementation does not validate +// that the `LockID` matches the one that currently holds the lock. This could +// allow any caller to unlock an output, which could be a potential security +// risk in a multi-user environment. The implementation should be improved to +// perform this check. +// +// NOTE: This is part of the UtxoManager interface implementation. +func (w *Wallet) ReleaseOutput(_ context.Context, id wtxmgr.LockID, + op wire.OutPoint) error { + + return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + return w.txStore.UnlockOutput(txmgrNs, id, op) + }) +} diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index 485c10ad8f..b622dd7200 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -317,3 +317,27 @@ func TestLeaseOutput(t *testing.T) { require.NoError(t, err) require.Equal(t, expiration, actualExpiration) } + +// TestReleaseOutput tests the ReleaseOutput method. +func TestReleaseOutput(t *testing.T) { + t.Parallel() + + // Create a new test wallet with mocks. + w, mocks := testWalletWithMocks(t) + + // Create a UTXO. + utxo := wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + } + + // Mock the UnlockOutput method to return nil. + mocks.txStore.On("UnlockOutput", + mock.Anything, mock.Anything, utxo, + ).Return(nil) + + // Now, try to release the output. + leaseID := wtxmgr.LockID{1} + err := w.ReleaseOutput(t.Context(), leaseID, utxo) + require.NoError(t, err) +} \ No newline at end of file From 2de5cdef4c8066348e82608d067d015aa35d4718 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 4 Sep 2025 18:47:18 +0800 Subject: [PATCH 083/691] wallet: implement `ListLeasedOutputs` --- wallet/utxo_manager.go | 52 ++++++++++++++++++++++++++++++++++++- wallet/utxo_manager_test.go | 31 +++++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 8e79ec0b01..2cd1f8e915 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -88,7 +88,7 @@ type UtxoManager interface { op wire.OutPoint) error // ListLeasedOutputs returns a list of all currently leased outputs. - ListLeasedOutputs(ctx context.Context) ([]*ListLeasedOutputResult, error) + ListLeasedOutputs(ctx context.Context) ([]*wtxmgr.LockedOutput, error) } // ListUnspent returns a slice of unspent transaction outputs that match the @@ -475,3 +475,53 @@ func (w *Wallet) ReleaseOutput(_ context.Context, id wtxmgr.LockID, return w.txStore.UnlockOutput(txmgrNs, id, op) }) } + +// ListLeasedOutputs returns a list of all currently leased outputs. +// +// This method provides a way to inspect which UTXOs are currently locked and +// when their leases expire. This can be useful for debugging and for managing +// long-lived locks. +// +// How it works: +// The method delegates the listing operation to the underlying transaction +// store (`wtxmgr`), which scans its record of all leased outputs. +// +// Logical Steps: +// 1. Initiate a read-only database transaction. +// 2. Call the `wtxmgr.ListLeasedOutputs` method. +// 3. The `wtxmgr` iterates through all the recorded locks and returns them +// as a slice. +// +// Database Actions: +// - This method performs a single read-only database transaction +// (`walletdb.View`). +// - It reads from the `wtxmgr` namespace to get the list of leased +// outputs. +// +// Time Complexity: +// - The complexity is O(L), where L is the number of leased outputs, as it +// involves a full scan of the leased outputs bucket. +// +// TODO(yy): The current `wtxmgr.ListLeasedOutputs` implementation returns a +// struct from the `wtxmgr` package. This is a leaky abstraction. The method +// should return a struct defined in the `wallet` package to maintain a clean +// separation of concerns. +// +// NOTE: This is part of the UtxoManager interface implementation. +func (w *Wallet) ListLeasedOutputs( + _ context.Context) ([]*wtxmgr.LockedOutput, error) { + + var ( + leasedOutputs []*wtxmgr.LockedOutput + err error + ) + + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + leasedOutputs, err = w.txStore.ListLockedOutputs(txmgrNs) + + return err + }) + + return leasedOutputs, err +} diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index b622dd7200..26e66ccdd1 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -340,4 +340,33 @@ func TestReleaseOutput(t *testing.T) { leaseID := wtxmgr.LockID{1} err := w.ReleaseOutput(t.Context(), leaseID, utxo) require.NoError(t, err) -} \ No newline at end of file +} + +// TestListLeasedOutputs tests the ListLeasedOutputs method. +func TestListLeasedOutputs(t *testing.T) { + t.Parallel() + + // Create a new test wallet with mocks. + w, mocks := testWalletWithMocks(t) + + // Create a leased output. + leasedOutput := &wtxmgr.LockedOutput{ + Outpoint: wire.OutPoint{ + Hash: [32]byte{1}, + Index: 0, + }, + LockID: wtxmgr.LockID{1}, + Expiration: time.Now().Add(time.Hour), + } + + // Mock the ListLockedOutputs method to return the leased output. + mocks.txStore.On("ListLockedOutputs", mock.Anything).Return( + []*wtxmgr.LockedOutput{leasedOutput}, nil, + ) + + // Now, try to list the leased outputs. + leasedOutputs, err := w.ListLeasedOutputs(t.Context()) + require.NoError(t, err) + require.Len(t, leasedOutputs, 1) + require.Equal(t, leasedOutput, leasedOutputs[0]) +} From 8f59a0845506c15a772f345f1423cc0c306c9a1f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 9 Sep 2025 15:15:13 +0800 Subject: [PATCH 084/691] wallet: use `t.Context` in the tests --- wallet/account_manager_test.go | 82 ++++++++++++++++------------------ wallet/address_manager_test.go | 37 +++++++-------- 2 files changed, 56 insertions(+), 63 deletions(-) diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 4a087376ed..7ce0203d47 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -5,7 +5,6 @@ package wallet import ( - "context" "testing" "time" @@ -37,7 +36,7 @@ func TestNewAccount(t *testing.T) { // expect this to succeed. scope := waddrmgr.KeyScopeBIP0084 account, err := w.NewAccount( - context.Background(), scope, testAccountName, + t.Context(), scope, testAccountName, ) require.NoError(t, err, "unable to create new account") @@ -50,7 +49,7 @@ func TestNewAccount(t *testing.T) { require.NoError(t, err, "unable to retrieve account") // We should not be able to create a new account with the same name. - _, err = w.NewAccount(context.Background(), scope, testAccountName) + _, err = w.NewAccount(t.Context(), scope, testAccountName) require.Error(t, err, "expected error when creating duplicate account") // We should not be able to create a new account when the wallet is @@ -58,7 +57,7 @@ func TestNewAccount(t *testing.T) { err = w.addrStore.Lock() require.NoError(t, err) - _, err = w.NewAccount(context.Background(), scope, "test2") + _, err = w.NewAccount(t.Context(), scope, "test2") require.Error( t, err, "expected error when creating account while wallet is "+ "locked", @@ -75,12 +74,12 @@ func TestListAccounts(t *testing.T) { // We'll start by creating a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 - _, err := w.NewAccount(context.Background(), scope, testAccountName) + _, err := w.NewAccount(t.Context(), scope, testAccountName) require.NoError(t, err, "unable to create new account") // Now, we'll list all accounts and check that we have the default // account and the new account. - accounts, err := w.ListAccounts(context.Background()) + accounts, err := w.ListAccounts(t.Context()) require.NoError(t, err, "unable to list accounts") // We should have five accounts, the four default accounts and the new @@ -134,19 +133,19 @@ func TestListAccountsByScope(t *testing.T) { // under the BIP0049 scope. scopeBIP84 := waddrmgr.KeyScopeBIP0084 accBIP84Name := "test bip84" - _, err := w.NewAccount(context.Background(), scopeBIP84, accBIP84Name) + _, err := w.NewAccount(t.Context(), scopeBIP84, accBIP84Name) require.NoError(t, err) scopeBIP49 := waddrmgr.KeyScopeBIP0049Plus accBIP49Name := "test bip49" - _, err = w.NewAccount(context.Background(), scopeBIP49, accBIP49Name) + _, err = w.NewAccount(t.Context(), scopeBIP49, accBIP49Name) require.NoError(t, err) // Now, we'll list the accounts for the BIP0084 scope and check that // we only get the default account for that scope and the new account we // created. accountsBIP84, err := w.ListAccountsByScope( - context.Background(), scopeBIP84, + t.Context(), scopeBIP84, ) require.NoError(t, err) @@ -163,7 +162,7 @@ func TestListAccountsByScope(t *testing.T) { // Now, we'll do the same for the BIP0049 scope. accountsBIP49, err := w.ListAccountsByScope( - context.Background(), scopeBIP49, + t.Context(), scopeBIP49, ) require.NoError(t, err) @@ -192,19 +191,19 @@ func TestListAccountsByName(t *testing.T) { // under the BIP0049 scope. scopeBIP84 := waddrmgr.KeyScopeBIP0084 accBIP84Name := "test bip84" - _, err := w.NewAccount(context.Background(), scopeBIP84, accBIP84Name) + _, err := w.NewAccount(t.Context(), scopeBIP84, accBIP84Name) require.NoError(t, err) scopeBIP49 := waddrmgr.KeyScopeBIP0049Plus accBIP49Name := "test bip49" - _, err = w.NewAccount(context.Background(), scopeBIP49, accBIP49Name) + _, err = w.NewAccount(t.Context(), scopeBIP49, accBIP49Name) require.NoError(t, err) // Now, we'll list the accounts for the BIP0084 scope and check that // we only get the default account for that scope and the new account we // created. accountsBIP84, err := w.ListAccountsByName( - context.Background(), accBIP84Name, + t.Context(), accBIP84Name, ) require.NoError(t, err) @@ -217,7 +216,7 @@ func TestListAccountsByName(t *testing.T) { // Now, we'll do the same for the BIP0049 scope. accountsBIP49, err := w.ListAccountsByName( - context.Background(), accBIP49Name, + t.Context(), accBIP49Name, ) require.NoError(t, err) @@ -231,7 +230,7 @@ func TestListAccountsByName(t *testing.T) { // We should get an empty result if we query for a non-existent // account. accounts, err := w.ListAccountsByName( - context.Background(), "non-existent", + t.Context(), "non-existent", ) require.NoError(t, err) require.Empty(t, accounts.Accounts) @@ -247,27 +246,25 @@ func TestGetAccount(t *testing.T) { // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 - _, err := w.NewAccount(context.Background(), scope, testAccountName) + _, err := w.NewAccount(t.Context(), scope, testAccountName) require.NoError(t, err) // We should be able to get the new account. - account, err := w.GetAccount( - context.Background(), scope, testAccountName, - ) + account, err := w.GetAccount(t.Context(), scope, testAccountName) require.NoError(t, err) require.Equal(t, testAccountName, account.AccountName) require.Equal(t, uint32(1), account.AccountNumber) require.Equal(t, btcutil.Amount(0), account.TotalBalance) // We should also be able to get the default account. - account, err = w.GetAccount(context.Background(), scope, "default") + account, err = w.GetAccount(t.Context(), scope, "default") require.NoError(t, err) require.Equal(t, "default", account.AccountName) require.Equal(t, uint32(0), account.AccountNumber) require.Equal(t, btcutil.Amount(0), account.TotalBalance) // We should get an error when trying to get a non-existent account. - _, err = w.GetAccount(context.Background(), scope, "non-existent") + _, err = w.GetAccount(t.Context(), scope, "non-existent") require.Error(t, err) require.True( t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), @@ -287,20 +284,20 @@ func TestRenameAccount(t *testing.T) { scope := waddrmgr.KeyScopeBIP0084 oldName := "old name" newName := "new name" - _, err := w.NewAccount(context.Background(), scope, oldName) + _, err := w.NewAccount(t.Context(), scope, oldName) require.NoError(t, err) // We should be able to rename the account. - err = w.RenameAccount(context.Background(), scope, oldName, newName) + err = w.RenameAccount(t.Context(), scope, oldName, newName) require.NoError(t, err) // We should be able to get the account by its new name. - account, err := w.GetAccount(context.Background(), scope, newName) + account, err := w.GetAccount(t.Context(), scope, newName) require.NoError(t, err) require.Equal(t, newName, account.AccountName) // We should not be able to get the account by its old name. - _, err = w.GetAccount(context.Background(), scope, oldName) + _, err = w.GetAccount(t.Context(), scope, oldName) require.Error(t, err) require.True( t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), @@ -308,7 +305,7 @@ func TestRenameAccount(t *testing.T) { ) // We should not be able to rename an account to an existing name. - err = w.RenameAccount(context.Background(), scope, newName, "default") + err = w.RenameAccount(t.Context(), scope, newName, "default") require.Error(t, err) require.True( t, waddrmgr.IsError(err, waddrmgr.ErrDuplicateAccount), @@ -317,7 +314,7 @@ func TestRenameAccount(t *testing.T) { // We should not be able to rename a non-existent account. err = w.RenameAccount( - context.Background(), scope, "non-existent", "new name 2", + t.Context(), scope, "non-existent", "new name 2", ) require.Error(t, err) require.True( @@ -336,20 +333,19 @@ func TestBalance(t *testing.T) { // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 - _, err := w.NewAccount(context.Background(), scope, testAccountName) + _, err := w.NewAccount(t.Context(), scope, testAccountName) require.NoError(t, err) // The balance should be zero initially. balance, err := w.Balance( - context.Background(), 1, scope, testAccountName, + t.Context(), 1, scope, testAccountName, ) require.NoError(t, err) require.Equal(t, btcutil.Amount(0), balance) // Now, we'll add a UTXO to the account. addr, err := w.NewAddress( - context.Background(), testAccountName, - waddrmgr.WitnessPubKey, false, + t.Context(), testAccountName, waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) @@ -399,14 +395,14 @@ func TestBalance(t *testing.T) { // The balance should now be 100. balance, err = w.Balance( - context.Background(), 1, scope, testAccountName, + t.Context(), 1, scope, testAccountName, ) require.NoError(t, err) require.Equal(t, btcutil.Amount(100), balance) // We should get an error when trying to get the balance of a // non-existent account. - _, err = w.Balance(context.Background(), 1, scope, "non-existent") + _, err = w.Balance(t.Context(), 1, scope, "non-existent") require.Error(t, err) require.True( t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), @@ -434,19 +430,19 @@ func TestImportAccount(t *testing.T) { // We should be able to import the account. props, err := w.ImportAccount( - context.Background(), testAccountName, acctPubKey, + t.Context(), testAccountName, acctPubKey, root.ParentFingerprint(), addrType, false, ) require.NoError(t, err) require.Equal(t, testAccountName, props.AccountName) // We should be able to get the account by its name. - _, err = w.GetAccount(context.Background(), scope, testAccountName) + _, err = w.GetAccount(t.Context(), scope, testAccountName) require.NoError(t, err) // We should not be able to import an account with the same name. _, err = w.ImportAccount( - context.Background(), testAccountName, acctPubKey, + t.Context(), testAccountName, acctPubKey, root.ParentFingerprint(), addrType, false, ) require.Error(t, err) @@ -458,13 +454,13 @@ func TestImportAccount(t *testing.T) { // We should be able to do a dry run of the import. dryRunName := "dry run" _, err = w.ImportAccount( - context.Background(), dryRunName, acctPubKey, + t.Context(), dryRunName, acctPubKey, root.ParentFingerprint(), addrType, true, ) require.NoError(t, err) // The account should not have been imported. - _, err = w.GetAccount(context.Background(), scope, dryRunName) + _, err = w.GetAccount(t.Context(), scope, dryRunName) require.Error(t, err) require.True( t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), @@ -589,7 +585,7 @@ func addTestUTXOForBalance(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, } addr, err := w.NewAddress( - context.Background(), accountName, addrType, false, + t.Context(), accountName, addrType, false, ) require.NoError(t, err) @@ -664,7 +660,7 @@ func TestFetchAccountBalances(t *testing.T) { t.Helper() w, cleanup := testWallet(t) - ctx := context.Background() + ctx := t.Context() // Create accounts. _, err := w.NewAccount( @@ -731,7 +727,7 @@ func TestFetchAccountBalances(t *testing.T) { t.Helper() _, err := w.NewAccount( - context.Background(), + t.Context(), waddrmgr.KeyScopeBIP0084, "no-balance", ) require.NoError(t, err) @@ -787,11 +783,11 @@ func TestListAccountsWithBalances(t *testing.T) { // predictable state. scope := waddrmgr.KeyScopeBIP0084 acc1Name := "test account" - _, err := w.NewAccount(context.Background(), scope, acc1Name) + _, err := w.NewAccount(t.Context(), scope, acc1Name) require.NoError(t, err) acc2Name := "no balance account" - _, err = w.NewAccount(context.Background(), scope, acc2Name) + _, err = w.NewAccount(t.Context(), scope, acc2Name) require.NoError(t, err) // We'll now create a balance map for some of the accounts. We diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 68a3f24a86..3973bee66d 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -5,7 +5,6 @@ package wallet import ( - "context" "testing" "time" @@ -143,7 +142,7 @@ func TestNewAddress(t *testing.T) { // Attempt to generate a new address with the specified // parameters. addr, err := w.NewAddress( - context.Background(), tc.accountName, + t.Context(), tc.accountName, tc.addrType, tc.change, ) @@ -164,9 +163,7 @@ func TestNewAddress(t *testing.T) { // Verify that the address is correctly marked as // internal or external. - addrInfo, err := w.AddressInfo( - context.Background(), addr, - ) + addrInfo, err := w.AddressInfo(t.Context(), addr) require.NoError(t, err) require.Equal(t, tc.change, addrInfo.Internal()) }) @@ -184,13 +181,13 @@ func TestGetUnusedAddress(t *testing.T) { // Get a new address to start with. addr, err := w.NewAddress( - context.Background(), "default", waddrmgr.WitnessPubKey, false, + t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) // The first unused address should be the one we just created. unusedAddr, err := w.GetUnusedAddress( - context.Background(), "default", waddrmgr.WitnessPubKey, false, + t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) require.Equal(t, addr.String(), unusedAddr.String()) @@ -235,7 +232,7 @@ func TestGetUnusedAddress(t *testing.T) { // Get the next unused address. nextAddr, err := w.GetUnusedAddress( - context.Background(), "default", waddrmgr.WitnessPubKey, false, + t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) @@ -244,13 +241,13 @@ func TestGetUnusedAddress(t *testing.T) { // Now, let's test the change address. changeAddr, err := w.NewAddress( - context.Background(), "default", waddrmgr.WitnessPubKey, true, + t.Context(), "default", waddrmgr.WitnessPubKey, true, ) require.NoError(t, err) // The first unused change address should be the one we just created. unusedChangeAddr, err := w.GetUnusedAddress( - context.Background(), "default", waddrmgr.WitnessPubKey, true, + t.Context(), "default", waddrmgr.WitnessPubKey, true, ) require.NoError(t, err) require.Equal(t, changeAddr.String(), unusedChangeAddr.String()) @@ -267,12 +264,12 @@ func TestAddressInfo(t *testing.T) { // Get a new external address to test with. extAddr, err := w.NewAddress( - context.Background(), "default", waddrmgr.WitnessPubKey, false, + t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) // Get the address info for the external address. - extInfo, err := w.AddressInfo(context.Background(), extAddr) + extInfo, err := w.AddressInfo(t.Context(), extAddr) require.NoError(t, err) // Check the external address info. @@ -284,12 +281,12 @@ func TestAddressInfo(t *testing.T) { // Get a new internal address to test with. intAddr, err := w.NewAddress( - context.Background(), "default", waddrmgr.WitnessPubKey, true, + t.Context(), "default", waddrmgr.WitnessPubKey, true, ) require.NoError(t, err) // Get the address info for the internal address. - intInfo, err := w.AddressInfo(context.Background(), intAddr) + intInfo, err := w.AddressInfo(t.Context(), intAddr) require.NoError(t, err) // Check the internal address info. @@ -311,7 +308,7 @@ func TestListAddresses(t *testing.T) { // Get a new address and give it a balance. addr, err := w.NewAddress( - context.Background(), "default", waddrmgr.WitnessPubKey, false, + t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) @@ -355,7 +352,7 @@ func TestListAddresses(t *testing.T) { // List the addresses for the default account. addrs, err := w.ListAddresses( - context.Background(), "default", waddrmgr.WitnessPubKey, + t.Context(), "default", waddrmgr.WitnessPubKey, ) require.NoError(t, err) @@ -382,7 +379,7 @@ func TestImportPublicKey(t *testing.T) { // Import the public key. err = w.ImportPublicKey( - context.Background(), pubKey, waddrmgr.WitnessPubKey, + t.Context(), pubKey, waddrmgr.WitnessPubKey, ) require.NoError(t, err) @@ -428,7 +425,7 @@ func TestImportTaprootScript(t *testing.T) { } // Import the tapscript. - _, err = w.ImportTaprootScript(context.Background(), tapscript) + _, err = w.ImportTaprootScript(t.Context(), tapscript) require.NoError(t, err) // Check that the address is now managed by the wallet. @@ -454,7 +451,7 @@ func TestScriptForOutput(t *testing.T) { // Create a new p2wkh address and output. addr, err := w.NewAddress( - context.Background(), "default", waddrmgr.WitnessPubKey, false, + t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) @@ -466,7 +463,7 @@ func TestScriptForOutput(t *testing.T) { } // Get the script for the output. - script, err := w.ScriptForOutput(context.Background(), output) + script, err := w.ScriptForOutput(t.Context(), output) require.NoError(t, err) // Check that the script is correct. From 8facbe7973ab281d760f1640d69eb1d0d8ac9c40 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 9 Sep 2025 19:51:21 +0800 Subject: [PATCH 085/691] multi: fix linter errors --- .golangci.yml | 8 ++++++++ waddrmgr/interface.go | 21 ++++++++++++--------- wallet/import_test.go | 1 + wallet/interface.go | 21 +++++++++++---------- wallet/mock_test.go | 24 +++++++++++++++++------- wallet/utxo_manager.go | 13 +++++++------ wallet/wallet.go | 30 +++++++++++++++++++----------- wallet/wallet_test.go | 5 +++-- 8 files changed, 78 insertions(+), 45 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 3155fed1f9..76e41e7659 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -83,6 +83,14 @@ linters: multi-func: true multi-if: true + gomoddirectives: + replace-local: true + replace-allow-list: + # This package will be downgrade to internal so we will import it + # directly here. + - github.com/btcsuite/btcwallet/wtxmgr + + # Defines a set of rules to ignore issues. # It does not skip the analysis, and so does not ignore "typecheck" errors. exclusions: diff --git a/waddrmgr/interface.go b/waddrmgr/interface.go index 079dc09fb1..64aef555cc 100644 --- a/waddrmgr/interface.go +++ b/waddrmgr/interface.go @@ -80,8 +80,8 @@ type AddrStore interface { // recently-seen block described by the blockstamp. SetSyncedTo(ns walletdb.ReadWriteBucket, bs *BlockStamp) error - // SetBirthdayBlock sets the birthday block, or earliest time a key could - // have been used, for the manager. + // SetBirthdayBlock sets the birthday block, or earliest time a key + // could have been used, for the manager. SetBirthdayBlock(ns walletdb.ReadWriteBucket, block BlockStamp, verified bool) error @@ -153,7 +153,8 @@ type AddrStore interface { // NewScopedKeyManager creates a new scoped key manager from the root // manager. NewScopedKeyManager(ns walletdb.ReadWriteBucket, - scope KeyScope, addrSchema ScopeAddrSchema) (AccountStore, error) + scope KeyScope, + addrSchema ScopeAddrSchema) (AccountStore, error) // SetBirthday sets the birthday of the address store. SetBirthday(ns walletdb.ReadWriteBucket, birthday time.Time) error @@ -168,13 +169,13 @@ type AddrStore interface { LookupAccount(ns walletdb.ReadBucket, name string) (KeyScope, uint32, error) - // ForEachActiveAddress calls the given function with each active address - // stored in the manager, breaking early on error. + // ForEachActiveAddress calls the given function with each active + // address stored in the manager, breaking early on error. ForEachActiveAddress(ns walletdb.ReadBucket, fn func(addr btcutil.Address) error) error - // ConvertToWatchingOnly converts the current address manager to a locked - // watching-only address manager. + // ConvertToWatchingOnly converts the current address manager to a + // locked watching-only address manager. ConvertToWatchingOnly(ns walletdb.ReadWriteBucket) error // ChainParams returns the chain parameters for this address manager. @@ -217,11 +218,13 @@ type AccountStore interface { // AccountName returns the name of an account. AccountName(ns walletdb.ReadBucket, account uint32) (string, error) - // ExtendExternalAddresses extends the external addresses for an account. + // ExtendExternalAddresses extends the external addresses for an + // account. ExtendExternalAddresses(ns walletdb.ReadWriteBucket, account uint32, count uint32) error - // ExtendInternalAddresses extends the internal addresses for an account. + // ExtendInternalAddresses extends the internal addresses for an + // account. ExtendInternalAddresses(ns walletdb.ReadWriteBucket, account uint32, count uint32) error diff --git a/wallet/import_test.go b/wallet/import_test.go index 68cbbe9878..94f5ea7ba6 100644 --- a/wallet/import_test.go +++ b/wallet/import_test.go @@ -71,6 +71,7 @@ type testCase struct { } var ( + //nolint:lll testCases = []*testCase{{ name: "bip44 with nested witness address type", masterPriv: "tprv8ZgxMBicQKsPeWwrFuNjEGTTDSY4mRLwd2KDJAPGa1AY" + diff --git a/wallet/interface.go b/wallet/interface.go index 0042e3852b..a45be9d6b7 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -208,8 +208,8 @@ type Interface interface { CalculateAccountBalances(account uint32, requiredConfirmations int32) ( Balances, error) - // ListUnspentDeprecated returns all unspent transaction outputs for a given - // account and confirmation requirement. + // ListUnspentDeprecated returns all unspent transaction outputs for a + // given account and confirmation requirement. // // Deprecated: Use UtxoManager.ListUnspent instead. ListUnspentDeprecated(minconf, maxconf int32, accountName string) ( @@ -233,10 +233,10 @@ type Interface interface { // and should not be used as an input for created transactions. LockedOutpoint(op wire.OutPoint) bool - // LeaseOutputDeprecated locks an output to the given ID, preventing it from - // being available for coin selection. The absolute time of the lock's - // expiration is returned. The expiration of the lock can be extended by - // successive invocations of this call. + // LeaseOutputDeprecated locks an output to the given ID, preventing it + // from being available for coin selection. The absolute time of the + // lock's expiration is returned. The expiration of the lock can be + // extended by successive invocations of this call. // // Outputs can be unlocked before their expiration through // `UnlockOutput`. Otherwise, they are unlocked lazily through calls @@ -254,14 +254,15 @@ type Interface interface { LeaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint, duration time.Duration) (time.Time, error) - // ReleaseOutputDeprecated unlocks an output, allowing it to be available for - // coin selection if it remains unspent. The ID should match the one - // used to originally lock the output. + // ReleaseOutputDeprecated unlocks an output, allowing it to be + // available for coin selection if it remains unspent. The ID should + // match the one used to originally lock the output. // // Deprecated: Use UtxoManager.ReleaseOutput instead. ReleaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint) error - // ListLeasedOutputsDeprecated returns a list of all currently leased outputs. + // ListLeasedOutputsDeprecated returns a list of all currently leased + // outputs. // // Deprecated: Use UtxoManager.ListLeasedOutputs instead. ListLeasedOutputsDeprecated() ([]*ListLeasedOutputResult, error) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index abe7656dfd..766fcc43d7 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -122,7 +122,9 @@ func (m *mockTxStore) RangeTransactions(ns walletdb.ReadBucket, begin, } // Rollback implements the wtxmgr.TxStore interface. -func (m *mockTxStore) Rollback(ns walletdb.ReadWriteBucket, height int32) error { +func (m *mockTxStore) Rollback( + ns walletdb.ReadWriteBucket, height int32) error { + args := m.Called(ns, height) return args.Error(0) } @@ -400,7 +402,9 @@ func (m *mockAddrStore) LookupAccount(ns walletdb.ReadBucket, name string) (waddrmgr.KeyScope, uint32, error) { args := m.Called(ns, name) - return args.Get(0).(waddrmgr.KeyScope), args.Get(1).(uint32), args.Error(2) + + return args.Get(0).(waddrmgr.KeyScope), + args.Get(1).(uint32), args.Error(2) } // ForEachActiveAddress calls the given function with each active address @@ -663,7 +667,8 @@ func (m *mockAccountStore) NewRawAccount(ns walletdb.ReadWriteBucket, } // NewRawAccountWatchingOnly implements the waddrmgr.AccountStore interface. -func (m *mockAccountStore) NewRawAccountWatchingOnly(ns walletdb.ReadWriteBucket, +func (m *mockAccountStore) NewRawAccountWatchingOnly( + ns walletdb.ReadWriteBucket, number uint32, pubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, addrSchema *waddrmgr.ScopeAddrSchema) error { @@ -673,8 +678,9 @@ func (m *mockAccountStore) NewRawAccountWatchingOnly(ns walletdb.ReadWriteBucket } // ImportScript implements the waddrmgr.AccountStore interface. -func (m *mockAccountStore) ImportScript(ns walletdb.ReadWriteBucket, - script []byte, bs *waddrmgr.BlockStamp) (waddrmgr.ManagedScriptAddress, error) { +func (m *mockAccountStore) ImportScript( + ns walletdb.ReadWriteBucket, script []byte, + bs *waddrmgr.BlockStamp) (waddrmgr.ManagedScriptAddress, error) { args := m.Called(ns, script, bs) return args.Get(0).(waddrmgr.ManagedScriptAddress), args.Error(1) @@ -739,7 +745,11 @@ func (m *mockManagedAddress) InternalAccount() uint32 { } // DerivationInfo implements the waddrmgr.ManagedAddress interface. -func (m *mockManagedAddress) DerivationInfo() (waddrmgr.KeyScope, waddrmgr.DerivationPath, bool) { +func (m *mockManagedAddress) DerivationInfo() ( + waddrmgr.KeyScope, waddrmgr.DerivationPath, bool) { + args := m.Called() - return args.Get(0).(waddrmgr.KeyScope), args.Get(1).(waddrmgr.DerivationPath), args.Bool(2) + + return args.Get(0).(waddrmgr.KeyScope), + args.Get(1).(waddrmgr.DerivationPath), args.Bool(2) } diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 2cd1f8e915..8874d8baf5 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -181,8 +181,8 @@ func (w *Wallet) ListUnspent(_ context.Context, // Iterate through each UTXO to apply filters and enrich it with // address-specific details. for _, output := range unspent { - // Calculate the current confirmation status based on the - // wallet's synced block height. + // Calculate the current confirmation status based on + // the wallet's synced block height. confs := int32(0) if output.Height != -1 { confs = currentHeight - output.Height @@ -198,7 +198,8 @@ func (w *Wallet) ListUnspent(_ context.Context, continue } - // Extract the address from the UTXO's public key script. + // Extract the address from the UTXO's public key + // script. // For multi-address scripts, the first address is used. addr := extractAddrFromPKScript( output.PkScript, w.chainParams, @@ -422,9 +423,9 @@ func (w *Wallet) LeaseOutput(_ context.Context, id wtxmgr.LockID, err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) - var err error - - expiration, err = w.txStore.LockOutput(txmgrNs, id, op, duration) + expiration, err = w.txStore.LockOutput( + txmgrNs, id, op, duration, + ) return err }) diff --git a/wallet/wallet.go b/wallet/wallet.go index d293aa03e0..4b87047d67 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -9,7 +9,7 @@ // // TODO(yy): bring wrapcheck back when implementing the `Store` interface. // -//nolint:wrapcheck +//nolint:wrapcheck,cyclop,gocognit package wallet import ( @@ -1701,6 +1701,7 @@ func (w *Wallet) CalculateBalance(confirms int32) (btcutil.Amount, error) { blk := w.addrStore.SyncedTo() balance, err = w.txStore.Balance(txmgrNs, confirms, blk.Height) + return err }) return balance, err @@ -2756,8 +2757,9 @@ func (s creditSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } -// ListUnspentDeprecated returns a slice of objects representing the unspent wallet -// transactions fitting the given criteria. The confirmations will be more than +// ListUnspentDeprecated returns a slice of objects representing the +// unspent wallet transactions fitting the given criteria. The confirmations +// will be more than // minconf, less than maxconf and if addresses is populated only the addresses // contained within it will be considered. If we know nothing about a // transaction an empty array will be returned. @@ -2910,11 +2912,13 @@ type ListLeasedOutputResult struct { PkScript []byte } -// ListLeasedOutputsDeprecated returns a list of objects representing the currently locked -// utxos. +// ListLeasedOutputsDeprecated returns a list of objects representing the +// currently locked utxos. // // Deprecated: Use UtxoManager.ListLeasedOutputs instead. -func (w *Wallet) ListLeasedOutputsDeprecated() ([]*ListLeasedOutputResult, error) { +func (w *Wallet) ListLeasedOutputsDeprecated() ( + []*ListLeasedOutputResult, error) { + var results []*ListLeasedOutputResult err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(wtxmgrNamespaceKey) @@ -3077,8 +3081,9 @@ func (w *Wallet) LockedOutpoints() []btcjson.TransactionInput { return locked } -// LeaseOutputDeprecated locks an output to the given ID, preventing it from being -// available for coin selection. The absolute time of the lock's expiration is +// LeaseOutputDeprecated locks an output to the given ID, preventing it from +// being available for coin selection. The absolute time of the lock's +// expiration is // returned. The expiration of the lock can be extended by successive // invocations of this call. // @@ -3108,12 +3113,14 @@ func (w *Wallet) LeaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint, return expiry, err } -// ReleaseOutputDeprecated unlocks an output, allowing it to be available for coin -// selection if it remains unspent. The ID should match the one used to +// ReleaseOutputDeprecated unlocks an output, allowing it to be available for +// coin selection if it remains unspent. The ID should match the one used to // originally lock the output. // // Deprecated: Use UtxoManager.ReleaseOutput instead. -func (w *Wallet) ReleaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint) error { +func (w *Wallet) ReleaseOutputDeprecated( + id wtxmgr.LockID, op wire.OutPoint) error { + return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) return w.txStore.UnlockOutput(ns, id, op) @@ -4083,6 +4090,7 @@ func (w *Wallet) DeriveFromKeyPath(scope waddrmgr.KeyScope, } privKey, err = mpka.PrivKey() + return err }) if err != nil { diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 512afdc53c..11f5b44bec 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -241,7 +241,8 @@ func TestGetTransaction(t *testing.T) { expectedHeight int32 // Store function. - f func(wtxmgr.TxStore, walletdb.ReadWriteBucket) (wtxmgr.TxStore, error) + f func(wtxmgr.TxStore, + walletdb.ReadWriteBucket) (wtxmgr.TxStore, error) // The error we expect to be returned. expectedErr error @@ -276,7 +277,7 @@ func TestGetTransaction(t *testing.T) { name: "non-existing transaction", txid: *TstTxHash, // Write no txdetail to disk. - f: func(s wtxmgr.TxStore, ns walletdb.ReadWriteBucket) ( + f: func(s wtxmgr.TxStore, _ walletdb.ReadWriteBucket) ( wtxmgr.TxStore, error) { return s, nil From 334906f3ad6a8202bfb729c516e11ea697cab003 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Sep 2025 18:15:47 +0800 Subject: [PATCH 086/691] wallet: defer wallet start to caller The `createNewWallet` function should only be responsible for creating a new wallet instance, not starting its goroutines. By removing the call to `w.Start()`, we give the caller explicit control over the wallet's lifecycle. This change improves modularity and is particularly useful in testing scenarios where mocks or other configurations need to be set up on the wallet instance before it begins executing background tasks. The test functions have been updated to explicitly start the wallet after creation. --- wallet/example_test.go | 8 ++++++++ wallet/loader.go | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/wallet/example_test.go b/wallet/example_test.go index 0f7ff731c4..59e8129185 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -36,8 +36,13 @@ func testWallet(t *testing.T) (*Wallet, func()) { if err != nil { t.Fatalf("unable to create wallet: %v", err) } + chainClient := &mockChainClient{} w.chainClient = chainClient + + // Start the wallet. + w.Start() + if err := w.Unlock(privPass, time.After(10*time.Minute)); err != nil { t.Fatalf("unable to unlock wallet: %v", err) } @@ -85,6 +90,9 @@ func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { w.txStore = txStore w.addrStore = addrStore + // Start the wallet. + w.Start() + err = w.Unlock(privPass, time.After(60*time.Minute)) require.NoError(t, err) diff --git a/wallet/loader.go b/wallet/loader.go index 6e2f5fc3df..792417ae8c 100644 --- a/wallet/loader.go +++ b/wallet/loader.go @@ -295,7 +295,6 @@ func (l *Loader) createNewWallet(pubPassphrase, privPassphrase []byte, if err != nil { return nil, err } - w.Start() l.onLoaded(w) return w, nil From 05121b21513bcabf75f6884ba0aced654445d3f8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 18 Sep 2025 20:33:17 +0800 Subject: [PATCH 087/691] wallet: add wallet cleanup in `t.Cleanup` --- wallet/account_manager_test.go | 39 ++++++++++++-------------------- wallet/address_manager_test.go | 21 ++++++----------- wallet/benchmark_helpers_test.go | 3 +-- wallet/createtx_test.go | 12 ++++------ wallet/example_test.go | 22 ++++++++++++++---- wallet/import_test.go | 6 ++--- wallet/psbt_test.go | 6 ++--- wallet/signer_test.go | 5 ++-- wallet/utxos_test.go | 12 ++++------ wallet/wallet_test.go | 21 +++++------------ 10 files changed, 60 insertions(+), 87 deletions(-) diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 7ce0203d47..02d3252fae 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -29,8 +29,7 @@ func TestNewAccount(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // We'll start by creating a new account under the BIP0084 scope. We // expect this to succeed. @@ -69,8 +68,7 @@ func TestListAccounts(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // We'll start by creating a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -126,8 +124,7 @@ func TestListAccountsByScope(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // We'll create two new accounts, one under the BIP0084 scope and one // under the BIP0049 scope. @@ -184,8 +181,7 @@ func TestListAccountsByName(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // We'll create two new accounts, one under the BIP0084 scope and one // under the BIP0049 scope. @@ -241,8 +237,7 @@ func TestGetAccount(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -277,8 +272,7 @@ func TestRenameAccount(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -328,8 +322,7 @@ func TestBalance(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -415,8 +408,7 @@ func TestImportAccount(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // We'll start by creating a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -473,8 +465,7 @@ func TestImportAccount(t *testing.T) { func TestExtractAddrFromPKScript(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) w.chainParams = &chaincfg.MainNetParams @@ -656,10 +647,10 @@ func TestFetchAccountBalances(t *testing.T) { // The function returns the fully initialized wallet and a cleanup // function that should be deferred by the caller to ensure that the // wallet's resources are properly released after the test completes. - setupTestCase := func(t *testing.T) (*Wallet, func()) { + setupTestCase := func(t *testing.T) *Wallet { t.Helper() - w, cleanup := testWallet(t) + w := testWallet(t) ctx := t.Context() // Create accounts. @@ -695,7 +686,7 @@ func TestFetchAccountBalances(t *testing.T) { }) require.NoError(t, err) - return w, cleanup + return w } testCases := []struct { @@ -744,8 +735,7 @@ func TestFetchAccountBalances(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, cleanup := setupTestCase(t) - t.Cleanup(cleanup) + w := setupTestCase(t) if tc.setup != nil { tc.setup(t, w) @@ -776,8 +766,7 @@ func TestListAccountsWithBalances(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // We'll create two new accounts under the BIP0084 scope to have a // predictable state. diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 3973bee66d..5989e736c7 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -81,8 +81,7 @@ func TestNewAddress(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // Define a set of test cases to cover different address types and // scenarios. @@ -176,8 +175,7 @@ func TestGetUnusedAddress(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // Get a new address to start with. addr, err := w.NewAddress( @@ -259,8 +257,7 @@ func TestAddressInfo(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // Get a new external address to test with. extAddr, err := w.NewAddress( @@ -303,8 +300,7 @@ func TestListAddresses(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // Get a new address and give it a balance. addr, err := w.NewAddress( @@ -368,8 +364,7 @@ func TestImportPublicKey(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // Create a new public key to import. privKey, err := btcec.NewPrivateKey() @@ -399,8 +394,7 @@ func TestImportTaprootScript(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // Create a new tapscript to import. privKey, err := btcec.NewPrivateKey() @@ -446,8 +440,7 @@ func TestScriptForOutput(t *testing.T) { t.Parallel() // Create a new test wallet. - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // Create a new p2wkh address and output. addr, err := w.NewAddress( diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 0bee7805f5..3e016f0a85 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -166,8 +166,7 @@ func setupBenchmarkWallet(tb testing.TB, config benchmarkWalletConfig) *Wallet { // *testing.B. Instead, we create a setup *testing.T and manually fail // the benchmark if the setup fails. setupT := &testing.T{} - w, cleanup := testWallet(setupT) - tb.Cleanup(cleanup) + w := testWallet(setupT) require.False(tb, setupT.Failed(), "testWallet setup failed") addresses := createTestAccounts( diff --git a/wallet/createtx_test.go b/wallet/createtx_test.go index 6968630c03..ff38c32672 100644 --- a/wallet/createtx_test.go +++ b/wallet/createtx_test.go @@ -38,8 +38,7 @@ var ( func TestTxToOutputsDryRun(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // Create an address we can use to send some coins to. keyScope := waddrmgr.KeyScopeBIP0049Plus @@ -279,8 +278,7 @@ func TestInputYield(t *testing.T) { func TestTxToOutputsRandom(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // Create an address we can use to send some coins to. keyScope := waddrmgr.KeyScopeBIP0049Plus @@ -360,8 +358,7 @@ func TestTxToOutputsRandom(t *testing.T) { func TestCreateSimpleCustomChange(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // First, we'll make a P2TR and a P2WKH address to send some coins to // (two different coin scopes). @@ -452,8 +449,7 @@ func TestCreateSimpleCustomChange(t *testing.T) { func TestSelectUtxosTxoToOutpoint(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // First, we'll make a P2TR and a P2WKH address to send some coins to. p2wkhAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) diff --git a/wallet/example_test.go b/wallet/example_test.go index 59e8129185..36352e9793 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -16,7 +16,8 @@ import ( var defaultDBTimeout = 10 * time.Second // testWallet creates a test wallet and unlocks it. -func testWallet(t *testing.T) (*Wallet, func()) { +func testWallet(t *testing.T) *Wallet { + t.Helper() // Set up a wallet. dir := t.TempDir() @@ -43,11 +44,17 @@ func testWallet(t *testing.T) (*Wallet, func()) { // Start the wallet. w.Start() + // Add the shutdown to the test's cleanup process. + t.Cleanup(func() { + w.Stop() + w.WaitForShutdown() + }) + if err := w.Unlock(privPass, time.After(10*time.Minute)); err != nil { t.Fatalf("unable to unlock wallet: %v", err) } - return w, func() {} + return w } // mockers is a struct that holds all the mocked interfaces that can be @@ -115,7 +122,8 @@ func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { } // testWalletWatchingOnly creates a test watch only wallet and unlocks it. -func testWalletWatchingOnly(t *testing.T) (*Wallet, func()) { +func testWalletWatchingOnly(t *testing.T) *Wallet { + t.Helper() // Set up a wallet. dir := t.TempDir() @@ -148,5 +156,11 @@ func testWalletWatchingOnly(t *testing.T) (*Wallet, func()) { t.Fatalf("unable to create default scopes: %v", err) } - return w, func() {} + w.Start() + t.Cleanup(func() { + w.Stop() + w.WaitForShutdown() + }) + + return w } diff --git a/wallet/import_test.go b/wallet/import_test.go index 94f5ea7ba6..17a0701151 100644 --- a/wallet/import_test.go +++ b/wallet/import_test.go @@ -136,8 +136,7 @@ func TestImportAccountDeprecated(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) testImportAccount(t, w, tc, false, tc.name) }) @@ -146,8 +145,7 @@ func TestImportAccountDeprecated(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - w, cleanup := testWalletWatchingOnly(t) - defer cleanup() + w := testWalletWatchingOnly(t) testImportAccount(t, w, tc, true, name) }) diff --git a/wallet/psbt_test.go b/wallet/psbt_test.go index 511cc2d803..98b0255051 100644 --- a/wallet/psbt_test.go +++ b/wallet/psbt_test.go @@ -33,8 +33,7 @@ var ( func TestFundPsbt(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // Create a P2WKH address we can use to send some coins to. addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) @@ -430,8 +429,7 @@ func containsUtxo(list []wire.OutPoint, candidate wire.OutPoint) bool { func TestFinalizePsbt(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // Create a P2WKH address we can use to send some coins to. addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) diff --git a/wallet/signer_test.go b/wallet/signer_test.go index af3e70e6fc..88a47e3984 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -18,6 +18,8 @@ import ( func TestComputeInputScript(t *testing.T) { t.Parallel() + w := testWallet(t) + testCases := []struct { name string scope waddrmgr.KeyScope @@ -32,9 +34,6 @@ func TestComputeInputScript(t *testing.T) { expectedScriptLen: 23, }} - w, cleanup := testWallet(t) - defer cleanup() - for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { diff --git a/wallet/utxos_test.go b/wallet/utxos_test.go index bbd60195ea..17451ddf49 100644 --- a/wallet/utxos_test.go +++ b/wallet/utxos_test.go @@ -21,8 +21,7 @@ import ( func TestFetchInputInfo(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // Create an address we can use to send some coins to. addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) @@ -98,8 +97,7 @@ func TestFetchInputInfo(t *testing.T) { func TestFetchOutpointInfo(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // Create an address we can use to send some coins to. addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) @@ -135,8 +133,7 @@ func TestFetchOutpointInfo(t *testing.T) { func TestFetchOutpointInfoErr(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // Create an address we can use to send some coins to. addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) @@ -213,8 +210,7 @@ func TestFetchOutpointInfoErr(t *testing.T) { func TestFetchDerivationInfo(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // Create an address we can use to send some coins to. addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 11f5b44bec..0362d1b096 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -169,10 +169,7 @@ func TestLabelTransaction(t *testing.T) { test := test t.Run(test.name, func(t *testing.T) { - t.Parallel() - - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) // If the transaction should be known to the store, we // write txdetail to disk. @@ -289,8 +286,7 @@ func TestGetTransaction(t *testing.T) { test := test t.Run(test.name, func(t *testing.T) { - w, cleanup := testWallet(t) - defer cleanup() + w := testWallet(t) err := walletdb.Update(w.db, func(rw walletdb.ReadWriteTx) error { ns := rw.ReadWriteBucket(wtxmgrNamespaceKey) @@ -396,8 +392,7 @@ func TestGetTransactionConfirmations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - w, cleanup := testWallet(t) - t.Cleanup(cleanup) + w := testWallet(t) // Set the wallet's synced height. err := walletdb.Update( @@ -493,9 +488,7 @@ func TestGetTransactionConfirmations(t *testing.T) { // TestDuplicateAddressDerivation tests that duplicate addresses are not // derived when multiple goroutines are concurrently requesting new addresses. func TestDuplicateAddressDerivation(t *testing.T) { - w, cleanup := testWallet(t) - defer cleanup() - + w := testWallet(t) var ( m sync.Mutex globalAddrs = make(map[string]btcutil.Address) @@ -557,7 +550,7 @@ func TestEndRecovery(t *testing.T) { // when using btcwallet with a fresh seed, because it requires an early // birthday to be set or established. - w, cleanup := testWallet(t) + w := testWallet(t) blockHashCalled := make(chan struct{}) @@ -611,11 +604,9 @@ func TestEndRecovery(t *testing.T) { case <-blockHashCalled: case <-recoveryDone: } - cleanup() // Try again. - w, cleanup = testWallet(t) - defer cleanup() + w = testWallet(t) // We'll catch the error to make sure we're hitting our desired path. The // WaitGroup isn't required for the test, but does show how it completes From 434dd50bf25c929437814dfe98031df1cc74f09a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 10 Oct 2025 17:38:18 +0800 Subject: [PATCH 088/691] wallet: fix linter `cyclop` We add a helper method to shorten the lines in `ListUnspent`. --- wallet/utxo_manager.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 8874d8baf5..12d461ae5c 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -181,12 +181,7 @@ func (w *Wallet) ListUnspent(_ context.Context, // Iterate through each UTXO to apply filters and enrich it with // address-specific details. for _, output := range unspent { - // Calculate the current confirmation status based on - // the wallet's synced block height. - confs := int32(0) - if output.Height != -1 { - confs = currentHeight - output.Height - } + confs := calcConf(currentHeight, output.Height) log.Tracef("Checking utxo[%v]: current height=%v, "+ "confirm height=%v, conf=%v", output.OutPoint, @@ -265,6 +260,17 @@ func (w *Wallet) ListUnspent(_ context.Context, return utxos, err } +// calcConf calculates the current confirmation status based on the wallet's +// synced block height. +func calcConf(currentHeight, outputHeight int32) int32 { + confs := int32(0) + if outputHeight != -1 { + confs = currentHeight - outputHeight + } + + return confs +} + // GetUtxo returns the output information for a given outpoint. // // This method provides a detailed view of a single UTXO, identified by its @@ -334,10 +340,7 @@ func (w *Wallet) GetUtxo(_ context.Context, return wtxmgr.ErrUtxoNotFound } - confs := int32(0) - if output.Height != -1 { - confs = currentHeight - output.Height - } + confs := calcConf(currentHeight, output.Height) // Extract the address from the UTXO's public key script. // For multi-address scripts, the first address is used. From efd3f6f1d87c19b51f6d16aac362b00081372607 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 7 Oct 2025 08:08:14 +0000 Subject: [PATCH 089/691] wallet: setup benchmark wallet with outpoints --- wallet/account_manager_benchmark_test.go | 26 ++++++------ wallet/address_manager_benchmark_test.go | 52 ++++++++++++++---------- wallet/benchmark_helpers_test.go | 44 +++++++++++++++++--- 3 files changed, 82 insertions(+), 40 deletions(-) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index 022d7b2beb..dbbbd11e7b 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -85,7 +85,7 @@ func BenchmarkListAccountsAPI(b *testing.B) { for _, size := range benchmarkSizes { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -98,7 +98,7 @@ func BenchmarkListAccountsAPI(b *testing.B) { b.ResetTimer() for b.Loop() { - _, err := listAccountsDeprecated(w) + _, err := listAccountsDeprecated(bw.Wallet) require.NoError(b, err) } }) @@ -144,7 +144,7 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { accountName, _ := generateAccountName(size.numAccounts, scopes) b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -158,7 +158,7 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { for b.Loop() { _, err := listAccountsByNameDeprecated( - w, accountName, + bw.Wallet, accountName, ) require.NoError(b, err) } @@ -203,7 +203,7 @@ func BenchmarkNewAccountAPI(b *testing.B) { for _, size := range benchmarkSizes { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -223,7 +223,9 @@ func BenchmarkNewAccountAPI(b *testing.B) { accountName := fmt.Sprintf("new-account-%d", count) - _, err := w.NextAccount(scopes[0], accountName) + _, err := bw.NextAccount( + scopes[0], accountName, + ) require.NoError(b, err) count++ @@ -279,7 +281,7 @@ func BenchmarkGetAccountAPI(b *testing.B) { accountName, _ := generateAccountName(size.numAccounts, scopes) b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -293,7 +295,7 @@ func BenchmarkGetAccountAPI(b *testing.B) { for b.Loop() { _, err := getAccountDeprecated( - w, scopes[0], accountName, + bw.Wallet, scopes[0], accountName, ) require.NoError(b, err) } @@ -428,7 +430,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { accountName, _ := generateAccountName(size.numAccounts, scopes) b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -442,7 +444,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { for b.Loop() { _, err := getBalanceDeprecated( - w, scopes[0], accountName, + bw.Wallet, scopes[0], accountName, confirmations, ) require.NoError(b, err) @@ -450,7 +452,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { }) b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -463,7 +465,7 @@ func BenchmarkGetBalanceAPI(b *testing.B) { b.ResetTimer() for b.Loop() { - _, err := w.Balance( + _, err := bw.Balance( b.Context(), confirmations, scopes[0], accountName, ) diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index 2d45cf09f1..98b18eeb8f 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -30,7 +30,7 @@ func BenchmarkListAddressesAPI(b *testing.B) { ) b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -44,7 +44,7 @@ func BenchmarkListAddressesAPI(b *testing.B) { for b.Loop() { _, err := listAddressesDeprecated( - w, accountNumber, + bw.Wallet, accountNumber, ) require.NoError(b, err) } @@ -91,7 +91,7 @@ func BenchmarkAddressInfoAPI(b *testing.B) { for _, size := range benchmarkSizes { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -100,19 +100,21 @@ func BenchmarkAddressInfoAPI(b *testing.B) { }, ) - testAddr := getTestAddress(b, w, size.numAccounts) + testAddr := getTestAddress( + b, bw.Wallet, size.numAccounts, + ) b.ReportAllocs() b.ResetTimer() for b.Loop() { - _, err := w.AddressInfoDeprecated(testAddr) + _, err := bw.AddressInfoDeprecated(testAddr) require.NoError(b, err) } }) b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -121,13 +123,15 @@ func BenchmarkAddressInfoAPI(b *testing.B) { }, ) - testAddr := getTestAddress(b, w, size.numAccounts) + testAddr := getTestAddress( + b, bw.Wallet, size.numAccounts, + ) b.ReportAllocs() b.ResetTimer() for b.Loop() { - _, err := w.AddressInfo(b.Context(), testAddr) + _, err := bw.AddressInfo(b.Context(), testAddr) require.NoError(b, err) } }) @@ -159,7 +163,7 @@ func BenchmarkGetUnusedAddressAPI(b *testing.B) { ) b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -172,19 +176,19 @@ func BenchmarkGetUnusedAddressAPI(b *testing.B) { b.ResetTimer() for b.Loop() { - addr, err := w.NewAddressDeprecated( + addr, err := bw.NewAddressDeprecated( accountNumber, scopes[0], ) require.NoError(b, err) // Mark the address as used to make the // benchmark iteration idempotent. - markAddressAsUsed(b, w, addr) + markAddressAsUsed(b, bw.Wallet, addr) } }) b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -197,7 +201,7 @@ func BenchmarkGetUnusedAddressAPI(b *testing.B) { b.ResetTimer() for b.Loop() { - addr, err := w.GetUnusedAddress( + addr, err := bw.GetUnusedAddress( b.Context(), accountName, addrType, false, ) @@ -205,7 +209,7 @@ func BenchmarkGetUnusedAddressAPI(b *testing.B) { // Mark the address as used to make the // benchmark iteration idempotent. - markAddressAsUsed(b, w, addr) + markAddressAsUsed(b, bw.Wallet, addr) } }) } @@ -235,7 +239,7 @@ func BenchmarkNewAddressAPI(b *testing.B) { ) b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -248,7 +252,7 @@ func BenchmarkNewAddressAPI(b *testing.B) { b.ResetTimer() for b.Loop() { - _, err := w.NewAddressDeprecated( + _, err := bw.NewAddressDeprecated( accountNumber, scopes[0], ) require.NoError(b, err) @@ -485,7 +489,7 @@ func BenchmarkScriptForOutputAPI(b *testing.B) { for _, size := range benchmarkSizes { b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -494,14 +498,16 @@ func BenchmarkScriptForOutputAPI(b *testing.B) { }, ) - testAddr := getTestAddress(b, w, size.numAccounts) + testAddr := getTestAddress( + b, bw.Wallet, size.numAccounts, + ) testTxOut := generateTestTxOut(b, testAddr) b.ReportAllocs() b.ResetTimer() for b.Loop() { - _, _, _, err := w.ScriptForOutputDeprecated( + _, _, _, err := bw.ScriptForOutputDeprecated( &testTxOut, ) require.NoError(b, err) @@ -509,7 +515,7 @@ func BenchmarkScriptForOutputAPI(b *testing.B) { }) b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { - w := setupBenchmarkWallet( + bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, numAccounts: size.numAccounts, @@ -518,14 +524,16 @@ func BenchmarkScriptForOutputAPI(b *testing.B) { }, ) - testAddr := getTestAddress(b, w, size.numAccounts) + testAddr := getTestAddress( + b, bw.Wallet, size.numAccounts, + ) testTxOut := generateTestTxOut(b, testAddr) b.ReportAllocs() b.ResetTimer() for b.Loop() { - _, err := w.ScriptForOutput( + _, err := bw.ScriptForOutput( b.Context(), testTxOut, ) require.NoError(b, err) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 3e016f0a85..ff74ab04a8 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -157,9 +157,19 @@ type benchmarkWalletConfig struct { numAddresses int } +// benchmarkWallet holds a wallet and its created UTXO outpoints. +type benchmarkWallet struct { + *Wallet + + outpoints []wire.OutPoint +} + // setupBenchmarkWallet creates a wallet with test data based on the provided -// configuration. It distributes accounts evenly across the specified scopes. -func setupBenchmarkWallet(tb testing.TB, config benchmarkWalletConfig) *Wallet { +// configuration. It distributes accounts evenly across the specified scopes +// and returns the wallet along with the outpoints of all created UTXOs. +func setupBenchmarkWallet(tb testing.TB, + config benchmarkWalletConfig) *benchmarkWallet { + tb.Helper() // Since testWallet requires a *testing.T, we can't pass the benchmark's @@ -174,9 +184,12 @@ func setupBenchmarkWallet(tb testing.TB, config benchmarkWalletConfig) *Wallet { config.numAddresses, ) - createTestUTXOs(tb, w, addresses, config.numUTXOs) + outpoints := createTestUTXOs(tb, w, addresses, config.numUTXOs) - return w + return &benchmarkWallet{ + Wallet: w, + outpoints: outpoints, + } } // createTestAccounts creates test accounts across the specified key scopes @@ -253,12 +266,15 @@ func createAccountsInScope(w *Wallet, tx walletdb.ReadWriteTx, } // createTestUTXOs creates the specified number of test UTXOs using the provided -// addresses for benchmark data setup. +// addresses for benchmark data setup. It returns the outpoints of all created +// UTXOs. func createTestUTXOs(tb testing.TB, w *Wallet, - addresses []waddrmgr.ManagedAddress, numUTXOs int) { + addresses []waddrmgr.ManagedAddress, numUTXOs int) []wire.OutPoint { tb.Helper() + var outpoints []wire.OutPoint + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) @@ -322,12 +338,20 @@ func createTestUTXOs(tb testing.TB, w *Wallet, if err != nil { return err } + + // Store the actual outpoint for later use. + outpoints = append(outpoints, wire.OutPoint{ + Hash: rec.Hash, + Index: 0, + }) } return nil }) require.NoError(tb, err, "failed to create test UTXOs: %v", err) + + return outpoints } // generateAccountName generates a consistent account name and number for @@ -421,6 +445,14 @@ func markAddressAsUsed(b *testing.B, w *Wallet, addr btcutil.Address) { require.NoError(b, err) } +// getTestUtxoOutpoint returns a median UTXO outpoint from the provided list +// for benchmarking purposes. It returns the outpoint from the middle of the +// list to provide a representative test case. +func getTestUtxoOutpoint(outpoints []wire.OutPoint) wire.OutPoint { + medianIndex := len(outpoints) / 2 + return outpoints[medianIndex] +} + // generateTestTapscript generates a test tapscript for benchmarking purposes. // It creates a simple script that checks a signature against the provided // public key, wraps it in a tap leaf, and returns a complete Tapscript From 5b42eeeaa9585d6184aae6c3050eec9234f2c922 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 7 Oct 2025 08:08:41 +0000 Subject: [PATCH 090/691] wallet: benchmark `GetUtxo` API --- wallet/benchmark_helpers_test.go | 53 +++++++++++++++++++ wallet/utxo_manager_benchmark_test.go | 73 +++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 wallet/utxo_manager_benchmark_test.go diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index ff74ab04a8..c0d32eaf83 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -636,3 +636,56 @@ func listAddressesDeprecated(w *Wallet, return allProperties, nil } + +// getUtxoDeprecated wraps the deprecated FetchOutpointInfo API to satisfy the +// same contract as GetUtxo by calling FetchOutpointInfo and performing +// additional lookups to construct a complete Utxo struct. This demonstrates +// the inefficiency of the old API which returns raw data requiring the caller +// to perform multiple additional lookups. +func getUtxoDeprecated(w *Wallet, prevOut wire.OutPoint) (*Utxo, error) { + _, txOut, confs, err := w.FetchOutpointInfo(&prevOut) + if err != nil { + return nil, err + } + + // Additional lookup 1: Extract address from pkScript. + addr := extractAddrFromPKScript(txOut.PkScript, w.chainParams) + if addr == nil { + return nil, ErrNotMine + } + + // Additional lookup 2: Get address details (spendability, account, + // address type) from the address manager. + var ( + spendable bool + account string + addrType waddrmgr.AddressType + ) + + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + spendable, account, addrType = w.addrStore.AddressDetails( + addrmgrNs, addr, + ) + + return nil + }) + if err != nil { + return nil, err + } + + // Additional lookup 3: Check if the output is locked. + locked := w.LockedOutpoint(prevOut) + + return &Utxo{ + OutPoint: prevOut, + Amount: btcutil.Amount(txOut.Value), + PkScript: txOut.PkScript, + Confirmations: int32(confs), + Spendable: spendable, + Address: addr, + Account: account, + AddressType: addrType, + Locked: locked, + }, nil +} diff --git a/wallet/utxo_manager_benchmark_test.go b/wallet/utxo_manager_benchmark_test.go new file mode 100644 index 0000000000..d1a16fc7f9 --- /dev/null +++ b/wallet/utxo_manager_benchmark_test.go @@ -0,0 +1,73 @@ +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// BenchmarkGetUtxoAPI benchmarks GetUtxo API and its deprecated variant +// FetchOutpointInfo using same key scope and identical test data across +// multiple dataset sizes. Test names start with dataset size to group API +// comparisons for benchstat analysis. +func BenchmarkGetUtxoAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: exponentialGrowth, + addressGrowth: linearGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + for _, size := range benchmarkSizes { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testOutpoint := getTestUtxoOutpoint(bw.outpoints) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := getUtxoDeprecated( + bw.Wallet, testOutpoint, + ) + require.NoError(b, err) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testOutpoint := getTestUtxoOutpoint(bw.outpoints) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := bw.GetUtxo( + b.Context(), testOutpoint, + ) + require.NoError(b, err) + } + }) + } +} From 60beaf98c801c07d627bcf21419ee6d46b6cb93f Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 7 Oct 2025 09:43:41 +0000 Subject: [PATCH 091/691] wallet: make sure the synced height is up-to-date --- wallet/benchmark_helpers_test.go | 35 ++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index c0d32eaf83..5999e92e74 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -186,12 +186,31 @@ func setupBenchmarkWallet(tb testing.TB, outpoints := createTestUTXOs(tb, w, addresses, config.numUTXOs) + // Sync wallet to the block height where UTXOs were created. + setSyncedToHeight(tb, w, 1) + return &benchmarkWallet{ Wallet: w, outpoints: outpoints, } } +// setSyncedToHeight updates the wallet's synced block height. This is useful +// for benchmark tests to ensure confirmation calculations work correctly. +func setSyncedToHeight(tb testing.TB, w *Wallet, height int32) { + tb.Helper() + + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + return w.addrStore.SetSyncedTo(addrmgrNs, &waddrmgr.BlockStamp{ + Height: height, + Hash: chainhash.Hash{}, + }) + }) + require.NoError(tb, err, "failed to set synced height to %d", height) +} + // createTestAccounts creates test accounts across the specified key scopes // and returns all generated addresses. func createTestAccounts(tb testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, @@ -280,6 +299,14 @@ func createTestUTXOs(tb testing.TB, w *Wallet, addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) msgTx := TstTx.MsgTx() + blockMeta := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: chainhash.Hash{}, + Height: 1, + }, + Time: time.Now(), + } + for i := 0; i < numUTXOs && i < len(addresses); i++ { newMsgTx := wire.NewMsgTx(msgTx.Version) addr := addresses[i%len(addresses)] @@ -311,14 +338,6 @@ func createTestUTXOs(tb testing.TB, w *Wallet, return err } - blockMeta := &wtxmgr.BlockMeta{ - Block: wtxmgr.Block{ - Hash: chainhash.Hash{}, - Height: 1, - }, - Time: time.Now(), - } - err = w.txStore.InsertTx(txmgrNs, rec, blockMeta) if err != nil { return err From fb102f34ffe813fa9489c3e6302c4f2b9b7476d4 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 7 Oct 2025 09:45:02 +0000 Subject: [PATCH 092/691] wallet: benchmark `ListUnspent` API --- wallet/utxo_manager_benchmark_test.go | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/wallet/utxo_manager_benchmark_test.go b/wallet/utxo_manager_benchmark_test.go index d1a16fc7f9..849b7c8b33 100644 --- a/wallet/utxo_manager_benchmark_test.go +++ b/wallet/utxo_manager_benchmark_test.go @@ -1,6 +1,7 @@ package wallet import ( + "math" "testing" "github.com/btcsuite/btcwallet/waddrmgr" @@ -71,3 +72,73 @@ func BenchmarkGetUtxoAPI(b *testing.B) { }) } } + +// BenchmarkListUnspentAPI benchmarks ListUnspent API and its deprecated +// variant ListUnspentDeprecated using same key scope and identical test data +// across multiple dataset sizes. Test names start with dataset size to group +// API comparisons for benchstat analysis. +func BenchmarkListUnspentAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: linearGrowth, + utxoGrowth: exponentialGrowth, + addressGrowth: linearGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + minConfs := 0 + maxConfs := math.MaxInt32 + + for _, size := range benchmarkSizes { + accountName, _ := generateAccountName(size.numAccounts, scopes) + + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := bw.ListUnspentDeprecated( + int32(minConfs), int32(maxConfs), + accountName, + ) + require.NoError(b, err) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := bw.ListUnspent( + b.Context(), UtxoQuery{ + Account: accountName, + MinConfs: int32(minConfs), + MaxConfs: int32(maxConfs), + }, + ) + require.NoError(b, err) + } + }) + } +} From 1459e96d3668cbb764673625da3948a69b6b84a6 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 7 Oct 2025 15:40:03 +0000 Subject: [PATCH 093/691] wallet: benchmark `LeaseOutput` API --- wallet/utxo_manager_benchmark_test.go | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/wallet/utxo_manager_benchmark_test.go b/wallet/utxo_manager_benchmark_test.go index 849b7c8b33..ae7b158ee4 100644 --- a/wallet/utxo_manager_benchmark_test.go +++ b/wallet/utxo_manager_benchmark_test.go @@ -3,8 +3,10 @@ package wallet import ( "math" "testing" + "time" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/require" ) @@ -142,3 +144,71 @@ func BenchmarkListUnspentAPI(b *testing.B) { }) } } + +// BenchmarkLeaseOutputAPI benchmarks LeaseOutput API and its deprecated +// variant LeaseOutputDeprecated. Although LeaseOutput is an O(1) operation, +// testing across different dataset sizes helps identify any database bucket +// depth effects or positional bias as the UTXO set grows. +func BenchmarkLeaseOutputAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: constantGrowth, + utxoGrowth: linearGrowth, + addressGrowth: constantGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + lockID := wtxmgr.LockID{0x01, 0x02, 0x03, 0x04} + duration := time.Hour + + for _, size := range benchmarkSizes { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testOutpoint := getTestUtxoOutpoint(bw.outpoints) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := bw.LeaseOutputDeprecated( + lockID, testOutpoint, duration, + ) + require.NoError(b, err) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testOutpoint := getTestUtxoOutpoint(bw.outpoints) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := bw.LeaseOutput( + b.Context(), lockID, testOutpoint, + duration, + ) + require.NoError(b, err) + } + }) + } +} From c56101012a17bb1eb22c77c8ad93013b45eb99c3 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 7 Oct 2025 15:41:02 +0000 Subject: [PATCH 094/691] wallet: benchmark `ReleaseOutput` API --- wallet/utxo_manager_benchmark_test.go | 79 +++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/wallet/utxo_manager_benchmark_test.go b/wallet/utxo_manager_benchmark_test.go index ae7b158ee4..e4fbce7b17 100644 --- a/wallet/utxo_manager_benchmark_test.go +++ b/wallet/utxo_manager_benchmark_test.go @@ -212,3 +212,82 @@ func BenchmarkLeaseOutputAPI(b *testing.B) { }) } } + +// BenchmarkReleaseOutputAPI benchmarks ReleaseOutput API and its deprecated +// variant ReleaseOutputDeprecated. Although ReleaseOutput is an O(1) operation, +// testing across different dataset sizes helps identify any database bucket +// depth effects or positional bias as the UTXO set grows. Outputs must be +// leased before they can be released. +func BenchmarkReleaseOutputAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: constantGrowth, + utxoGrowth: linearGrowth, + addressGrowth: constantGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + lockID := wtxmgr.LockID{0x01, 0x02, 0x03, 0x04} + duration := time.Hour + + for _, size := range benchmarkSizes { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testOutpoint := getTestUtxoOutpoint(bw.outpoints) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := bw.LeaseOutputDeprecated( + lockID, testOutpoint, duration, + ) + require.NoError(b, err) + + err = bw.ReleaseOutputDeprecated( + lockID, testOutpoint, + ) + require.NoError(b, err) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + testOutpoint := getTestUtxoOutpoint(bw.outpoints) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := bw.LeaseOutput( + b.Context(), lockID, testOutpoint, + duration, + ) + require.NoError(b, err) + + err = bw.ReleaseOutput( + b.Context(), lockID, testOutpoint, + ) + require.NoError(b, err) + } + }) + } +} From 849654d37bb81d74b1c4212f481452cef88f5285 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 7 Oct 2025 15:43:00 +0000 Subject: [PATCH 095/691] wallet: benchmark `ListLeasedOutputs` API --- wallet/benchmark_helpers_test.go | 18 ++++++++ wallet/utxo_manager_benchmark_test.go | 65 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 5999e92e74..3f1638da08 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -512,6 +512,24 @@ func generateTestTxOut(tb testing.TB, addr btcutil.Address) wire.TxOut { } } +// leaseAllOutputs leases all outputs in the wallet with unique lock IDs. This +// is used to set up benchmarks for ListLeasedOutputs where we want to maximize +// the N+1 query impact when comparing the new vs deprecated ListLeasedOutputs +// APIs. +func leaseAllOutputs(tb testing.TB, w *Wallet, outpoints []wire.OutPoint, + duration time.Duration) { + + tb.Helper() + + for i, outpoint := range outpoints { + lockID := wtxmgr.LockID{byte(i)} + _, err := w.LeaseOutput( + tb.Context(), lockID, outpoint, duration, + ) + require.NoError(tb, err, "failed to lease output %v", outpoint) + } +} + // listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same // contract as ListAccounts by calling Accounts API across all active key scopes // and aggregating the results. diff --git a/wallet/utxo_manager_benchmark_test.go b/wallet/utxo_manager_benchmark_test.go index e4fbce7b17..b28f1dfe19 100644 --- a/wallet/utxo_manager_benchmark_test.go +++ b/wallet/utxo_manager_benchmark_test.go @@ -291,3 +291,68 @@ func BenchmarkReleaseOutputAPI(b *testing.B) { }) } } + +// BenchmarkListLeasedOutputsAPI benchmarks ListLeasedOutputs API and its +// deprecated variant ListLeasedOutputsDeprecated. The deprecated API performs +// N+1 transaction lookups to enrich each leased output with value and pkScript, +// while the new API returns minimal lock metadata in a single scan. Performance +// difference scales with the number of leased outputs. +func BenchmarkListLeasedOutputsAPI(b *testing.B) { + benchmarkSizes, namingInfo := generateBenchmarkSizes( + benchmarkConfig{ + accountGrowth: constantGrowth, + utxoGrowth: exponentialGrowth, + addressGrowth: constantGrowth, + maxIterations: 14, + startIndex: 0, + }, + ) + scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + duration := time.Hour + + for _, size := range benchmarkSizes { + b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + // Lease all outputs to maximize the N+1 query impact. + leaseAllOutputs(b, bw.Wallet, bw.outpoints, duration) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := bw.ListLeasedOutputsDeprecated() + require.NoError(b, err) + } + }) + + b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: size.numAccounts, + numAddresses: size.numAddresses, + numUTXOs: size.numUTXOs, + }, + ) + + // Lease all outputs to maximize the N+1 query impact. + leaseAllOutputs(b, bw.Wallet, bw.outpoints, duration) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := bw.ListLeasedOutputs(b.Context()) + require.NoError(b, err) + } + }) + } +} From 39169a2760a2eee4774bdc545dfa78f561baeac8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 15 Sep 2025 20:09:14 +0800 Subject: [PATCH 096/691] wallet: introduce TxCreator interface and refined TxIntent This commit introduces a new, decoupled interface for transaction creation, `TxCreator`, and a set of well-defined structs to represent the user's intent for building a transaction. This is the first step in a larger refactoring to separate transaction creation, signing, and publishing, moving away from the monolithic `SendOutputs` method. The new `TxIntent` struct serves as a blueprint for creating transactions, providing a clear and robust API that prevents invalid states at compile time. Key design features of the new interfaces: - **Decoupled Workflows:** The `TxCreator` is responsible only for creating unsigned transactions. Signing and publishing are handled by separate, forthcoming `Signer` and `TxPublisher` interfaces. - **Explicit User Intent:** The `TxIntent` struct requires the user to explicitly choose between manual input selection (`InputsManual`) and automatic coin selection (`InputsPolicy`). This is enforced using a sealed interface pattern for the `Inputs` field, which prevents ambiguous or conflicting requests. - **Flexible Coin Selection:** The `InputsPolicy` allows for sophisticated coin selection by specifying a source for the UTXOs. The source can either be a wallet account (`ScopedAccount`) or a predefined list of candidate UTXOs (`CoinSourceUTXOs`), which is also enforced via a sealed interface. - **User-Friendly Identifiers:** The API now uses account names (string) instead of account numbers (uint32) to improve usability and reduce the risk of errors. - **Modernized API:** The new interfaces are designed to be more idiomatic and safer, using modern Go patterns to guide the user and prevent common pitfalls like forgetting a change output or creating ambiguous funding requests. --- wallet/tx_creator.go | 268 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 wallet/tx_creator.go diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go new file mode 100644 index 0000000000..8576d9e76b --- /dev/null +++ b/wallet/tx_creator.go @@ -0,0 +1,268 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +// Package wallet provides a bitcoin wallet implementation that is ready for +// use. +// +// TODO(yy): bring wrapcheck back when implementing the `Store` interface. +// +//nolint:wrapcheck +package wallet + +import ( + "context" + "errors" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet/txauthor" +) + +var ( + // ErrManualInputsEmpty is returned when manual inputs are specified but + // the list is empty. + ErrManualInputsEmpty = errors.New("manual inputs cannot be empty") + + // ErrDuplicatedUtxo is returned when a UTXO is specified multiple + // times. + ErrDuplicatedUtxo = errors.New("duplicated utxo") +) + +// TxCreator provides an interface for creating transactions. Its primary +// role is to produce a fully-formed, unsigned transaction that can be passed +// to the Signer interface. +type TxCreator interface { + // CreateTransaction creates a new, unsigned transaction based on the + // provided intent. The resulting AuthoredTx will contain the unsigned + // transaction and all the necessary metadata to sign it. + CreateTransaction(ctx context.Context, intent *TxIntent) ( + *txauthor.AuthoredTx, error) +} + +// SatPerKVByte is a type that represents a fee rate in satoshis per +// kilo-virtual-byte. This is the standard unit for fee estimation in modern +// Bitcoin transactions that use SegWit. +type SatPerKVByte btcutil.Amount + +// TxIntent represents the user's intent to create a transaction. It serves as +// a blueprint for the TxCreator, bundling all the parameters required to +// construct a transaction into a single, coherent structure. +// +// A TxIntent can be used to create a transaction in four main ways: +// +// 1. Automatic Coin Selection from the Default Account: +// The simplest way to create a transaction is to specify only the outputs +// and the fee rate. By leaving the `Inputs` field as nil, the wallet will +// automatically select coins from the default account to fund the +// transaction. +// +// Example: +// +// intent := &TxIntent{ +// Outputs: outputs, +// FeeRate: feeRate, +// } +// +// 2. Manual Input Selection: +// To have direct control over the inputs used, the caller can specify the +// exact UTXOs to spend. This is achieved by setting the `Inputs` field to +// an `InputsManual` struct, which contains a slice of the desired +// `wire.OutPoint`s. In this mode, all coin selection logic is bypassed; the +// wallet simply uses the provided inputs. +// +// Example: +// +// intent := &TxIntent{ +// Outputs: outputs, +// Inputs: &InputsManual{UTXOs: []wire.OutPoint{...}}, +// FeeRate: feeRate, +// ChangeSource: changeSource, +// } +// +// 3. Policy-Based Coin Selection from an Account: +// To have the wallet select inputs from a specific account, the caller can +// specify a policy. This is achieved by setting the `Inputs` field to an +// `InputsPolicy` struct. This struct defines the strategy (e.g., +// largest-first), the minimum number of confirmations, and the source of +// the coins. If the `Source` is a `ScopedAccount`, the wallet will select +// coins from that account. If the `Source` field is nil, the wallet will +// use a default source, typically the default account. +// +// Example: +// +// intent := &TxIntent{ +// Outputs: outputs, +// Inputs: &InputsPolicy{ +// Strategy: CoinSelectionLargest, +// MinConfs: 1, +// Source: &ScopedAccount{AccountName: "default", ...}, +// }, +// FeeRate: feeRate, +// ChangeSource: changeSource, +// } +// +// 4. Policy-Based Coin Selection from a specific set of UTXOs: +// For more advanced control, the caller can provide a specific list of UTXOs +// and have the coin selection algorithm choose the best subset from that +// list. This is useful for scenarios like coin control where the user wants +// to limit the potential inputs for a transaction. This is achieved by +// setting the `Source` of an `InputsPolicy` to a `CoinSourceUTXOs` struct. +// +// Example: +// +// intent := &TxIntent{ +// Outputs: outputs, +// Inputs: &InputsPolicy{ +// Strategy: CoinSelectionLargest, +// MinConfs: 1, +// Source: &CoinSourceUTXOs{ +// UTXOs: []wire.OutPoint{...}, +// }, +// }, +// FeeRate: feeRate, +// ChangeSource: changeSource, +// } +type TxIntent struct { + // Outputs specifies the recipients and amounts for the transaction. + // This field is required. + Outputs []wire.TxOut + + // Inputs defines the source of the inputs for the transaction. This + // must be one of the Inputs implementations (InputsManual or + // InputsPolicy). This field is required. + Inputs Inputs + + // ChangeSource specifies the destination for the transaction's change + // output. If this field is nil, the wallet will use a default change + // source based on the account and scope of the inputs. + ChangeSource *ScopedAccount + + // FeeRate specifies the desired fee rate for the transaction, + // expressed in satoshis per kilo-virtual-byte (sat/kvb). This field is + // required. + FeeRate SatPerKVByte + + // Label is an optional, human-readable label for the transaction. This + // can be used to associate a memo with the transaction for later + // reference. + Label string +} + +// Inputs is a sealed interface that defines the source of inputs for a +// transaction. It can either be a manually specified set of UTXOs or a policy +// for coin selection. The sealed interface pattern is used here to +// provide compile-time safety, ensuring that only the intended implementations +// can be used. +type Inputs interface { + // isInputs is a marker method that is part of the sealed interface + // pattern. It is unexported, so it can only be implemented by types + // within this package. This ensures that only the intended types + // can be used as an Inputs implementation. + isInputs() +} + +// InputsManual implements the Inputs interface and specifies the exact UTXOs +// to be used as transaction inputs. When this is used, all automatic coin +// selection logic is bypassed. +type InputsManual struct { + // UTXOs is a slice of outpoints to be used as the exact inputs for the + // transaction. The wallet will validate that these UTXOs are known and + // spendable but will not perform any further coin selection. + UTXOs []wire.OutPoint +} + +// InputsPolicy implements the Inputs interface and specifies the policy +// for coin selection by the wallet. +type InputsPolicy struct { + // Strategy is the algorithm to use for selecting coins (e.g., largest + // first, random). If this is nil, the wallet's default coin selection + // strategy will be used. + Strategy CoinSelectionStrategy + + // MinConfs is the minimum number of confirmations a UTXO must have to + // be considered eligible for coin selection. + MinConfs uint32 + + // Source specifies the pool of UTXOs to select from. If this is nil, + // the wallet will use a default source (e.g., the default account). + // Otherwise, this must be one of the CoinSource implementations. + Source CoinSource +} + +// isInputs marks InputsManual as an implementation of the Inputs interface. +func (*InputsManual) isInputs() {} + +// isInputs marks InputsPolicy as an implementation of the Inputs +// interface. +func (*InputsPolicy) isInputs() {} + +// A compile-time assertion to ensure that all types implementing the Inputs +// interface adhere to it. +var _ Inputs = (*InputsManual)(nil) +var _ Inputs = (*InputsPolicy)(nil) + +// CoinSource is a sealed interface that defines the pool of UTXOs available +// for coin selection. The sealed interface pattern ensures that only +// the intended implementations can be used. +type CoinSource interface { + // isCoinSource is a marker method that is part of the sealed interface + // pattern. It is unexported, so it can only be implemented by types + // within this package. This ensures that only the intended types + // can be used as a CoinSource implementation. + isCoinSource() +} + +// ScopedAccount defines a wallet account within a particular key scope. It is +// used to specify the source of funds for coin selection and the +// destination for change outputs. +type ScopedAccount struct { + // AccountName specifies the name of the account. This must be a + // non-empty string. + AccountName string + + // KeyScope specifies the key scope (e.g., P2WKH, P2TR). + KeyScope waddrmgr.KeyScope +} + +// CoinSourceUTXOs specifies that the wallet should select coins from a +// specific, predefined list of candidate UTXOs. +type CoinSourceUTXOs struct { + // UTXOs is a slice of outpoints from which the coin selection + // algorithm will choose. This list must not be empty. + UTXOs []wire.OutPoint +} + +// isCoinSource marks ScopedAccount as an implementation of the CoinSource +// interface. +func (ScopedAccount) isCoinSource() {} + +// isCoinSource marks CoinSourceUTXOs as an implementation of the CoinSource +// interface. +func (CoinSourceUTXOs) isCoinSource() {} + +// validateOutPoints checks a slice of `wire.OutPoint`s for emptiness and +// duplicate entries. It returns `ErrManualInputsEmpty` if the slice is empty +// and `ErrDuplicatedUtxo` if any duplicates are found. +func validateOutPoints(outpoints []wire.OutPoint) error { + if len(outpoints) == 0 { + return ErrManualInputsEmpty + } + + seenUTXOs := make(map[wire.OutPoint]struct{}) + for _, utxo := range outpoints { + if _, ok := seenUTXOs[utxo]; ok { + return ErrDuplicatedUtxo + } + + seenUTXOs[utxo] = struct{}{} + } + + return nil +} + +// A compile-time assertion to ensure that all types implementing the CoinSource +// interface adhere to it. +var _ CoinSource = (*ScopedAccount)(nil) +var _ CoinSource = (*CoinSourceUTXOs)(nil) From 4915d140d7a4da3f9be64b7eda4549891cfc0814 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 19 Sep 2025 03:25:29 +0800 Subject: [PATCH 097/691] wallet: add method `validateTxIntent` Which will be used in the next commit. --- wallet/tx_creator.go | 149 ++++++++++++++++++ wallet/tx_creator_test.go | 321 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 wallet/tx_creator_test.go diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index 8576d9e76b..0fda3ace0a 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -13,11 +13,13 @@ package wallet import ( "context" "errors" + "fmt" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" + "github.com/btcsuite/btcwallet/wallet/txrules" ) var ( @@ -28,6 +30,55 @@ var ( // ErrDuplicatedUtxo is returned when a UTXO is specified multiple // times. ErrDuplicatedUtxo = errors.New("duplicated utxo") + + // ErrUnsupportedTxInputs is returned when the `Inputs` field of a + // TxIntent is not of a supported type. + ErrUnsupportedTxInputs = errors.New("unsupported tx inputs type") + + // ErrUtxoNotEligible is returned when a UTXO is not eligible to be + // spent. + ErrUtxoNotEligible = errors.New("utxo not eligible to spend") + + // ErrAccountNotFound is returned when an account is not found. + ErrAccountNotFound = errors.New("account not found") + + // ErrNoTxOutputs is returned when a transaction is created without any + // outputs. + ErrNoTxOutputs = errors.New("tx has no outputs") + + // ErrFeeRateTooLarge is returned when a transaction is created with a + // fee rate that is larger than the configured max allowed fee rate. + // The default max fee rate is 1000 sat/vb. + ErrFeeRateTooLarge = errors.New("fee rate too large") + + // ErrMissingFeeRate is returned when a transaction is created without + // a fee rate. + ErrMissingFeeRate = errors.New("missing fee rate") + + // ErrMissingAccountName is returned when an account name is required + // but not provided. + ErrMissingAccountName = errors.New("account name cannot be empty") + + // ErrUnsupportedCoinSource is returned when the `Source` field of a + // CoinSelectionPolicy is not of a supported type. + ErrUnsupportedCoinSource = errors.New("unsupported coin source type") + + // ErrMissingInputs is returned when a transaction is created without + // any inputs. + ErrMissingInputs = errors.New("tx has no inputs") + + // ErrNilTxIntent is returned when a nil `TxIntent` is provided. + ErrNilTxIntent = errors.New("nil TxIntent") +) + +const ( + // DefaultMaxFeeRate is the default maximum fee rate in sat/kvb that + // the wallet will consider sane. This is currently set to 1000 sat/vb + // (1,000,000 sat/kvb). + // + // TODO(yy): The max fee rate should be made configurable as part of + // the WalletController interface implementation. + DefaultMaxFeeRate SatPerKVByte = 1000 * 1000 ) // TxCreator provides an interface for creating transactions. Its primary @@ -161,6 +212,11 @@ type Inputs interface { // within this package. This ensures that only the intended types // can be used as an Inputs implementation. isInputs() + + // validate performs a series of checks on the input source to ensure + // it is well-formed. This method is called before any database + // transactions are opened, allowing for early, efficient validation. + validate() error } // InputsManual implements the Inputs interface and specifies the exact UTXOs @@ -194,10 +250,42 @@ type InputsPolicy struct { // isInputs marks InputsManual as an implementation of the Inputs interface. func (*InputsManual) isInputs() {} +// validate performs validation on the manual inputs. +func (i *InputsManual) validate() error { + return validateOutPoints(i.UTXOs) +} + // isInputs marks InputsPolicy as an implementation of the Inputs // interface. func (*InputsPolicy) isInputs() {} +// validate performs validation on the input policy. +func (i *InputsPolicy) validate() error { + if i.Source == nil { + return nil + } + + switch source := i.Source.(type) { + // If the source is a scoped account, it must have a non-empty account + // name. + case *ScopedAccount: + if source.AccountName == "" { + return ErrMissingAccountName + } + + // If the source is a list of UTXOs, it must not be empty and must not + // contain duplicates. + case *CoinSourceUTXOs: + return validateOutPoints(source.UTXOs) + + // Any other source type is unsupported. + default: + return fmt.Errorf("%w: %T", ErrUnsupportedCoinSource, source) + } + + return nil +} + // A compile-time assertion to ensure that all types implementing the Inputs // interface adhere to it. var _ Inputs = (*InputsManual)(nil) @@ -266,3 +354,64 @@ func validateOutPoints(outpoints []wire.OutPoint) error { // interface adhere to it. var _ CoinSource = (*ScopedAccount)(nil) var _ CoinSource = (*CoinSourceUTXOs)(nil) + +// validateTxIntent performs a series of checks on a TxIntent to ensure it is +// well-formed. This function is called before any transaction creation logic +// to ensure that the caller has provided a valid intent. This function is for +// validation only and does not modify the TxIntent. +// +// The following checks are performed: +// - The intent must have at least one output. +// - Each output must not be a dust output. +// - If a change source is specified, it must have a non-empty account name. +// - The intent must have a valid, non-nil input source. +// - The input source itself is validated via the `validate` method. +func validateTxIntent(intent *TxIntent) error { + // The intent must have at least one output. + if len(intent.Outputs) == 0 { + return ErrNoTxOutputs + } + + // Each output must not be a dust output according to the default relay + // fee policy. + for _, output := range intent.Outputs { + err := txrules.CheckOutput( + &output, txrules.DefaultRelayFeePerKb, + ) + if err != nil { + return err + } + } + + // If a change source is specified, it must have a non-empty account + // name. + if intent.ChangeSource != nil && intent.ChangeSource.AccountName == "" { + return ErrMissingAccountName + } + + // If no input source is specified, an error is returned. + if intent.Inputs == nil { + return ErrMissingInputs + } + + // Validate the inputs. + err := intent.Inputs.validate() + if err != nil { + return err + } + + // The intent must have a non-zero fee rate. + if intent.FeeRate == 0 { + return ErrMissingFeeRate + } + + // Ensure the fee rate is not "insane". This prevents users from + // accidentally paying exorbitant fees. + if intent.FeeRate > DefaultMaxFeeRate { + return fmt.Errorf("%w: fee rate of %d sat/kvb is too high, "+ + "max sane fee rate is %d sat/kvb", ErrFeeRateTooLarge, + intent.FeeRate, DefaultMaxFeeRate) + } + + return nil +} diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go new file mode 100644 index 0000000000..1b32cc05e7 --- /dev/null +++ b/wallet/tx_creator_test.go @@ -0,0 +1,321 @@ +package wallet + +import ( + "errors" + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet/txrules" + "github.com/stretchr/testify/require" +) + +var ( + // errStrategy is used to simulate failures in coin selection + // strategies within tests. + errStrategy = errors.New("strategy error") + + // errDB is used to simulate database operation failures within tests. + errDB = errors.New("db error") +) + +// TestValidateTxIntent ensures that the validateTxIntent function returns +// errors for all expected invalid transaction intents, and that it returns nil +// for valid intents. The test covers a range of scenarios, including missing +// inputs or outputs, dust outputs, duplicate UTXOs, and invalid account or +// change source configurations. +func TestValidateTxIntent(t *testing.T) { + t.Parallel() + + // Define a set of valid outputs and inputs to be reused across test + // cases. + validOutput := wire.TxOut{Value: 10000, PkScript: []byte{}} + validUTXO := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + validAccountName := "default" + validScopedAccount := &ScopedAccount{ + AccountName: validAccountName, + KeyScope: waddrmgr.KeyScopeBIP0086, + } + + // Define the test cases, each representing a different scenario for + // validating a TxIntent. + testCases := []struct { + name string + intent *TxIntent + expectedErr error + }{ + { + name: "valid intent with manual inputs", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: nil, + }, + { + name: "valid intent with policy inputs " + + "(scoped account)", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsPolicy{ + Source: validScopedAccount, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: nil, + }, + { + name: "valid intent with policy inputs (utxo source)", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsPolicy{ + Source: &CoinSourceUTXOs{ + UTXOs: []wire.OutPoint{ + validUTXO, + }, + }, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: nil, + }, + { + name: "valid intent with nil source in policy", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsPolicy{Source: nil}, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: nil, + }, + { + name: "invalid intent - nil inputs", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: nil, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: ErrMissingInputs, + }, + { + name: "invalid intent - no outputs", + intent: &TxIntent{ + Outputs: []wire.TxOut{}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: ErrNoTxOutputs, + }, + { + name: "invalid intent - dust output", + intent: &TxIntent{ + Outputs: []wire.TxOut{{Value: 1}}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: txrules.ErrOutputIsDust, + }, + { + name: "invalid intent - empty manual inputs", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: ErrManualInputsEmpty, + }, + { + name: "invalid intent - duplicate manual inputs", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{ + validUTXO, validUTXO, + }, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: ErrDuplicatedUtxo, + }, + { + name: "invalid intent - empty account name in source", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsPolicy{ + Source: &ScopedAccount{AccountName: ""}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: ErrMissingAccountName, + }, + { + name: "invalid intent - empty utxo list in source", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsPolicy{ + Source: &CoinSourceUTXOs{ + UTXOs: []wire.OutPoint{}, + }, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: ErrManualInputsEmpty, + }, + { + name: "invalid intent - duplicate utxos in policy", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsPolicy{ + Source: &CoinSourceUTXOs{ + UTXOs: []wire.OutPoint{ + validUTXO, validUTXO, + }, + }, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: ErrDuplicatedUtxo, + }, + { + name: "invalid intent - unsupported coin source", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsPolicy{ + Source: &unsupportedCoinSource{}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: ErrUnsupportedCoinSource, + }, + { + name: "invalid intent - unsupported inputs type", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &unsupportedInputs{}, + ChangeSource: &ScopedAccount{ + AccountName: "default", + }, + FeeRate: 1000, + }, + expectedErr: nil, + }, + { + name: "invalid intent - empty account name in change", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "", + KeyScope: waddrmgr.KeyScopeBIP0086, + }, + FeeRate: 1000, + }, + expectedErr: ErrMissingAccountName, + }, + { + name: "invalid intent - zero fee rate", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0086, + }, + FeeRate: 0, + }, + expectedErr: ErrMissingFeeRate, + }, + { + name: "invalid intent - insane fee rate", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0086, + }, + FeeRate: DefaultMaxFeeRate + 1, + }, + expectedErr: ErrFeeRateTooLarge, + }, + } + + // Iterate through all test cases and run them. + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Call the validate function and check that the error + // matches the expected error. + err := validateTxIntent(tc.intent) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +// unsupportedInputs is a mock implementation of the Inputs interface used for +// testing purposes. +type unsupportedInputs struct{} + +func (u *unsupportedInputs) isInputs() {} +func (u *unsupportedInputs) validate() error { return nil } + +// unsupportedCoinSource is a mock implementation of the CoinSource interface +// used for testing purposes. +type unsupportedCoinSource struct{} + +func (u *unsupportedCoinSource) isCoinSource() {} From d3b2b9bcf7095b47a2bac90c8e805bc0ca2b3c8b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 22 Sep 2025 20:29:29 +0800 Subject: [PATCH 098/691] wallet: add `getEligibleUTXOs` to select inputs based on input policy These are helper methods to be used in the next commit. --- wallet/mock_test.go | 23 ++++ wallet/tx_creator.go | 111 +++++++++++++++ wallet/tx_creator_test.go | 279 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 413 insertions(+) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 766fcc43d7..01ad74a060 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -163,6 +163,11 @@ func (m *mockTxStore) UnspentOutputs( ns walletdb.ReadBucket) ([]wtxmgr.Credit, error) { args := m.Called(ns) + + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]wtxmgr.Credit), args.Error(1) } @@ -753,3 +758,21 @@ func (m *mockManagedAddress) DerivationInfo() ( return args.Get(0).(waddrmgr.KeyScope), args.Get(1).(waddrmgr.DerivationPath), args.Bool(2) } + +// mockCoinSelectionStrategy is a mock implementation of the +// CoinSelectionStrategy interface used for testing purposes. +type mockCoinSelectionStrategy struct { + mock.Mock +} + +// ArrangeCoins implements the CoinSelectionStrategy interface. +func (m *mockCoinSelectionStrategy) ArrangeCoins(coins []Coin, + feePerKb btcutil.Amount) ([]Coin, error) { + + args := m.Called(coins, feePerKb) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).([]Coin), args.Error(1) +} \ No newline at end of file diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index 0fda3ace0a..eb2ef5d4a3 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -20,6 +20,8 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" ) var ( @@ -415,3 +417,112 @@ func validateTxIntent(intent *TxIntent) error { return nil } + +// getEligibleUTXOs returns a slice of eligible UTXOs that can be used as +// inputs for a transaction, based on the specified source and confirmation +// requirements. A UTXO is considered ineligible if it is not found in the +// wallet's transaction store or if it does not meet the minimum confirmation +// requirements. +func (w *Wallet) getEligibleUTXOs(dbtx walletdb.ReadTx, + source CoinSource, minconf uint32) ([]wtxmgr.Credit, error) { + + // TODO(yy): remove this requireChainClient. The block stamp should be + // passed in as a parameter. + chainClient, err := w.requireChainClient() + if err != nil { + return nil, err + } + + // Get the current block's height and hash. This is needed to determine + // the number of confirmations for UTXOs. + bs, err := chainClient.BlockStamp() + if err != nil { + return nil, err + } + + // Dispatch based on the type of the coin source. + switch source := source.(type) { + // If the source is nil, we'll use the default account. + case nil: + return w.findEligibleOutputs( + dbtx, &waddrmgr.KeyScopeBIP0086, + waddrmgr.DefaultAccountNum, int32(minconf), bs, nil, + ) + + // If the source is a scoped account, we find all eligible outputs for + // that specific account and key scope. + case *ScopedAccount: + return w.getEligibleUTXOsFromAccount(dbtx, source, minconf, bs) + + // If the source is a list of UTXOs, we validate and fetch each UTXO + // from the provided list. + case *CoinSourceUTXOs: + return w.getEligibleUTXOsFromList(dbtx, source, minconf, bs) + + // Any other source type is unsupported. + default: + return nil, ErrUnsupportedCoinSource + } +} + +// getEligibleUTXOsFromAccount returns a slice of eligible UTXOs for a specific +// account and key scope. +func (w *Wallet) getEligibleUTXOsFromAccount(dbtx walletdb.ReadTx, + source *ScopedAccount, minconf uint32, bs *waddrmgr.BlockStamp) ( + []wtxmgr.Credit, error) { + + keyScope := &source.KeyScope + + account, err := w.AccountNumber(*keyScope, source.AccountName) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrAccountNotFound, + source.AccountName) + } + + return w.findEligibleOutputs( + dbtx, keyScope, account, int32(minconf), bs, nil, + ) +} + +// getEligibleUTXOsFromList returns a slice of eligible UTXOs from a specified +// list of outpoints. +func (w *Wallet) getEligibleUTXOsFromList(dbtx walletdb.ReadTx, + source *CoinSourceUTXOs, minconf uint32, bs *waddrmgr.BlockStamp) ( + []wtxmgr.Credit, error) { + + // Create a slice to hold the eligible UTXOs. + eligible := make([]wtxmgr.Credit, 0, len(source.UTXOs)) + + // Get the transaction manager's namespace. + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + // Iterate through the manually specified UTXOs and ensure that each + // one is eligible for spending. + for _, outpoint := range source.UTXOs { + // Fetch the UTXO from the database. + credit, err := w.txStore.GetUtxo(txmgrNs, outpoint) + if err != nil { + return nil, fmt.Errorf("%w: %v", + ErrUtxoNotEligible, outpoint) + } + + // A UTXO is only eligible if it has reached the required + // number of confirmations. + if !confirmed(int32(minconf), credit.Height, bs.Height) { + // Calculate the number of confirmations for the + // warning message. + confs := calcConf(bs.Height, credit.Height) + + log.Warnf("Skipping user-specified UTXO %v "+ + "because it has %d confs but needs %d", + credit.OutPoint, confs, minconf) + + continue + } + + // If the UTXO is eligible, add it to the list. + eligible = append(eligible, *credit) + } + + return eligible, nil +} diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index 1b32cc05e7..f0854c0d79 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -7,6 +7,9 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txrules" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -319,3 +322,279 @@ func (u *unsupportedInputs) validate() error { return nil } type unsupportedCoinSource struct{} func (u *unsupportedCoinSource) isCoinSource() {} + +type mockReadBucket struct { + walletdb.ReadBucket +} + +type mockReadTx struct { + walletdb.ReadTx +} + +func (m *mockReadTx) ReadBucket(key []byte) walletdb.ReadBucket { + return &mockReadBucket{} +} + +// TestGetEligibleUTXOsFromList tests that the getEligibleUTXOsFromList method +// correctly filters a list of UTXOs based on their confirmation status. It +// ensures that UTXOs with sufficient confirmations are included, while those +// that are unconfirmed or do not meet the minimum confirmation requirement are +// excluded. The test also verifies that an error is returned if a specified +// UTXO is not found in the wallet. +func TestGetEligibleUTXOsFromList(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + + // Define a block stamp for the current chain height. + currentHeight := int32(100) + blockStamp := &waddrmgr.BlockStamp{ + Height: currentHeight, + } + + // Define some UTXOs. + // This UTXO has 1 confirmation. + utxo1 := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + + // This UTXO has 6 confirmations. + utxo2 := wire.OutPoint{Hash: [32]byte{2}, Index: 0} + + // This UTXO is unconfirmed. + utxo3 := wire.OutPoint{Hash: [32]byte{3}, Index: 0} + + // This UTXO is not found. + utxo4 := wire.OutPoint{Hash: [32]byte{4}, Index: 0} + + // Define the corresponding credits. + credit1 := &wtxmgr.Credit{ + OutPoint: utxo1, + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + // 1 conf = 100 - 100 + 1. + Height: currentHeight, + }, + }, + } + credit2 := &wtxmgr.Credit{ + OutPoint: utxo2, + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + // 6 confs = 100 - 95 + 1. + Height: currentHeight - 5, + }, + }, + } + credit3 := &wtxmgr.Credit{ + OutPoint: utxo3, + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + // Unconfirmed. + Height: -1, + }, + }, + } + + // Set up mock calls for txStore.GetUtxo. + mocks.txStore.On("GetUtxo", mock.Anything, utxo1).Return(credit1, nil) + mocks.txStore.On("GetUtxo", mock.Anything, utxo2).Return(credit2, nil) + mocks.txStore.On("GetUtxo", mock.Anything, utxo3).Return(credit3, nil) + mocks.txStore.On("GetUtxo", mock.Anything, utxo4).Return( + nil, wtxmgr.ErrUtxoNotFound, + ) + + testCases := []struct { + name string + source *CoinSourceUTXOs + minconf uint32 + expectedUtxos []wtxmgr.Credit + expectedErr error + }{ + { + name: "all utxos with minconf 0", + source: &CoinSourceUTXOs{ + UTXOs: []wire.OutPoint{utxo1, utxo2, utxo3}, + }, + minconf: 0, + expectedUtxos: []wtxmgr.Credit{ + *credit1, *credit2, *credit3, + }, + }, + { + name: "1 conf required", + source: &CoinSourceUTXOs{ + UTXOs: []wire.OutPoint{utxo1, utxo2, utxo3}, + }, + minconf: 1, + expectedUtxos: []wtxmgr.Credit{*credit1, *credit2}, + }, + { + name: "6 confs required", + source: &CoinSourceUTXOs{ + UTXOs: []wire.OutPoint{utxo1, utxo2, utxo3}, + }, + minconf: 6, + expectedUtxos: []wtxmgr.Credit{*credit2}, + }, + { + name: "7 confs required", + source: &CoinSourceUTXOs{ + UTXOs: []wire.OutPoint{utxo1, utxo2, utxo3}, + }, + minconf: 7, + expectedUtxos: []wtxmgr.Credit{}, + }, + { + name: "utxo not found", + source: &CoinSourceUTXOs{ + UTXOs: []wire.OutPoint{utxo1, utxo4}, + }, + minconf: 1, + expectedErr: ErrUtxoNotEligible, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dbtx := &mockReadTx{} + utxos, err := w.getEligibleUTXOsFromList( + dbtx, tc.source, tc.minconf, blockStamp, + ) + + require.ErrorIs(t, err, tc.expectedErr) + + if err == nil { + require.ElementsMatch( + t, tc.expectedUtxos, utxos, + ) + } + }) + } +} + +// TestGetEligibleUTXOsFromAccount tests that the getEligibleUTXOsFromAccount +// method correctly returns an ErrAccountNotFound when the specified account +// does not exist. This ensures that the function properly handles cases where +// UTXOs are requested from a non-existent account. +func TestGetEligibleUTXOsFromAccount(t *testing.T) { + t.Parallel() + + // Define a block stamp for the current chain height. + blockStamp := &waddrmgr.BlockStamp{ + Height: 100, + } + + keyScope := waddrmgr.KeyScopeBIP0086 + minconf := uint32(1) + + w, mocks := testWalletWithMocks(t) + accountStore := &mockAccountStore{} + mocks.addrStore.On("FetchScopedKeyManager", keyScope). + Return(accountStore, nil) + + // We need to define the error type explicitly to avoid mock panics. + errNotFound := waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAccountNotFound, + } + accountStore.On("LookupAccount", mock.Anything, "unknown"). + Return(uint32(0), errNotFound) + + _, err := w.getEligibleUTXOsFromAccount( + &mockReadTx{}, + &ScopedAccount{ + AccountName: "unknown", + KeyScope: keyScope, + }, + minconf, blockStamp, + ) + require.ErrorIs(t, err, ErrAccountNotFound) +} + +// TestGetEligibleUTXOs serves as a comprehensive test suite for the +// getEligibleUTXOs method, which acts as a dispatcher based on the provided +// CoinSource type. This test ensures that the method correctly delegates to the +// appropriate sub-handler for each source type (scoped account, UTXO list, or +// nil for default) and that it properly returns an error for unsupported +// source types. +func TestGetEligibleUTXOs(t *testing.T) { + t.Parallel() + + minconf := uint32(1) + utxo := wire.OutPoint{} + credit := &wtxmgr.Credit{} + scopedAccount := &ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0086, + } + + testCases := []struct { + name string + source CoinSource + setupMocks func(m *mockers, source CoinSource) + expectedErr error + }{ + { + name: "scoped account", + source: scopedAccount, + setupMocks: func( + m *mockers, source CoinSource, + ) { + scopedSrc := source.(*ScopedAccount) + accountStore := &mockAccountStore{} + + m.addrStore.On("FetchScopedKeyManager", + scopedSrc.KeyScope, + ).Return(accountStore, nil) + + accountStore.On("LookupAccount", + mock.Anything, scopedSrc.AccountName, + ).Return(uint32(0), nil) + + m.txStore.On("UnspentOutputs", + mock.Anything, + ).Return([]wtxmgr.Credit{}, nil) + }, + }, + { + name: "utxo source", + source: &CoinSourceUTXOs{ + UTXOs: []wire.OutPoint{utxo}, + }, + setupMocks: func(m *mockers, source CoinSource) { + m.txStore.On("GetUtxo", mock.Anything, utxo). + Return(credit, nil) + }, + }, + { + name: "nil source", + source: nil, + setupMocks: func(m *mockers, source CoinSource) { + m.txStore.On("UnspentOutputs", + mock.Anything, + ).Return([]wtxmgr.Credit{}, nil) + }, + }, + { + name: "unsupported source", + source: &unsupportedCoinSource{}, + setupMocks: func(m *mockers, source CoinSource) {}, + expectedErr: ErrUnsupportedCoinSource, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + tc.setupMocks(mocks, tc.source) + + _, err := w.getEligibleUTXOs( + &mockReadTx{}, tc.source, minconf, + ) + + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} From 4e4b14953325083b4b41a09623ff764920420392 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 22 Sep 2025 20:31:29 +0800 Subject: [PATCH 099/691] wallet: add `createInputSource` to prepare inputs for the tx This is a helper method to be used in the next commit. --- wallet/tx_creator.go | 119 ++++++++++++++++++ wallet/tx_creator_test.go | 254 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+) diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index eb2ef5d4a3..4f879f45e9 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -418,6 +418,125 @@ func validateTxIntent(intent *TxIntent) error { return nil } +// createInputSource creates a txauthor.InputSource that will be used to select +// inputs for a transaction. It acts as a dispatcher, delegating to either the +// manual or policy-based input source creator based on the type of the intent's +// Inputs field. +// +// TODO(yy): We use customized queries here to make the utxo lookups atomic +// inside a big tx that's created in `CreateTransaction`, however, we should +// instead have methods made on the `txStore`, which takes a db tx and use them +// here, as the logic will be largely overlapped with the interface methods used +// in `wallet/utxo_manager.go`. +func (w *Wallet) createInputSource(dbtx walletdb.ReadTx, intent *TxIntent) ( + txauthor.InputSource, error) { + + switch inputs := intent.Inputs.(type) { + // If the inputs are manually specified, we create a "constant" input + // source that will only ever return the specified UTXOs. + case *InputsManual: + return w.createManualInputSource(dbtx, inputs) + + // If the inputs are policy-based, we create an input source that will + // perform coin selection. + case *InputsPolicy: + return w.createPolicyInputSource(dbtx, inputs, intent.FeeRate) + + // Any other type is unsupported. + default: + return nil, ErrUnsupportedTxInputs + } +} + +// createManualInputSource creates an input source from a list of manually +// specified UTXOs. It fetches the UTXOs directly from the database and ensures +// that they are eligible for spending. +func (w *Wallet) createManualInputSource(dbtx walletdb.ReadTx, + inputs *InputsManual) ( + txauthor.InputSource, error) { + + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + // Create a slice to hold the eligible UTXOs. + eligibleSelectedUtxo := make( + []wtxmgr.Credit, 0, len(inputs.UTXOs), + ) + + // Iterate through the manually specified UTXOs and ensure that each + // one is eligible for spending. + for _, outpoint := range inputs.UTXOs { + // Fetch the UTXO from the database. + credit, err := w.txStore.GetUtxo(txmgrNs, outpoint) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUtxoNotEligible, + outpoint) + } + + // TODO(yy): check for locked utxos and log a warning. + eligibleSelectedUtxo = append(eligibleSelectedUtxo, *credit) + } + + // Return a constant input source that will only provide the selected + // UTXOs. + return constantInputSource(eligibleSelectedUtxo), nil +} + +// createPolicyInputSource creates an input source that will perform automatic +// coin selection based on the provided policy. +func (w *Wallet) createPolicyInputSource(dbtx walletdb.ReadTx, + policy *InputsPolicy, feeRate SatPerKVByte) ( + txauthor.InputSource, error) { + + // Fall back to the default coin selection strategy if none is supplied. + strategy := policy.Strategy + if strategy == nil { + strategy = CoinSelectionLargest + } + + // Get the full set of eligible UTXOs based on the policy's source + // and confirmation requirements. + eligible, err := w.getEligibleUTXOs( + dbtx, policy.Source, policy.MinConfs, + ) + if err != nil { + return nil, err + } + + // Wrap our wtxmgr.Credit coins in a `Coin` type that implements the + // SelectableCoin interface. This allows the coin selection strategy + // to operate on them. + // + // TODO(yy): unify the types here - we should use `Utxo` instead of + // `Credit` or `Coin`. + wrappedEligible := make([]Coin, len(eligible)) + for i := range eligible { + wrappedEligible[i] = Coin{ + TxOut: wire.TxOut{ + Value: int64( + eligible[i].Amount, + ), + PkScript: eligible[i].PkScript, + }, + OutPoint: eligible[i].OutPoint, + } + } + + // Arrange the eligible coins according to the chosen strategy (e.g., + // sort by largest first, or shuffle for random selection). + feeSatPerKb := btcutil.Amount(feeRate) + + arrangedCoins, err := strategy.ArrangeCoins( + wrappedEligible, feeSatPerKb, + ) + if err != nil { + return nil, err + } + + // Return an input source that will dispense the arranged coins one by + // one as requested by the txauthor. + return makeInputSource(arrangedCoins), nil +} + // getEligibleUTXOs returns a slice of eligible UTXOs that can be used as // inputs for a transaction, based on the specified source and confirmation // requirements. A UTXO is considered ineligible if it is not found in the diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index f0854c0d79..f7808dabc1 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -598,3 +598,257 @@ func TestGetEligibleUTXOs(t *testing.T) { }) } } + +// TestCreateManualInputSource verifies that the createManualInputSource +// function correctly creates an input source from a manually specified list of +// UTXOs. It tests the success path, where all UTXOs are valid and spendable, +// and the failure path, where a UTXO is not found in the wallet, ensuring that +// the function returns the expected error in that case. +func TestCreateManualInputSource(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + dbtx := &mockReadTx{} + + utxo1 := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + credit1 := &wtxmgr.Credit{OutPoint: utxo1} + + utxo2 := wire.OutPoint{Hash: [32]byte{2}, Index: 0} + + testCases := []struct { + name string + inputs *InputsManual + setupMocks func() + expectedErr error + }{ + { + name: "success", + inputs: &InputsManual{ + UTXOs: []wire.OutPoint{utxo1}, + }, + setupMocks: func() { + mocks.txStore.On("GetUtxo", + mock.Anything, utxo1, + ).Return(credit1, nil).Once() + }, + }, + { + name: "utxo not found", + inputs: &InputsManual{ + UTXOs: []wire.OutPoint{utxo2}, + }, + setupMocks: func() { + mocks.txStore.On("GetUtxo", + mock.Anything, utxo2, + ).Return(nil, wtxmgr.ErrUtxoNotFound).Once() + }, + expectedErr: ErrUtxoNotEligible, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tc.setupMocks() + + source, err := w.createManualInputSource( + dbtx, tc.inputs, + ) + + require.ErrorIs(t, err, tc.expectedErr) + + if err == nil { + require.NotNil(t, source) + } else { + require.Nil(t, source) + } + }) + } +} + +// TestCreatePolicyInputSource tests the functionality of the +// createPolicyInputSource method. It ensures that the method correctly creates +// an input source for coin selection based on a given policy. The test covers +// scenarios where a default coin selection strategy is used, as well as cases +// with a custom strategy. It also verifies that errors from underlying +// dependencies, such as the database or the coin selection strategy itself, are +// properly propagated. +func TestCreatePolicyInputSource(t *testing.T) { + t.Parallel() + + dbtx := &mockReadTx{} + feeRate := SatPerKVByte(1000) + + utxo1 := wtxmgr.Credit{ + OutPoint: wire.OutPoint{Hash: [32]byte{1}, Index: 0}, + } + utxo2 := wtxmgr.Credit{ + OutPoint: wire.OutPoint{Hash: [32]byte{2}, Index: 0}, + } + eligibleUtxos := []wtxmgr.Credit{utxo1, utxo2} + + // A mock strategy that just returns the coins as is. + mockStrategy := &mockCoinSelectionStrategy{} + mockStrategy.On("ArrangeCoins", mock.Anything, mock.Anything). + Return(make([]Coin, 0), nil) + + // A mock strategy that returns an error. + errCoinSelection := &mockCoinSelectionStrategy{} + errCoinSelection.On("ArrangeCoins", mock.Anything, mock.Anything). + Return(([]Coin)(nil), errStrategy) + + testCases := []struct { + name string + policy *InputsPolicy + setupMocks func(m *mockers) + expectedErr error + }{ + { + name: "success with default strategy", + policy: &InputsPolicy{ + // Should default to default account + Source: nil, + MinConfs: 1, + }, + setupMocks: func(m *mockers) { + m.txStore.On("UnspentOutputs", mock.Anything). + Return(eligibleUtxos, nil).Once() + }, + }, + { + name: "success with custom strategy", + policy: &InputsPolicy{ + Strategy: mockStrategy, + Source: nil, + MinConfs: 1, + }, + setupMocks: func(m *mockers) { + m.txStore.On("UnspentOutputs", mock.Anything). + Return(eligibleUtxos, nil).Once() + }, + }, + { + name: "getEligibleUTXOs fails on UnspentOutputs", + policy: &InputsPolicy{ + Source: nil, + MinConfs: 1, + }, + setupMocks: func(m *mockers) { + m.txStore.On("UnspentOutputs", + mock.Anything, + ).Return(nil, errDB).Once() + }, + expectedErr: errDB, + }, + { + name: "strategy ArrangeCoins fails", + policy: &InputsPolicy{ + Strategy: errCoinSelection, + Source: nil, + MinConfs: 1, + }, + setupMocks: func(m *mockers) { + m.txStore.On("UnspentOutputs", mock.Anything). + Return(eligibleUtxos, nil).Once() + }, + expectedErr: errStrategy, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + tc.setupMocks(mocks) + + source, err := w.createPolicyInputSource( + dbtx, tc.policy, feeRate, + ) + + if tc.expectedErr != nil { + require.Error(t, err) + require.Contains(t, err.Error(), + tc.expectedErr.Error()) + require.Nil(t, source) + } else { + require.NoError(t, err) + require.NotNil(t, source) + } + }) + } +} + +// TestCreateInputSource serves as a dispatcher test for the createInputSource +// method. It verifies that the method correctly delegates to the appropriate +// specialized input source creator—either for manual or policy-based coin +// selection—based on the type of the `Inputs` field in the `TxIntent`. The test +// also ensures that an `ErrUnsupportedTxInputs` error is returned if an +// unknown input type is provided. +func TestCreateInputSource(t *testing.T) { + t.Parallel() + + dbtx := &mockReadTx{} + + utxo := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + credit := &wtxmgr.Credit{OutPoint: utxo} + + manualInputs := &InputsManual{UTXOs: []wire.OutPoint{utxo}} + policyInputs := &InputsPolicy{} + unsupported := &unsupportedInputs{} + + intentManual := &TxIntent{Inputs: manualInputs} + intentPolicy := &TxIntent{Inputs: policyInputs, FeeRate: 1000} + intentUnsupported := &TxIntent{Inputs: unsupported} + + testCases := []struct { + name string + intent *TxIntent + setupMocks func(m *mockers) + expectedErr error + }{ + { + name: "manual inputs", + intent: intentManual, + setupMocks: func(m *mockers) { + m.txStore.On("GetUtxo", mock.Anything, utxo). + Return(credit, nil).Once() + }, + }, + { + name: "policy inputs", + intent: intentPolicy, + setupMocks: func(m *mockers) { + m.txStore.On("UnspentOutputs", + mock.Anything, + ).Return([]wtxmgr.Credit{*credit}, nil).Once() + }, + }, + { + name: "unsupported inputs", + intent: intentUnsupported, + setupMocks: func(m *mockers) {}, + expectedErr: ErrUnsupportedTxInputs, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + tc.setupMocks(mocks) + + source, err := w.createInputSource(dbtx, tc.intent) + + require.ErrorIs(t, err, tc.expectedErr) + + if err == nil { + require.NotNil(t, source) + } else { + require.Nil(t, source) + } + }) + } +} From a77f57fda401c3d37982f73c1152a01efdcf6c87 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 22 Sep 2025 20:33:50 +0800 Subject: [PATCH 100/691] wallet: implement `CreateTransaction` This new CreateTransaction method is a significant architectural improvement over the legacy CreateSimpleTx function. It replaces a long, inflexible parameter list with a single, explicit TxIntent struct, making the API cleaner, safer, and more extensible. The primary motivations for this change are: - Improved API Design & Clarity: The TxIntent struct acts as a builder, bundling all parameters into a clear blueprint. This makes the caller's goal self-documenting, replacing an ambiguous mix of direct parameters, nil-able pointers, and functional options. - Extensibility via Sealed Interfaces: By using the Inputs and CoinSource sealed interfaces, we can add new coin selection strategies or sources in the future without modifying the core CreateTransaction signature. This adheres to the Open/Closed Principle. - Centralized Validation: Logic for validating outputs, fees, and input sources is now centralized within validateTxIntent, reducing code duplication and ensuring consistent checks for all callers, such as lnwallet. The lnwallet usage of the old CreateSimpleTx highlights the need for this change, as it requires manual fee conversion and sanity checks that are now handled by the new, more robust method. The path forward is to refactor callers to use CreateTransaction and deprecate CreateSimpleTx. --- wallet/tx_creator.go | 126 +++++++++++++++++++++++++++++ wallet/tx_creator_test.go | 161 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index 4f879f45e9..f366ee57b8 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -94,6 +94,9 @@ type TxCreator interface { *txauthor.AuthoredTx, error) } +// A compile time check to ensure that Wallet implements the interface. +var _ TxCreator = (*Wallet)(nil) + // SatPerKVByte is a type that represents a fee rate in satoshis per // kilo-virtual-byte. This is the standard unit for fee estimation in modern // Bitcoin transactions that use SegWit. @@ -418,6 +421,129 @@ func validateTxIntent(intent *TxIntent) error { return nil } +// CreateTransaction creates a new unsigned transaction spending unspent outputs +// to the given outputs. It is the main implementation of the TxCreator +// interface. The method will produce a valid, unsigned transaction, which can +// then be passed to the Signer interface to be signed. +func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( + *txauthor.AuthoredTx, error) { + + // Check that the intent is not nil. + if intent == nil { + return nil, ErrNilTxIntent + } + + // If no input source is specified, an auto coin selection with the + // default account will be used. + if intent.Inputs == nil { + log.Debug("No input source specified, using default policy " + + "for automatic coin selection") + + intent.Inputs = &InputsPolicy{} + } + + err := validateTxIntent(intent) + if err != nil { + return nil, err + } + + // The addrMgrWithChangeSource function of the wallet creates a new + // change address. The address manager uses OnCommit on the walletdb tx + // to update the in-memory state of the account state. But because the + // commit happens _after_ the account manager internal lock has been + // released, there is a chance for the address index to be accessed + // concurrently, even though the closure in OnCommit re-acquires the + // lock. To avoid this issue, we surround the whole address creation + // process with a lock. + w.newAddrMtx.Lock() + defer w.newAddrMtx.Unlock() + + var ( + changeSource *txauthor.ChangeSource + inputSource txauthor.InputSource + ) + + // We perform the core logic of creating the input and change sources + // within a single database transaction to ensure atomicity. + err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { + changeKeyScope := &intent.ChangeSource.KeyScope + accountName := intent.ChangeSource.AccountName + + // Query the account's number using the account name. + // + // TODO(yy): Remove this query in upcoming SQL. + account, err := w.AccountNumber(*changeKeyScope, accountName) + if err != nil { + return fmt.Errorf("%w: %s", ErrAccountNotFound, + accountName) + } + + // Create the change source, which is a closure that the + // txauthor package will use to generate a new change address + // when needed. + // + // TODO(yy): Refactor to ensure atomicity. The underlying + // `GetUnusedAddress` call creates its own database + // transaction, breaking the atomicity of this + // `walletdb.Update` block. A new method should be added to + // `AccountStore` that accepts an active database transaction + // and returns an unused address. This will allow the address + // derivation to occur within the same atomic transaction as + // the rest of the tx creation logic. Once fixed, we can remove + // the above `w.newAddrMtx` lock. + _, changeSource, err = w.addrMgrWithChangeSource( + dbtx, changeKeyScope, account, + ) + if err != nil { + return err + } + + // Create the input source, which is a closure that the + // txauthor package will use to select coins. + inputSource, err = w.createInputSource(dbtx, intent) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + // The txauthor.NewUnsignedTransaction function expects a slice of + // *wire.TxOut, but our intent has a slice of wire.TxOut. We perform + // the conversion here. + // + // TODO(yy): change the signature of `NewUnsignedTransaction` to take a + // list of `wire.TxOut`. + outputs := make([]*wire.TxOut, 0, len(intent.Outputs)) + for _, output := range intent.Outputs { + outputs = append(outputs, &output) + } + + // With the input source and change source prepared, we can now call the + // txauthor package to perform the actual coin selection and create the + // unsigned transaction. + feeSatPerKb := btcutil.Amount(intent.FeeRate) + + tx, err := txauthor.NewUnsignedTransaction( + outputs, feeSatPerKb, inputSource, changeSource, + ) + if err != nil { + return nil, err + } + + // Randomize the position of the change output, if one was created. This + // helps to improve privacy by making it harder to distinguish change + // outputs from other outputs. + if tx.ChangeIndex >= 0 { + tx.RandomizeChangePosition() + } + + return tx, nil +} + // createInputSource creates a txauthor.InputSource that will be used to select // inputs for a transaction. It acts as a dispatcher, delegating to either the // manual or policy-based input source creator based on the type of the intent's diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index f7808dabc1..e30300a786 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -1,9 +1,13 @@ package wallet import ( + "context" "errors" "testing" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txrules" @@ -852,3 +856,160 @@ func TestCreateInputSource(t *testing.T) { }) } } + +// TestCreateTransaction provides an end-to-end test of the CreateTransaction +// method. It covers the main success path for creating a transaction with +// manually specified inputs, ensuring that all dependencies are called as +// expected. It also includes failure cases to verify that errors, such as an +// invalid transaction intent or a database-level account lookup failure, are +// handled correctly and propagated to the caller. +func TestCreateTransaction(t *testing.T) { + t.Parallel() + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(privKey.PubKey().SerializeCompressed()), + &chainParams, + ) + require.NoError(t, err) + validPkScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + // Common variables for test cases + validOutput := wire.TxOut{Value: 10000, PkScript: validPkScript} + validUTXO := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + + changeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + changeAddr, err := btcutil.NewAddressPubKey( + changeKey.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + + mockChangeAddr := &mockManagedAddress{} + mockChangeAddr.On("Address").Return(changeAddr) + mockChangeAddr.On("Internal").Return(true) + mockChangeAddr.On("Compressed").Return(true) + mockChangeAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + mockChangeAddr.On("InternalAccount").Return(uint32(0)) + mockChangeAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0086, waddrmgr.DerivationPath{}, true, + ) + + credit := &wtxmgr.Credit{ + OutPoint: validUTXO, + Amount: btcutil.Amount(50000), // Generous amount + PkScript: []byte{4, 5, 6}, + } + + testCases := []struct { + name string + intent *TxIntent + setupMocks func(m *mockers) + expectedErr error + }{ + { + name: "success manual inputs", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0086, + }, + FeeRate: 1000, + }, + setupMocks: func(m *mockers) { + accountStore := &mockAccountStore{} + m.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0086, + ).Return(accountStore, nil) + + accountStore.On("LookupAccount", + mock.Anything, "default", + ).Return(uint32(0), nil) + + accountProps := &waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + } + accountStore.On("AccountProperties", + mock.Anything, uint32(0), + ).Return(accountProps, nil) + + accountStore.On( + "NextInternalAddresses", mock.Anything, + uint32(0), uint32(1), + ).Return( + []waddrmgr.ManagedAddress{ + mockChangeAddr, + }, nil, + ) + + m.txStore.On("GetUtxo", + mock.Anything, validUTXO, + ).Return(credit, nil) + }, + }, + { + name: "invalid intent", + intent: &TxIntent{ + Outputs: []wire.TxOut{}, // No outputs + }, + setupMocks: func(m *mockers) {}, + expectedErr: ErrNoTxOutputs, + }, + { + name: "account not found", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "unknown", + KeyScope: waddrmgr.KeyScopeBIP0086, + }, + FeeRate: 1000, + }, + setupMocks: func(m *mockers) { + accountStore := &mockAccountStore{} + m.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0086).Return( + accountStore, nil, + ) + errNotFound := waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAccountNotFound, + } + accountStore.On("LookupAccount", + mock.Anything, "unknown", + ).Return(uint32(0), errNotFound) + }, + expectedErr: ErrAccountNotFound, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + tc.setupMocks(mocks) + + tx, err := w.CreateTransaction( + context.Background(), tc.intent, + ) + + require.ErrorIs(t, err, tc.expectedErr) + + if err == nil { + require.NotNil(t, tx) + } else { + require.Nil(t, tx) + } + }) + } +} From 67d14f15133d8c855018d76db2f99b9f717574be Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Sep 2025 01:51:46 +0800 Subject: [PATCH 101/691] wallet: introduce interface `TxPublisher` --- wallet/tx_publisher.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 wallet/tx_publisher.go diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go new file mode 100644 index 0000000000..a04100d0b1 --- /dev/null +++ b/wallet/tx_publisher.go @@ -0,0 +1,36 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +// Package wallet provides a bitcoin wallet implementation that is ready for +// use. +// +// TODO(yy): bring wrapcheck back when implementing the `Store` interface. +// +//nolint:wrapcheck +package wallet + +import ( + "context" + "errors" + + "github.com/btcsuite/btcd/wire" +) + +var ( + // ErrMempoolAccept is a sentinel error used to indicate that the + // mempool acceptance test returned an unexpected number of results. + ErrMempoolAccept = errors.New( + "expected 1 result from TestMempoolAccept", + ) +) + +// TxPublisher provides an interface for publishing transactions. +type TxPublisher interface { + // CheckMempoolAcceptance checks if a transaction would be accepted by + // the mempool without broadcasting. + CheckMempoolAcceptance(ctx context.Context, tx *wire.MsgTx) error + + // Broadcast broadcasts a transaction to the network. + Broadcast(ctx context.Context, tx *wire.MsgTx, label string) error +} From a57451786df1500ac8579512d8441cdb4f152e51 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Sep 2025 01:52:03 +0800 Subject: [PATCH 102/691] wallet: implement `CheckMempoolAcceptance` --- wallet/tx_publisher.go | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index a04100d0b1..398f6c79c9 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -34,3 +34,48 @@ type TxPublisher interface { // Broadcast broadcasts a transaction to the network. Broadcast(ctx context.Context, tx *wire.MsgTx, label string) error } + +// CheckMempoolAcceptance checks if a transaction would be accepted by the +// mempool without broadcasting. +func (w *Wallet) CheckMempoolAcceptance(_ context.Context, + tx *wire.MsgTx) error { + + // TODO(yy): thread context through. + chainClient, err := w.requireChainClient() + if err != nil { + return err + } + + // The TestMempoolAccept rpc expects a slice of transactions. + txns := []*wire.MsgTx{tx} + + // Use a max feerate of 0 means the default value will be used when + // testing mempool acceptance. The default max feerate is 0.10 BTC/kvb, + // or 10,000 sat/vb. + maxFeeRate := float64(0) + + results, err := chainClient.TestMempoolAccept(txns, maxFeeRate) + if err != nil { + return err + } + + // Sanity check that the expected single result is returned. + if len(results) != 1 { + return ErrMempoolAccept + } + + result := results[0] + + // If the transaction is allowed, we can return early. + if result.Allowed { + return nil + } + + // Otherwise, we'll map the reason to a concrete error type and return + // it. + // + //nolint:err113 + err = errors.New(result.RejectReason) + + return chainClient.MapRPCErr(err) +} From 6b0cc012ff2af89de040ba4aa5c35e564fca687a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Sep 2025 10:54:33 +0800 Subject: [PATCH 103/691] wallet: add `mockChain` using `mock.Mock` --- wallet/mock_test.go | 148 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 01ad74a060..57d7a1aafe 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -9,14 +9,17 @@ package wallet import ( + "context" "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" @@ -775,4 +778,147 @@ func (m *mockCoinSelectionStrategy) ArrangeCoins(coins []Coin, } return args.Get(0).([]Coin), args.Error(1) -} \ No newline at end of file +} + +// mockChain is a mock implementation of the chain.Interface. +type mockChain struct { + mock.Mock +} + +// A compile-time assertion to ensure that mockChain implements the +// chain.Interface. +var _ chain.Interface = (*mockChain)(nil) + +// Start implements the chain.Interface interface. +func (m *mockChain) Start(_ context.Context) error { + args := m.Called() + return args.Error(0) +} + +// Stop implements the chain.Interface interface. +func (m *mockChain) Stop() { + m.Called() +} + +// WaitForShutdown implements the chain.Interface interface. +func (m *mockChain) WaitForShutdown() { + m.Called() +} + +// GetBestBlock implements the chain.Interface interface. +func (m *mockChain) GetBestBlock() (*chainhash.Hash, int32, error) { + args := m.Called() + hash, _ := args.Get(0).(*chainhash.Hash) + + return hash, int32(args.Int(1)), args.Error(2) +} + +// GetBlock implements the chain.Interface interface. +func (m *mockChain) GetBlock(hash *chainhash.Hash) (*wire.MsgBlock, error) { + args := m.Called(hash) + block, _ := args.Get(0).(*wire.MsgBlock) + + return block, args.Error(1) +} + +// GetBlockHash implements the chain.Interface interface. +func (m *mockChain) GetBlockHash(height int64) (*chainhash.Hash, error) { + args := m.Called(height) + hash, _ := args.Get(0).(*chainhash.Hash) + + return hash, args.Error(1) +} + +// GetBlockHeader implements the chain.Interface interface. +func (m *mockChain) GetBlockHeader( + hash *chainhash.Hash) (*wire.BlockHeader, error) { + + args := m.Called(hash) + header, _ := args.Get(0).(*wire.BlockHeader) + + return header, args.Error(1) +} + +// IsCurrent implements the chain.Interface interface. +func (m *mockChain) IsCurrent() bool { + args := m.Called() + return args.Bool(0) +} + +// FilterBlocks implements the chain.Interface interface. +func (m *mockChain) FilterBlocks(req *chain.FilterBlocksRequest) ( + *chain.FilterBlocksResponse, error) { + + args := m.Called(req) + resp, _ := args.Get(0).(*chain.FilterBlocksResponse) + + return resp, args.Error(1) +} + +// BlockStamp implements the chain.Interface interface. +func (m *mockChain) BlockStamp() (*waddrmgr.BlockStamp, error) { + args := m.Called() + stamp, _ := args.Get(0).(*waddrmgr.BlockStamp) + + return stamp, args.Error(1) +} + +// SendRawTransaction implements the chain.Interface interface. +func (m *mockChain) SendRawTransaction(tx *wire.MsgTx, + allowHighFees bool) (*chainhash.Hash, error) { + + args := m.Called(tx, allowHighFees) + hash, _ := args.Get(0).(*chainhash.Hash) + + return hash, args.Error(1) +} + +// Rescan implements the chain.Interface interface. +func (m *mockChain) Rescan(hash *chainhash.Hash, addrs []btcutil.Address, + outpoints map[wire.OutPoint]btcutil.Address) error { + + args := m.Called(hash, addrs, outpoints) + return args.Error(0) +} + +// NotifyReceived implements the chain.Interface interface. +func (m *mockChain) NotifyReceived(addrs []btcutil.Address) error { + args := m.Called(addrs) + return args.Error(0) +} + +// NotifyBlocks implements the chain.Interface interface. +func (m *mockChain) NotifyBlocks() error { + args := m.Called() + return args.Error(0) +} + +// Notifications implements the chain.Interface interface. +func (m *mockChain) Notifications() <-chan any { + args := m.Called() + ch, _ := args.Get(0).(<-chan any) + + return ch +} + +// BackEnd implements the chain.Interface interface. +func (m *mockChain) BackEnd() string { + args := m.Called() + return args.String(0) +} + +// TestMempoolAccept implements the chain.Interface interface. +func (m *mockChain) TestMempoolAccept(txns []*wire.MsgTx, + maxFeeRate float64) ([]*btcjson.TestMempoolAcceptResult, error) { + + args := m.Called(txns, maxFeeRate) + res, _ := args.Get(0).([]*btcjson.TestMempoolAcceptResult) + + return res, args.Error(1) +} + +// MapRPCErr implements the chain.Interface interface. +func (m *mockChain) MapRPCErr(err error) error { + args := m.Called(err) + return args.Error(0) +} From bffb166b72004d4c0607343ae212c8d868bac23c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Sep 2025 11:08:21 +0800 Subject: [PATCH 104/691] wallet: use `mockChain` instead in the unit tests --- wallet/example_test.go | 17 +++++++++-------- wallet/tx_creator_test.go | 38 ++++++++++++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/wallet/example_test.go b/wallet/example_test.go index 36352e9793..9cd637edac 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -60,9 +60,9 @@ func testWallet(t *testing.T) *Wallet { // mockers is a struct that holds all the mocked interfaces that can be // used to test the wallet. type mockers struct { - chainClient *mockChainClient - addrStore *mockAddrStore - txStore *mockTxStore + chain *mockChain + addrStore *mockAddrStore + txStore *mockTxStore } // testWalletWithMocks creates a test wallet and unlocks it. In contrast to @@ -86,14 +86,14 @@ func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { w, err := loader.CreateNewWallet(pubPass, privPass, seed, time.Now()) require.NoError(t, err) - chainClient := &mockChainClient{} + chain := &mockChain{} txStore := &mockTxStore{} addrStore := &mockAddrStore{} addrStore.On("IsLocked").Return(false) addrStore.On("Unlock", mock.Anything, mock.Anything).Return(nil) - w.chainClient = chainClient + w.chainClient = chain w.txStore = txStore w.addrStore = addrStore @@ -106,14 +106,15 @@ func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { // Create the mockers struct so it can be used by the tests to mock // methods. m := &mockers{ - chainClient: chainClient, - txStore: txStore, - addrStore: addrStore, + chain: chain, + txStore: txStore, + addrStore: addrStore, } // When the test finishes, we need to assert the mocked methods are // called or not called as expected. t.Cleanup(func() { + chain.AssertExpectations(t) txStore.AssertExpectations(t) addrStore.AssertExpectations(t) }) diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index e30300a786..b667d8a8d4 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -544,7 +544,12 @@ func TestGetEligibleUTXOs(t *testing.T) { setupMocks: func( m *mockers, source CoinSource, ) { - scopedSrc := source.(*ScopedAccount) + + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ) + scopedSrc, ok := source.(*ScopedAccount) + require.True(t, ok) accountStore := &mockAccountStore{} m.addrStore.On("FetchScopedKeyManager", @@ -566,6 +571,9 @@ func TestGetEligibleUTXOs(t *testing.T) { UTXOs: []wire.OutPoint{utxo}, }, setupMocks: func(m *mockers, source CoinSource) { + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ) m.txStore.On("GetUtxo", mock.Anything, utxo). Return(credit, nil) }, @@ -574,15 +582,22 @@ func TestGetEligibleUTXOs(t *testing.T) { name: "nil source", source: nil, setupMocks: func(m *mockers, source CoinSource) { + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ) m.txStore.On("UnspentOutputs", mock.Anything, ).Return([]wtxmgr.Credit{}, nil) }, }, { - name: "unsupported source", - source: &unsupportedCoinSource{}, - setupMocks: func(m *mockers, source CoinSource) {}, + name: "unsupported source", + source: &unsupportedCoinSource{}, + setupMocks: func(m *mockers, source CoinSource) { + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ) + }, expectedErr: ErrUnsupportedCoinSource, }, } @@ -716,6 +731,9 @@ func TestCreatePolicyInputSource(t *testing.T) { MinConfs: 1, }, setupMocks: func(m *mockers) { + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ).Once() m.txStore.On("UnspentOutputs", mock.Anything). Return(eligibleUtxos, nil).Once() }, @@ -728,6 +746,9 @@ func TestCreatePolicyInputSource(t *testing.T) { MinConfs: 1, }, setupMocks: func(m *mockers) { + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ).Once() m.txStore.On("UnspentOutputs", mock.Anything). Return(eligibleUtxos, nil).Once() }, @@ -739,6 +760,9 @@ func TestCreatePolicyInputSource(t *testing.T) { MinConfs: 1, }, setupMocks: func(m *mockers) { + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ).Once() m.txStore.On("UnspentOutputs", mock.Anything, ).Return(nil, errDB).Once() @@ -753,6 +777,9 @@ func TestCreatePolicyInputSource(t *testing.T) { MinConfs: 1, }, setupMocks: func(m *mockers) { + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ).Once() m.txStore.On("UnspentOutputs", mock.Anything). Return(eligibleUtxos, nil).Once() }, @@ -824,6 +851,9 @@ func TestCreateInputSource(t *testing.T) { name: "policy inputs", intent: intentPolicy, setupMocks: func(m *mockers) { + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ).Once() m.txStore.On("UnspentOutputs", mock.Anything, ).Return([]wtxmgr.Credit{*credit}, nil).Once() From b9029bd29779ec5a720a00915ccd16c169f341ac Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Sep 2025 12:36:53 +0800 Subject: [PATCH 105/691] wallet: add helper method `checkMempool` Which will be used in the `Broadcast` implementation. --- wallet/tx_publisher.go | 52 +++++++++++ wallet/tx_publisher_test.go | 173 ++++++++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 wallet/tx_publisher_test.go diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 398f6c79c9..192e211040 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -13,8 +13,11 @@ package wallet import ( "context" "errors" + "fmt" + "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/chain" ) var ( @@ -79,3 +82,52 @@ func (w *Wallet) CheckMempoolAcceptance(_ context.Context, return chainClient.MapRPCErr(err) } + +var ( + // errAlreadyBroadcasted is a sentinel error used to indicate that a tx + // has already been broadcast. + errAlreadyBroadcasted = errors.New("tx already broadcasted") +) + +// checkMempool is a helper function that checks if a tx is acceptable to the +// mempool before broadcasting. +func (w *Wallet) checkMempool(ctx context.Context, + tx *wire.MsgTx) error { + + // We'll start by checking if the tx is acceptable to the mempool. + err := w.CheckMempoolAcceptance(ctx, tx) + + switch { + // If the tx is already in the mempool or confirmed, we can return + // early. + case errors.Is(err, chain.ErrTxAlreadyInMempool), + errors.Is(err, chain.ErrTxAlreadyKnown), + errors.Is(err, chain.ErrTxAlreadyConfirmed): + + log.Infof("Tx %v already broadcasted", tx.TxHash()) + + // TODO(yy): Add a new method UpdateTxLabel to allow updating + // the label of a tx. With this change, the label passed in + // will be ignored if the tx is already known. + return errAlreadyBroadcasted + + // If the backend does not support the mempool acceptance test, we'll + // just attempt to publish the tx. + case errors.Is(err, rpcclient.ErrBackendVersion), + errors.Is(err, chain.ErrUnimplemented): + + log.Warnf("Backend does not support mempool acceptance test, "+ + "broadcasting directly: %v", err) + + return nil + + // If the tx was rejected for any other reason, we'll return the error + // directly. + case err != nil: + return fmt.Errorf("tx rejected by mempool: %w", err) + + // Otherwise, the tx is valid and we can publish it. + default: + return nil + } +} diff --git a/wallet/tx_publisher_test.go b/wallet/tx_publisher_test.go new file mode 100644 index 0000000000..d3384d394e --- /dev/null +++ b/wallet/tx_publisher_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/chain" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var ( + errDummy = errors.New("dummy") + errInsufficientFee = errors.New("insufficient fee") + errRpc = errors.New("rpc error") + errPublish = errors.New("publish error") + errRemove = errors.New("remove error") +) + +const testTxLabel = "test-tx" + +// TestCheckMempoolAcceptance tests the CheckMempoolAcceptance method. +func TestCheckMempoolAcceptance(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tx := &wire.MsgTx{} + + mempoolAcceptResultAllowed := []*btcjson.TestMempoolAcceptResult{ + {Allowed: true}, + } + mempoolAcceptResultRejected := []*btcjson.TestMempoolAcceptResult{ + { + Allowed: false, + RejectReason: errInsufficientFee.Error(), + }, + } + + testCases := []struct { + name string + rpcResult []*btcjson.TestMempoolAcceptResult + rpcErr error + expectedErr error + }{ + { + name: "accepted", + rpcResult: mempoolAcceptResultAllowed, + rpcErr: nil, + expectedErr: nil, + }, + { + name: "rejected", + rpcResult: mempoolAcceptResultRejected, + rpcErr: nil, + expectedErr: errInsufficientFee, + }, + { + name: "rpc error", + rpcResult: nil, + rpcErr: errRpc, + expectedErr: errRpc, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + w, m := testWalletWithMocks(t) + + m.chain.On("TestMempoolAccept", + mock.Anything, mock.Anything, + ).Return(tc.rpcResult, tc.rpcErr) + + // We only need to mock the MapRPCErr function if the + // RPC call is expected to succeed but the tx is + // rejected. + if tc.rpcErr == nil && !tc.rpcResult[0].Allowed { + m.chain.On("MapRPCErr", + mock.Anything, + ).Return(errInsufficientFee) + } + + err := w.CheckMempoolAcceptance(ctx, tx) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +// TestCheckMempool tests the checkMempool helper function. +func TestCheckMempool(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tx := &wire.MsgTx{} + + testCases := []struct { + name string + mempoolAcceptErr error + expectedErr error + expectWrappedErr bool + rejectionReason string + mapRPCErr func(error) error + }{ + { + name: "accepted", + mempoolAcceptErr: nil, + expectedErr: nil, + }, + { + name: "already in mempool", + mempoolAcceptErr: chain.ErrTxAlreadyInMempool, + expectedErr: errAlreadyBroadcasted, + }, + { + name: "already known", + mempoolAcceptErr: chain.ErrTxAlreadyKnown, + expectedErr: errAlreadyBroadcasted, + }, + { + name: "already confirmed", + mempoolAcceptErr: chain.ErrTxAlreadyConfirmed, + expectedErr: errAlreadyBroadcasted, + }, + { + name: "backend version", + mempoolAcceptErr: rpcclient.ErrBackendVersion, + expectedErr: nil, + }, + { + name: "unimplemented", + mempoolAcceptErr: chain.ErrUnimplemented, + expectedErr: nil, + }, + { + name: "rejected", + mempoolAcceptErr: errDummy, + expectedErr: errDummy, + expectWrappedErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + w, m := testWalletWithMocks(t) + + // Setup the mock for TestMempoolAccept. + if tc.mempoolAcceptErr == nil { + m.chain.On("TestMempoolAccept", + mock.Anything, mock.Anything, + ).Return([]*btcjson.TestMempoolAcceptResult{ + {Allowed: true}, + }, nil) + } else { + m.chain.On("TestMempoolAccept", + mock.Anything, mock.Anything, + ).Return(nil, tc.mempoolAcceptErr) + } + + err := w.checkMempool(ctx, tx) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} From 93bc8f0c3c4b48f55e06ed721ae4c3d0b238e041 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Sep 2025 12:39:58 +0800 Subject: [PATCH 106/691] wallet: add helper method `addTxToWallet` Which will be used in the `Broadcast` implementation. --- wallet/mock_test.go | 4 + wallet/tx_publisher.go | 248 +++++++++++++++++++++ wallet/tx_publisher_test.go | 432 ++++++++++++++++++++++++++++++++++++ 3 files changed, 684 insertions(+) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 57d7a1aafe..85fdbe79ee 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -282,6 +282,10 @@ func (m *mockAddrStore) Address(ns walletdb.ReadBucket, address btcutil.Address) (waddrmgr.ManagedAddress, error) { args := m.Called(ns, address) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(waddrmgr.ManagedAddress), args.Error(1) } diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 192e211040..62a41649bc 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -14,10 +14,16 @@ import ( "context" "errors" "fmt" + "time" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" ) var ( @@ -131,3 +137,245 @@ func (w *Wallet) checkMempool(ctx context.Context, return nil } } + +// creditInfo is a struct that holds all the information needed to atomically +// record a transaction credit. +type creditInfo struct { + // index is the output index of the credit. + index uint32 + + // ma is the managed address of the credit. + ma waddrmgr.ManagedAddress + + // addr is the address of the credit. + addr btcutil.Address +} + +// addTxToWallet adds a tx to the wallet's database. This function is a critical +// part of the wallet's transaction processing pipeline and is designed for high +// performance and atomicity. It follows a four-stage process: +// +// 1. Extract: First, it performs a CPU-intensive, in-memory pre-processing +// step to parse all transaction outputs and extract all potential addresses. +// This is done outside of any database transaction to avoid holding locks +// during computationally expensive work. +// +// 2. Filter: Second, it uses a fast, read-only database transaction to +// filter the large list of potential addresses down to the small set that is +// actually owned by the wallet. This minimizes the time spent in the final, +// more expensive write transaction. +// +// 3. Plan: Third, it prepares a definitive "write plan" in memory. This plan +// is a simple slice of structs that contains all the information needed to +// atomically update the database. This step ensures that transactions with +// multiple outputs to the same address are handled correctly. +// +// 4. Execute: Finally, it executes this plan within a minimal, atomic write +// transaction. This transaction contains no business logic and only performs +// the necessary database writes, ensuring that the exclusive database lock is +// held for the shortest possible time. +func (w *Wallet) addTxToWallet(tx *wire.MsgTx, + label string) ([]btcutil.Address, error) { + + txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + if err != nil { + return nil, err + } + + // Stage 1: Extract potential addresses from all transaction outputs. + // This is a CPU-intensive operation that is performed entirely in + // memory, without holding any database locks. + txOutAddrs := w.extractTxAddrs(tx) + + // Stage 2: Filter the extracted addresses to find which ones are owned + // by the wallet. This is done in a fast, read-only database + // transaction to minimize contention. + ownedAddrs, err := w.filterOwnedAddresses(txOutAddrs) + if err != nil { + return nil, err + } + + // If the transaction has no outputs relevant to us, we can exit early. + if len(ownedAddrs) == 0 { + return nil, nil + } + + // Stage 3: Prepare a definitive "write plan". This plan is created in + // memory and contains all the information needed for the final atomic + // database update. + var ( + creditsToWrite []creditInfo + ourAddrs []btcutil.Address + ) + + // The nested loop structure here is critical. It iterates through the + // original transaction outputs and uses the `ownedAddrs` map as a + // quick lookup table. This correctly handles the edge case where a + // single transaction has multiple outputs paying to the same address, + // as it ensures a distinct entry in the `creditsToWrite` slice is + // created for each individual output. For example, if a transaction + // has two outputs (index 0 and 1) that both pay to `addr_A`, this + // loop will create two separate entries in `creditsToWrite`, one for + // each index, ensuring both UTXOs are correctly credited. + for index, addrs := range txOutAddrs { + for _, addr := range addrs { + ma, ok := ownedAddrs[addr] + if !ok { + continue + } + + creditsToWrite = append(creditsToWrite, creditInfo{ + index: index, + ma: ma, + addr: addr, + }) + ourAddrs = append(ourAddrs, addr) + } + } + + // Stage 4: Atomically execute the write plan. This is the only stage + // that takes an exclusive database lock, and it is designed to be as + // fast as possible, containing no business logic. + err = w.recordTxAndCredits(txRec, label, creditsToWrite) + if err != nil { + return nil, err + } + + return ourAddrs, nil +} + +// recordTxAndCredits performs a single atomic database transaction to execute a +// pre-computed "write plan" for a transaction. +func (w *Wallet) recordTxAndCredits(txRec *wtxmgr.TxRecord, label string, + creditsToWrite []creditInfo) error { + + return walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { + addrmgrNs := dbTx.ReadWriteBucket(waddrmgrNamespaceKey) + txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) + + // If there is a label we should write, get the namespace key + // and record it in the tx store. + if len(label) != 0 { + txHash := txRec.MsgTx.TxHash() + + err := w.txStore.PutTxLabel(txmgrNs, txHash, label) + if err != nil { + return err + } + } + + // At the moment all notified txs are assumed to actually be + // relevant. This assumption will not hold true when SPV + // support is added, but until then, simply insert the tx + // because there should either be one or more relevant inputs + // or outputs. + exists, err := w.txStore.InsertTxCheckIfExists( + txmgrNs, txRec, nil, + ) + if err != nil { + return err + } + + // If the tx has already been recorded, we can return early. + if exists { + return nil + } + + // Now, execute the write plan. + for _, credit := range creditsToWrite { + err := w.txStore.AddCredit( + txmgrNs, txRec, nil, credit.index, + credit.ma.Internal(), + ) + if err != nil { + return err + } + + err = w.addrStore.MarkUsed(addrmgrNs, credit.addr) + if err != nil { + return err + } + } + + return nil + }) +} + +// extractTxAddrs extracts all potential addresses from a transaction's outputs. +// This is a CPU-intensive function that should be run outside of a database +// transaction. +func (w *Wallet) extractTxAddrs(tx *wire.MsgTx) map[uint32][]btcutil.Address { + txOutAddrs := make(map[uint32][]btcutil.Address) + for i, output := range tx.TxOut { + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + output.PkScript, w.chainParams, + ) + // Ignore non-standard scripts. + if err != nil { + log.Warnf("Cannot extract non-std pkScript=%x", + output.PkScript) + + continue + } + + txOutAddrs[uint32(i)] = addrs + } + + return txOutAddrs +} + +// filterOwnedAddresses takes a map of output indexes to addresses and returns a +// new map containing only the addresses that are owned by the wallet. This +// function is a key part of the wallet's performance strategy. It efficiently +// filters a potentially large set of addresses down to the small subset that +// the wallet needs to act on. +// +// The function is optimized to handle transactions with multiple outputs +// paying to the same address. It internally de-duplicates the addresses to +// ensure that the expensive database lookup (`w.addrStore.Address`) is +// performed only once for each unique address. +func (w *Wallet) filterOwnedAddresses( + txOutAddrs map[uint32][]btcutil.Address) ( + map[btcutil.Address]waddrmgr.ManagedAddress, error) { + + ownedAddrs := make(map[btcutil.Address]waddrmgr.ManagedAddress) + + // Pre-deduplicate addresses outside the DB transaction. + uniqueAddrs := make(map[btcutil.Address]struct{}) + for _, addrs := range txOutAddrs { + for _, addr := range addrs { + uniqueAddrs[addr] = struct{}{} + } + } + + err := walletdb.View(w.db, func(dbTx walletdb.ReadTx) error { + addrmgrNs := dbTx.ReadBucket(waddrmgrNamespaceKey) + + for addr := range uniqueAddrs { + ma, err := w.addrStore.Address(addrmgrNs, addr) + + // If the address is not found, it simply means + // it does not belong to the wallet. This is + // the expected case for most addresses, so we + // can safely continue to the next one. + if waddrmgr.IsError( + err, waddrmgr.ErrAddressNotFound) { + + continue + } + + if err != nil { + return err + } + + ownedAddrs[addr] = ma + } + + return nil + }) + if err != nil { + return nil, err + } + + return ownedAddrs, nil +} diff --git a/wallet/tx_publisher_test.go b/wallet/tx_publisher_test.go index d3384d394e..7b02ce3992 100644 --- a/wallet/tx_publisher_test.go +++ b/wallet/tx_publisher_test.go @@ -6,13 +6,20 @@ package wallet import ( "context" + "crypto/sha256" "errors" "testing" + "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -94,6 +101,431 @@ func TestCheckMempoolAcceptance(t *testing.T) { } } +// testTxData is a helper struct to hold the results of createTestTx. +type testTxData struct { + // tx is the generated transaction. + tx *wire.MsgTx + + // addr1 is the P2WKH address used in the transaction. + addr1 btcutil.Address + + // addr2 is the P2SH address used in the transaction. + addr2 btcutil.Address + + // addr3 is the P2WSH address used in the transaction. + addr3 btcutil.Address +} + +// createTestTx is a helper function to create a transaction with various +// output types for testing. The created transaction has a single placeholder +// input and four outputs: +// - Output 0: A P2WKH (Pay-to-Witness-Key-Hash) output. +// - Output 1: A P2SH (Pay-to-Script-Hash) output. +// - Output 2: An OP_RETURN output for data embedding. +// - Output 3: A 2-of-2 multi-sig P2WSH (Pay-to-Witness-Script-Hash) output. +func createTestTx(t *testing.T, w *Wallet) *testTxData { + t.Helper() + + // Create some keys and addresses for testing. + privKey1, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey1 := privKey1.PubKey() + addr1, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey1.SerializeCompressed()), w.chainParams, + ) + require.NoError(t, err) + + privKey2, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey2 := privKey2.PubKey() + + // Create a transaction with various output types. + tx := &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, + }, + TxOut: []*wire.TxOut{}, + } + + // Output 0: P2WKH + pkScript1, err := txscript.PayToAddrScript(addr1) + require.NoError(t, err) + + tx.TxOut = append(tx.TxOut, &wire.TxOut{PkScript: pkScript1, Value: 1}) + + // Output 1: P2SH + script2 := []byte{txscript.OP_1} + addr2, err := btcutil.NewAddressScriptHash( + script2, w.chainParams, + ) + require.NoError(t, err) + pkScript2, err := txscript.PayToAddrScript(addr2) + require.NoError(t, err) + + tx.TxOut = append(tx.TxOut, &wire.TxOut{PkScript: pkScript2, Value: 1}) + + // Output 2: OP_RETURN + opReturnBuilder := txscript.NewScriptBuilder() + opReturnBuilder.AddOp(txscript.OP_RETURN).AddData([]byte("test")) + pkScript3, err := opReturnBuilder.Script() + require.NoError(t, err) + + tx.TxOut = append(tx.TxOut, &wire.TxOut{PkScript: pkScript3, Value: 0}) + + // Output 3: Multi-sig P2WSH + builder := txscript.NewScriptBuilder() + builder.AddInt64(2) + builder.AddData(pubKey1.SerializeCompressed()) + builder.AddData(pubKey2.SerializeCompressed()) + builder.AddInt64(2) + builder.AddOp(txscript.OP_CHECKMULTISIG) + multiSigScript, err := builder.Script() + require.NoError(t, err) + + scriptHash := sha256.Sum256(multiSigScript) + addr3, err := btcutil.NewAddressWitnessScriptHash( + scriptHash[:], w.chainParams, + ) + require.NoError(t, err) + pkScript4, err := txscript.PayToAddrScript(addr3) + require.NoError(t, err) + + tx.TxOut = append(tx.TxOut, &wire.TxOut{PkScript: pkScript4, Value: 1}) + + return &testTxData{ + tx: tx, + addr1: addr1, + addr2: addr2, + addr3: addr3, + } +} + +// TestExtractTxAddrs tests the extractTxAddrs method to ensure it correctly +// extracts all potential addresses from a transaction's outputs. +func TestExtractTxAddrs(t *testing.T) { + t.Parallel() + + // Create a new test wallet. + w, _ := testWalletWithMocks(t) + + // Create the test transaction. + testData := createTestTx(t, w) + + // Extract addresses. + extractedAddrs := w.extractTxAddrs(testData.tx) + + // Check the results. + // We expect 4 entries in the map, one for each output. + require.Len(t, extractedAddrs, 4, "expected 4 outputs") + + // Output 0 should have one address. + require.Len(t, extractedAddrs[0], 1) + require.Equal(t, testData.addr1.String(), extractedAddrs[0][0].String()) + + // Output 1 should have one address. + require.Len(t, extractedAddrs[1], 1) + require.Equal(t, testData.addr2.String(), extractedAddrs[1][0].String()) + + // Output 2 (OP_RETURN) should have no addresses. + require.Empty(t, extractedAddrs[2], "OP_RETURN output should have "+ + "no addresses") + + // Output 3 should have one address (the script hash address). + require.Len(t, extractedAddrs[3], 1) + require.Equal(t, testData.addr3.String(), extractedAddrs[3][0].String()) +} + +// TestFilterOwnedAddresses tests the filterOwnedAddresses method to ensure it +// correctly identifies owned addresses and handles de-duplication. +func TestFilterOwnedAddresses(t *testing.T) { + t.Parallel() + + // Create a new test wallet with mocks. + w, mocks := testWalletWithMocks(t) + + // Create two addresses, one owned and one not. + ownedPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + ownedAddr, err := btcutil.NewAddressPubKey( + ownedPrivKey.PubKey().SerializeCompressed(), w.chainParams, + ) + require.NoError(t, err) + + unownedPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + unownedAddr, err := btcutil.NewAddressPubKey( + unownedPrivKey.PubKey().SerializeCompressed(), w.chainParams, + ) + require.NoError(t, err) + + // Create an input map with both addresses, with the owned address + // appearing twice. + txOutAddrs := map[uint32][]btcutil.Address{ + 0: {ownedAddr}, + 1: {unownedAddr}, + 2: {ownedAddr}, // Duplicate + } + + // Set up the mock for the address store. + mockManagedAddr := &mockManagedAddress{} + errAddrNotFound := waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAddressNotFound, + } + + mocks.addrStore.On("Address", mock.Anything, ownedAddr). + Return(mockManagedAddr, nil).Once() + mocks.addrStore.On("Address", mock.Anything, unownedAddr). + Return(nil, errAddrNotFound).Once() + + // Filter the addresses. + ownedAddrs, err := w.filterOwnedAddresses(txOutAddrs) + require.NoError(t, err) + + // Check that the result contains only the owned address. + require.Len(t, ownedAddrs, 1) + _, ok := ownedAddrs[ownedAddr] + require.True(t, ok) +} + +// TestRecordTxAndCredits tests the recordTxAndCredits method to ensure it +// correctly records transactions and credits in the database. +func TestRecordTxAndCredits(t *testing.T) { + t.Parallel() + + // Create a sample TxRecord from a transaction with one input and one + // output with a value of 10000. + tx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{{Value: 10000}}, + } + txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + require.NoError(t, err) + + // Create a sample credit for a P2PK address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + addr, err := btcutil.NewAddressPubKey( + privKey.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + + mockManagedAddr := &mockManagedAddress{} + mockManagedAddr.On("Internal").Return(false) + credits := []creditInfo{{ + index: 0, + ma: mockManagedAddr, + addr: addr, + }} + + testCases := []struct { + name string + withLabel bool + txExists bool + }{ + { + name: "new tx with label", + withLabel: true, + txExists: false, + }, + { + name: "existing tx", + withLabel: true, + txExists: true, + }, + { + name: "no label", + withLabel: false, + txExists: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + txid := tx.TxHash() + + label := "" + if tc.withLabel { + label = testTxLabel + } + + mocks.txStore.On("InsertTxCheckIfExists", + mock.Anything, txRec, mock.Anything, + ).Return(tc.txExists, nil).Once() + + if tc.withLabel { + mocks.txStore.On("PutTxLabel", + mock.Anything, txid, label, + ).Return(nil).Once() + } + + if !tc.txExists { + mocks.txStore.On("AddCredit", + mock.Anything, txRec, mock.Anything, + uint32(0), false, + ).Return(nil).Once() + mocks.addrStore.On("MarkUsed", + mock.Anything, addr, + ).Return(nil).Once() + } + + err := w.recordTxAndCredits(txRec, label, credits) + require.NoError(t, err) + }) + } +} + +// TestAddTxToWallet tests the addTxToWallet method, which serves as an +// integration test for the transaction extraction, filtering, and recording +// process. +func TestAddTxToWallet(t *testing.T) { + t.Parallel() + + // Create some addresses for testing. + ownedPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + ownedAddr, err := btcutil.NewAddressPubKey( + ownedPrivKey.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + + unownedPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + unownedAddr, err := btcutil.NewAddressPubKey( + unownedPrivKey.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + + // Create a transaction with outputs to both owned and unowned + // addresses. + tx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{ + { + Value: 10000, + PkScript: mustPayToAddrScript(ownedAddr), + }, + { + Value: 20000, + PkScript: mustPayToAddrScript(unownedAddr), + }, + { + Value: 30000, + PkScript: mustPayToAddrScript(ownedAddr), + }, + }, + } + txid := tx.TxHash() + label := testTxLabel + + t.Run("tx with owned outputs", func(t *testing.T) { + t.Parallel() + w, m := testWalletWithMocks(t) + + // This test case simulates the scenario where the + // transaction has outputs owned by the wallet. We expect + // the wallet to identify these outputs, record the + // transaction, and credit the wallet with the new UTXOs. + // + // Set up the mock for the address store. + mockManagedAddr := &mockManagedAddress{} + mockManagedAddr.On("Internal").Return(false) + + errAddrNotFound := waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAddressNotFound, + } + + m.addrStore.On("Address", + mock.Anything, ownedAddr, + ).Return(mockManagedAddr, nil) + m.addrStore.On("Address", + mock.Anything, unownedAddr, + ).Return(nil, errAddrNotFound) + + // Set up the mocks for the transaction store. + m.txStore.On("PutTxLabel", + mock.Anything, txid, label, + ).Return(nil).Once() + m.txStore.On("InsertTxCheckIfExists", + mock.Anything, mock.Anything, + mock.Anything, + ).Return(false, nil).Once() + + // We expect two credits to be added for the two owned + // outputs. + m.txStore.On("AddCredit", + mock.Anything, mock.Anything, + mock.Anything, uint32(0), false, + ).Return(nil).Once() + m.txStore.On("AddCredit", + mock.Anything, mock.Anything, + mock.Anything, uint32(2), false, + ).Return(nil).Once() + m.addrStore.On("MarkUsed", + mock.Anything, ownedAddr, + ).Return(nil).Twice() + + // Add the transaction to the wallet. + ourAddrs, err := w.addTxToWallet(tx, label) + require.NoError(t, err) + + // Check that the returned addresses are correct. + require.Len(t, ourAddrs, 2) + require.Equal( + t, ownedAddr.String(), + ourAddrs[0].String(), + ) + require.Equal( + t, ownedAddr.String(), + ourAddrs[1].String(), + ) + }) + + t.Run("tx with no owned outputs", func(t *testing.T) { + t.Parallel() + w, m := testWalletWithMocks(t) + + // This test case simulates the scenario where the + // transaction has no outputs owned by the wallet. We + // expect the wallet to identify this and exit early + // without recording the transaction. + // + // Set up the mock for the address store to own no + // addresses. + errAddrNotFound := waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAddressNotFound, + } + m.addrStore.On("Address", + mock.Anything, ownedAddr, + ).Return(nil, errAddrNotFound) + m.addrStore.On("Address", + mock.Anything, unownedAddr, + ).Return(nil, errAddrNotFound) + + // Add the transaction to the wallet. + ourAddrs, err := w.addTxToWallet(tx, label) + require.NoError(t, err) + + // We expect no addresses to be returned and no calls to the + // transaction store. + require.Nil(t, ourAddrs) + }) +} + +// mustPayToAddrScript is a helper function to create a PkScript for a given +// address. It panics on error. +func mustPayToAddrScript(addr btcutil.Address) []byte { + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + panic(err) + } + + return pkScript +} + // TestCheckMempool tests the checkMempool helper function. func TestCheckMempool(t *testing.T) { t.Parallel() From 583b3602e77d237459306e4a76b1883b4555ac8a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Sep 2025 12:42:29 +0800 Subject: [PATCH 107/691] wallet: add methods `publishTx` and `removeUnminedTx` Which are used in the following commit. --- wallet/tx_publisher.go | 93 +++++++++++++++++++++++++++++++++++++ wallet/tx_publisher_test.go | 86 ++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 62a41649bc..6558d05db6 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -11,6 +11,7 @@ package wallet import ( + "bytes" "context" "errors" "fmt" @@ -24,6 +25,7 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/davecgh/go-spew/spew" ) var ( @@ -379,3 +381,94 @@ func (w *Wallet) filterOwnedAddresses( return ownedAddrs, nil } + +// publishTx is a helper function that handles the process of broadcasting a +// transaction to the network. This includes getting a chain client, +// registering for notifications, and sending the raw transaction. +func (w *Wallet) publishTx(tx *wire.MsgTx, ourAddrs []btcutil.Address) error { + chainClient, err := w.requireChainClient() + if err != nil { + return err + } + + // We'll also ask to be notified of the tx once it confirms on-chain. + // This is done outside of the database tx to prevent backend + // interaction within it. + err = chainClient.NotifyReceived(ourAddrs) + if err != nil { + return err + } + + txid := tx.TxHash() + + // allowHighFees is always false such that the max fee rate allowed is + // capped at 10,000 sat/vb for bitcoind. Note that this flag is only + // used in bitcoind chain backend. See, + // - https://github.com/btcsuite/btcd/blob/442ef28bcf03797e845c8e957e5cd6d4bffb5764/rpcclient/rawtransactions.go#L22 + // + //nolint:lll + allowHighFees := false + + _, rpcErr := chainClient.SendRawTransaction(tx, allowHighFees) + if rpcErr == nil { + return nil + } + + // If the tx was rejected, we need to determine why and act + // accordingly. + // + // NOTE: This check for ErrTxAlreadyInMempool should only be triggered + // if the wallet is running without mempool acceptance checks (e.g., + // with an older version of the chain backend or with Neutrino). + // Otherwise, this condition should have been caught earlier by the + // `checkMempool` function. + if errors.Is(rpcErr, chain.ErrTxAlreadyInMempool) { + log.Infof("%v: tx already in mempool", txid) + return nil + } + + // If the tx was rejected for any other reason, then we'll return the + // error and let the caller handle the cleanup. + return rpcErr +} + +// removeUnminedTx removes a tx from the unconfirmed store. +func (w *Wallet) removeUnminedTx(tx *wire.MsgTx) error { + txHash := tx.TxHash() + + dbErr := walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { + txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) + + txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + if err != nil { + return err + } + + return w.txStore.RemoveUnminedTx(txmgrNs, txRec) + }) + if dbErr != nil { + log.Warnf("Unable to remove invalid tx %v: %v", txHash, dbErr) + return dbErr + } + + log.Infof("Removed invalid tx: %v", txHash) + + // The serialized tx is for logging only, don't fail on the error. + var txRaw bytes.Buffer + + _ = tx.Serialize(&txRaw) + + // Optionally log the tx in debug when the size is manageable. + const maxTxSizeForLog = 1_000_000 + if txRaw.Len() < maxTxSizeForLog { + log.Debugf("Removed invalid tx: %v \n hex=%x", + newLogClosure(func() string { + return spew.Sdump(tx) + }), txRaw.Bytes()) + } else { + log.Debugf("Removed invalid tx %v due to its size "+ + "being too large", txHash) + } + + return nil +} diff --git a/wallet/tx_publisher_test.go b/wallet/tx_publisher_test.go index 7b02ce3992..331f576f64 100644 --- a/wallet/tx_publisher_test.go +++ b/wallet/tx_publisher_test.go @@ -526,6 +526,31 @@ func mustPayToAddrScript(addr btcutil.Address) []byte { return pkScript } +// TestRemoveUnminedTx tests the removeUnminedTx method to ensure it correctly +// removes a transaction from the unconfirmed store. +func TestRemoveUnminedTx(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + + // Create a sample transaction with one input and one output. + tx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{{ + Value: 10000, + }}, + } + + // Set up the mock for the transaction store. + mocks.txStore.On( + "RemoveUnminedTx", mock.Anything, mock.Anything, + ).Return(nil).Once() + + // Call the method under test. + err := w.removeUnminedTx(tx) + require.NoError(t, err) +} + // TestCheckMempool tests the checkMempool helper function. func TestCheckMempool(t *testing.T) { t.Parallel() @@ -603,3 +628,64 @@ func TestCheckMempool(t *testing.T) { }) } } + +// TestPublishTx tests the publishTx helper function. +func TestPublishTx(t *testing.T) { + t.Parallel() + + tx := &wire.MsgTx{} + addrs := []btcutil.Address{&btcutil.AddressPubKey{}} + + testCases := []struct { + name string + notifyErr error + sendErr error + expectedErr error + }{ + { + name: "success", + notifyErr: nil, + sendErr: nil, + expectedErr: nil, + }, + { + name: "notify received fails", + notifyErr: errDummy, + sendErr: nil, + expectedErr: errDummy, + }, + { + name: "send raw transaction fails", + notifyErr: nil, + sendErr: errDummy, + expectedErr: errDummy, + }, + { + name: "already in mempool", + notifyErr: nil, + sendErr: chain.ErrTxAlreadyInMempool, + expectedErr: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + w, m := testWalletWithMocks(t) + + m.chain.On("NotifyReceived", + mock.Anything).Return(tc.notifyErr) + + // We only expect SendRawTransaction to be called if + // NotifyReceived succeeds. + if tc.notifyErr == nil { + m.chain.On("SendRawTransaction", + mock.Anything, mock.Anything, + ).Return(nil, tc.sendErr) + } + + err := w.publishTx(tx, addrs) + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} From 5d4850c2f79a3d5af65b1529c114deb74fb9ede5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Sep 2025 12:43:10 +0800 Subject: [PATCH 108/691] wallet: implement `Broadcast` This commit introduces a comprehensive refactoring of the transaction publication pipeline, primarily centered around the `addTxToWallet` function and its helpers. The previous implementation was inefficient, performing CPU-intensive script parsing and multiple database reads within a single, long-running database write transaction. This design held an exclusive database lock for an extended period, creating a performance bottleneck and reducing concurrency. The new design addresses these issues by separating the process into a clear, four-stage pipeline: 1. **Extract:** A new `extractTxAddrs` helper performs all CPU-intensive script parsing entirely in memory, before any database transaction is initiated. 2. **Filter:** A new `filterOwnedAddresses` helper uses a fast, read-only database transaction to filter potential addresses down to only those owned by the wallet. This minimizes the work done in the final write stage and is optimized to perform only one lookup per unique address. 3. **Plan:** The main `addTxToWallet` function prepares a definitive "write plan" in memory. This plan correctly handles edge cases, such as transactions with multiple outputs to the same address. 4. **Execute:** A new `recordTxAndCredits` helper executes this plan within a minimal, atomic write transaction that contains no business logic and only performs writes. This new architecture significantly improves performance by minimizing the duration of exclusive database locks. It enhances code clarity and maintainability through a strong separation of concerns. This refactoring also resolves several correctness issues, including a bug that prevented credit detection for non-default account scopes and a confusing error handling path in the transaction broadcast logic, leading to a more robust and reliable implementation. --- wallet/tx_publisher.go | 54 +++++++++ wallet/tx_publisher_test.go | 211 ++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 6558d05db6..8e5a2ea27f 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -46,6 +46,9 @@ type TxPublisher interface { Broadcast(ctx context.Context, tx *wire.MsgTx, label string) error } +// A compile time check to ensure that Wallet implements the interface. +var _ TxPublisher = (*Wallet)(nil) + // CheckMempoolAcceptance checks if a transaction would be accepted by the // mempool without broadcasting. func (w *Wallet) CheckMempoolAcceptance(_ context.Context, @@ -91,6 +94,57 @@ func (w *Wallet) CheckMempoolAcceptance(_ context.Context, return chainClient.MapRPCErr(err) } +// Broadcast broadcasts a tx to the network. It is the main implementation of +// the TxPublisher interface. +func (w *Wallet) Broadcast(ctx context.Context, tx *wire.MsgTx, + label string) error { + + // We'll start by checking if the tx is acceptable to the mempool. + err := w.checkMempool(ctx, tx) + if errors.Is(err, errAlreadyBroadcasted) { + return nil + } + + if err != nil { + return err + } + + // First, we'll attempt to add the tx to our wallet's DB. This will + // allow us to track the tx's confirmation status, and also + // re-broadcast it upon startup. If any of the subsequent steps fail, + // this tx must be removed. + ourAddrs, err := w.addTxToWallet(tx, label) + if err != nil { + return err + } + + // Now, we'll attempt to publish the tx. On successful attempt, we + // return immediately. On any failures, we remove it from the tx store + // to prevent subsequent attempts with stale transaction data. + err = w.publishTx(tx, ourAddrs) + if err == nil { + return nil + } + + txid := tx.TxHash() + log.Errorf("%v: broadcast failed: %v", txid, err) + + // If the tx was rejected for any other reason, then we'll remove it + // from the tx store, as otherwise, we'll attempt to continually + // re-broadcast it, and the UTXO state of the wallet won't be accurate. + removeErr := w.removeUnminedTx(tx) + if removeErr != nil { + log.Warnf("Unable to remove tx %v after broadcast failed: %v", + txid, removeErr) + + // Return a wrapped error to give the caller full context. + return fmt.Errorf("broadcast failed: %w; and failed to "+ + "remove from wallet: %v", err, removeErr) + } + + return err +} + var ( // errAlreadyBroadcasted is a sentinel error used to indicate that a tx // has already been broadcast. diff --git a/wallet/tx_publisher_test.go b/wallet/tx_publisher_test.go index 331f576f64..73918874c1 100644 --- a/wallet/tx_publisher_test.go +++ b/wallet/tx_publisher_test.go @@ -689,3 +689,214 @@ func TestPublishTx(t *testing.T) { }) } } + +// TestBroadcastSuccess tests the Broadcast method for a successful broadcast. +func TestBroadcastSuccess(t *testing.T) { + t.Parallel() + + ctx := context.Background() + label := testTxLabel + w, m := testWalletWithMocks(t) + + // Create a transaction with an owned output. + ownedPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + ownedAddr, err := btcutil.NewAddressPubKey( + ownedPrivKey.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(ownedAddr) + require.NoError(t, err) + + tx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{{Value: 10000, PkScript: pkScript}}, + } + + // Mock checkMempool to succeed. + m.chain.On("TestMempoolAccept", + mock.Anything, mock.Anything, + ).Return([]*btcjson.TestMempoolAcceptResult{{Allowed: true}}, nil) + + // Mock addTxToWallet to succeed. + mockManagedAddr := &mockManagedAddress{} + mockManagedAddr.On("Internal").Return(false) + m.addrStore.On("Address", + mock.Anything, ownedAddr, + ).Return(mockManagedAddr, nil).Once() + m.txStore.On("PutTxLabel", + mock.Anything, tx.TxHash(), label, + ).Return(nil).Once() + m.txStore.On("InsertTxCheckIfExists", + mock.Anything, mock.Anything, mock.Anything, + ).Return(false, nil).Once() + m.txStore.On("AddCredit", + mock.Anything, mock.Anything, mock.Anything, uint32(0), false, + ).Return(nil).Once() + m.addrStore.On("MarkUsed", + mock.Anything, ownedAddr, + ).Return(nil).Once() + + // Mock publishTx to succeed. + m.chain.On("NotifyReceived", mock.Anything).Return(nil) + m.chain.On("SendRawTransaction", + mock.Anything, mock.Anything, + ).Return(nil, nil) + + err = w.Broadcast(ctx, tx, label) + require.NoError(t, err) +} + +// TestBroadcastAlreadyBroadcasted tests the Broadcast method when the +// transaction has already been broadcasted. +func TestBroadcastAlreadyBroadcasted(t *testing.T) { + t.Parallel() + + ctx := context.Background() + label := testTxLabel + w, m := testWalletWithMocks(t) + + tx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{{Value: 10000}}, + } + + // Mock checkMempool to return already broadcasted. + m.chain.On("TestMempoolAccept", mock.Anything, mock.Anything). + Return(nil, chain.ErrTxAlreadyInMempool) + + err := w.Broadcast(ctx, tx, label) + require.NoError(t, err) +} + +// TestBroadcastPublishFailsRemoveSucceeds tests the Broadcast method when +// publishing fails but removing the transaction from the wallet succeeds. +func TestBroadcastPublishFailsRemoveSucceeds(t *testing.T) { + t.Parallel() + + ctx := context.Background() + label := testTxLabel + w, m := testWalletWithMocks(t) + + // Create a transaction with an owned output. + ownedPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + ownedAddr, err := btcutil.NewAddressPubKey( + ownedPrivKey.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(ownedAddr) + require.NoError(t, err) + + tx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{{Value: 10000, PkScript: pkScript}}, + } + + // Mock checkMempool to succeed. + m.chain.On("TestMempoolAccept", + mock.Anything, mock.Anything, + ).Return([]*btcjson.TestMempoolAcceptResult{{Allowed: true}}, nil) + + // Mock addTxToWallet to succeed. + mockManagedAddr := &mockManagedAddress{} + mockManagedAddr.On("Internal").Return(false) + m.addrStore.On("Address", + mock.Anything, ownedAddr, + ).Return(mockManagedAddr, nil).Once() + m.txStore.On("PutTxLabel", + mock.Anything, tx.TxHash(), label, + ).Return(nil).Once() + m.txStore.On("InsertTxCheckIfExists", + mock.Anything, mock.Anything, mock.Anything, + ).Return(false, nil).Once() + m.txStore.On("AddCredit", + mock.Anything, mock.Anything, mock.Anything, uint32(0), false, + ).Return(nil).Once() + m.addrStore.On("MarkUsed", + mock.Anything, ownedAddr, + ).Return(nil).Once() + + // Mock publishTx to fail. + m.chain.On("NotifyReceived", mock.Anything).Return(nil) + m.chain.On("SendRawTransaction", + mock.Anything, mock.Anything, + ).Return(nil, errPublish) + + // Mock removeUnminedTx to succeed. + m.txStore.On("RemoveUnminedTx", + mock.Anything, mock.Anything, + ).Return(nil).Once() + + err = w.Broadcast(ctx, tx, label) + require.ErrorIs(t, err, errPublish) +} + +// TestBroadcastPublishFailsRemoveFails tests the Broadcast method when both +// publishing and removing the transaction from the wallet fail. +func TestBroadcastPublishFailsRemoveFails(t *testing.T) { + t.Parallel() + + ctx := context.Background() + label := testTxLabel + w, m := testWalletWithMocks(t) + + // Create a transaction with an owned output. + ownedPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + ownedAddr, err := btcutil.NewAddressPubKey( + ownedPrivKey.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(ownedAddr) + require.NoError(t, err) + + tx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{{Value: 10000, PkScript: pkScript}}, + } + + // Mock checkMempool to succeed. + m.chain.On("TestMempoolAccept", + mock.Anything, mock.Anything, + ).Return([]*btcjson.TestMempoolAcceptResult{{Allowed: true}}, nil) + + // Mock addTxToWallet to succeed. + mockManagedAddr := &mockManagedAddress{} + mockManagedAddr.On("Internal").Return(false) + + // Mock addrStore to succeed. + m.addrStore.On("Address", + mock.Anything, ownedAddr, + ).Return(mockManagedAddr, nil).Once() + m.addrStore.On("MarkUsed", + mock.Anything, ownedAddr, + ).Return(nil).Once() + + // Mock txStore to succeed. + m.txStore.On("PutTxLabel", + mock.Anything, tx.TxHash(), label, + ).Return(nil).Once() + m.txStore.On("InsertTxCheckIfExists", + mock.Anything, mock.Anything, mock.Anything, + ).Return(false, nil).Once() + m.txStore.On("AddCredit", + mock.Anything, mock.Anything, mock.Anything, uint32(0), false, + ).Return(nil).Once() + + // Mock publishTx to fail. + m.chain.On("NotifyReceived", mock.Anything).Return(nil) + m.chain.On("SendRawTransaction", + mock.Anything, mock.Anything, + ).Return(nil, errPublish) + + // Mock removeUnminedTx to fail. + m.txStore.On("RemoveUnminedTx", + mock.Anything, mock.Anything, + ).Return(errRemove).Once() + + err = w.Broadcast(ctx, tx, label) + require.Error(t, err) + require.Contains(t, err.Error(), errPublish.Error()) + require.Contains(t, err.Error(), errRemove.Error()) +} From 1df21f08cf9b1fd7154e8e35610206e3116bf66f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 10 Oct 2025 23:01:16 +0800 Subject: [PATCH 109/691] wallet: fix `gosec` linter errors on int32 conversion We now properly fix the `int32 <-> uint32` conversion in the new code - for the old code, it's safe to ignore cause they will be removed eventually. --- wallet/account_manager.go | 4 +-- wallet/account_manager_benchmark_test.go | 4 +-- wallet/createtx.go | 13 ++++++--- wallet/tx_creator.go | 6 ++-- wallet/tx_publisher.go | 4 +++ wallet/utxos.go | 5 +++- wallet/wallet.go | 36 +++++++++++++++--------- 7 files changed, 46 insertions(+), 26 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 177054e8b3..c589c9fdf5 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -88,7 +88,7 @@ type AccountManager interface { // Balance returns the balance for a specific account, identified by its // scope and name, for a given number of required confirmations. - Balance(ctx context.Context, conf int32, scope waddrmgr.KeyScope, + Balance(ctx context.Context, conf uint32, scope waddrmgr.KeyScope, name string) (btcutil.Amount, error) // ImportAccount imports an account from an extended public or private @@ -481,7 +481,7 @@ func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, // // The time complexity of this method is O(U*logA), where U is the number of // UTXOs and logA is the cost of an account lookup. -func (w *Wallet) Balance(_ context.Context, conf int32, +func (w *Wallet) Balance(_ context.Context, conf uint32, scope waddrmgr.KeyScope, name string) (btcutil.Amount, error) { var balance btcutil.Amount diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index dbbbd11e7b..0fe0c0ff4e 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -466,8 +466,8 @@ func BenchmarkGetBalanceAPI(b *testing.B) { for b.Loop() { _, err := bw.Balance( - b.Context(), confirmations, scopes[0], - accountName, + b.Context(), uint32(confirmations), + scopes[0], accountName, ) require.NoError(b, err) } diff --git a/wallet/createtx.go b/wallet/createtx.go index 9606a173f0..9c88739ebc 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -184,7 +184,9 @@ func (w *Wallet) txToOutputs(outputs []*wire.TxOut, } eligible, err := w.findEligibleOutputs( - dbtx, coinSelectKeyScope, account, minconf, + dbtx, coinSelectKeyScope, account, + //nolint:gosec + uint32(minconf), bs, allowUtxo, ) if err != nil { @@ -344,7 +346,7 @@ func (w *Wallet) txToOutputs(outputs []*wire.TxOut, } func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, - keyScope *waddrmgr.KeyScope, account uint32, minconf int32, + keyScope *waddrmgr.KeyScope, account uint32, minconf uint32, bs *waddrmgr.BlockStamp, allowUtxo func(utxo wtxmgr.Credit) bool) ([]wtxmgr.Credit, error) { @@ -379,8 +381,11 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, continue } if output.FromCoinBase { - target := int32(w.chainParams.CoinbaseMaturity) - if !confirmed(target, output.Height, bs.Height) { + target := w.chainParams.CoinbaseMaturity + if !confirmed( + uint32(target), output.Height, bs.Height, + ) { + continue } } diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index f366ee57b8..dbbb22d732 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -691,7 +691,7 @@ func (w *Wallet) getEligibleUTXOs(dbtx walletdb.ReadTx, case nil: return w.findEligibleOutputs( dbtx, &waddrmgr.KeyScopeBIP0086, - waddrmgr.DefaultAccountNum, int32(minconf), bs, nil, + waddrmgr.DefaultAccountNum, minconf, bs, nil, ) // If the source is a scoped account, we find all eligible outputs for @@ -725,7 +725,7 @@ func (w *Wallet) getEligibleUTXOsFromAccount(dbtx walletdb.ReadTx, } return w.findEligibleOutputs( - dbtx, keyScope, account, int32(minconf), bs, nil, + dbtx, keyScope, account, minconf, bs, nil, ) } @@ -753,7 +753,7 @@ func (w *Wallet) getEligibleUTXOsFromList(dbtx walletdb.ReadTx, // A UTXO is only eligible if it has reached the required // number of confirmations. - if !confirmed(int32(minconf), credit.Height, bs.Height) { + if !confirmed(minconf, credit.Height, bs.Height) { // Calculate the number of confirmations for the // warning message. confs := calcConf(bs.Height, credit.Height) diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 8e5a2ea27f..9adc208755 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -374,6 +374,10 @@ func (w *Wallet) extractTxAddrs(tx *wire.MsgTx) map[uint32][]btcutil.Address { continue } + // It's not possible for a transaction to have this many + // outputs, so we can ignore the gosec error. + // + //nolint:gosec txOutAddrs[uint32(i)] = addrs } diff --git a/wallet/utxos.go b/wallet/utxos.go index 13ce324be0..85bb6ceda4 100644 --- a/wallet/utxos.go +++ b/wallet/utxos.go @@ -35,7 +35,10 @@ type OutputSelectionPolicy struct { func (p *OutputSelectionPolicy) meetsRequiredConfs(txHeight, curHeight int32) bool { - return confirmed(p.RequiredConfirmations, txHeight, curHeight) + return confirmed( + //nolint:gosec + uint32(p.RequiredConfirmations), txHeight, curHeight, + ) } // UnspentOutputs fetches all unspent outputs from the wallet that match rules diff --git a/wallet/wallet.go b/wallet/wallet.go index 4b87047d67..a24d128db4 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1752,13 +1752,15 @@ func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balan bals.Total += output.Amount if output.FromCoinBase && !confirmed( - int32(w.chainParams.CoinbaseMaturity), + uint32(w.chainParams.CoinbaseMaturity), output.Height, syncBlock.Height, ) { bals.ImmatureReward += output.Amount } else if confirmed( - confirms, output.Height, syncBlock.Height, + //nolint:gosec + uint32(confirms), output.Height, + syncBlock.Height, ) { bals.Spendable += output.Amount @@ -2116,11 +2118,8 @@ func (c CreditCategory) String() string { // this package at a later time. func RecvCategory(details *wtxmgr.TxDetails, syncHeight int32, net *chaincfg.Params) CreditCategory { if blockchain.IsCoinBaseTx(&details.MsgTx) { - if confirmed( - int32(net.CoinbaseMaturity), details.Block.Height, - syncHeight, - ) { - + if confirmed(uint32(net.CoinbaseMaturity), details.Block.Height, + syncHeight) { return CreditGenerate } return CreditImmature @@ -2684,14 +2683,16 @@ func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope, for i := range unspentOutputs { output := &unspentOutputs[i] if !confirmed( - requiredConfs, output.Height, syncBlock.Height, + //nolint:gosec + uint32(requiredConfs), output.Height, + syncBlock.Height, ) { continue } if output.FromCoinBase && !confirmed( - int32(w.ChainParams().CoinbaseMaturity), + uint32(w.ChainParams().CoinbaseMaturity), output.Height, syncBlock.Height, ) { @@ -2800,7 +2801,9 @@ func (w *Wallet) ListUnspentDeprecated(minconf, maxconf int32, // Only mature coinbase outputs are included. if output.FromCoinBase { - target := int32(w.ChainParams().CoinbaseMaturity) + target := uint32( + w.ChainParams().CoinbaseMaturity, + ) if !confirmed( target, output.Height, syncBlock.Height, ) { @@ -3288,10 +3291,15 @@ func (w *Wallet) newChangeAddress(addrmgrNs walletdb.ReadWriteBucket, return addrs[0].Address(), nil } -// confirmed returns whether a transaction has met at least minConf -// confirmations at the current block height. -func confirmed(minConf, txHeight, curHeight int32) bool { - return confirms(txHeight, curHeight) >= minConf +// confirmed checks whether a transaction at height txHeight has met minconf +// confirmations for a blockchain at height curHeight. +func confirmed(minconf uint32, txHeight, curHeight int32) bool { + confs := confirms(txHeight, curHeight) + if confs < 0 { + return false + } + + return uint32(confs) >= minconf } // confirms returns the number of confirmations for a transaction given its From edc660ccc04a4b6852a6d9f180af7b1f70d3fd65 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Oct 2025 07:34:39 +0800 Subject: [PATCH 110/691] waddrmgr: export the default account name --- waddrmgr/manager.go | 6 +++--- waddrmgr/manager_test.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/waddrmgr/manager.go b/waddrmgr/manager.go index b29cf07498..b5468647d5 100644 --- a/waddrmgr/manager.go +++ b/waddrmgr/manager.go @@ -46,14 +46,14 @@ const ( // DefaultAccountNum is the number of the default account. DefaultAccountNum = 0 - // defaultAccountName is the initial name of the default account. Note + // DefaultAccountName is the initial name of the default account. Note // that the default account may be renamed and is not a reserved name, // so the default account might not be named "default" and non-default // accounts may be named "default". // // Account numbers never change, so the DefaultAccountNum should be // used to refer to (and only to) the default account. - defaultAccountName = "default" + DefaultAccountName = "default" // unknownAccountName is the string returned when an account is unknown. unknownAccountName = "unknown" @@ -1811,7 +1811,7 @@ func createManagerKeyScope(ns walletdb.ReadWriteBucket, // Save the information for the default account to the database. err = putDefaultAccountInfo( ns, &scope, DefaultAccountNum, acctPubEnc, acctPrivEnc, 0, 0, - defaultAccountName, + DefaultAccountName, ) if err != nil { return err diff --git a/waddrmgr/manager_test.go b/waddrmgr/manager_test.go index 2945067ce1..49a78e12cb 100644 --- a/waddrmgr/manager_test.go +++ b/waddrmgr/manager_test.go @@ -1494,7 +1494,7 @@ func testNewAccount(tc *testContext) bool { func testLookupAccount(tc *testContext) bool { // Lookup accounts created earlier in testNewAccount expectedAccounts := map[string]uint32{ - defaultAccountName: DefaultAccountNum, + DefaultAccountName: DefaultAccountNum, ImportedAddrAccountName: ImportedAddrAccount, } @@ -1765,7 +1765,7 @@ func testManagerAPI(tc *testContext, caseCreatedWatchingOnly bool) { testNewAccount(tc) expectedAccounts := map[string]uint32{ - defaultAccountName: DefaultAccountNum, + DefaultAccountName: DefaultAccountNum, } testLookupExpectedAccount(tc, expectedAccounts, 0) //testForEachAccount(tc) @@ -2067,7 +2067,7 @@ func testManagerCase(t *testing.T, caseName string, err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) _, err = scopedMgr.NewAccountWatchingOnly( - ns, defaultAccountName, acctKeyPub, 0, nil, + ns, DefaultAccountName, acctKeyPub, 0, nil, ) return err }) From 2afbffad4d1d3c45a28d7859b0512e2c80667bc5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Oct 2025 07:34:59 +0800 Subject: [PATCH 111/691] wallet: add `determineChangeSource` to fallback to default account --- wallet/tx_creator.go | 35 ++++++- wallet/tx_creator_test.go | 198 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 2 deletions(-) diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index dbbb22d732..34e6f54d44 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -447,6 +447,10 @@ func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( return nil, err } + // Determine the change source. If not specified, a default will be + // used. + changeAccount := w.determineChangeSource(intent) + // The addrMgrWithChangeSource function of the wallet creates a new // change address. The address manager uses OnCommit on the walletdb tx // to update the in-memory state of the account state. But because the @@ -466,8 +470,8 @@ func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( // We perform the core logic of creating the input and change sources // within a single database transaction to ensure atomicity. err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { - changeKeyScope := &intent.ChangeSource.KeyScope - accountName := intent.ChangeSource.AccountName + changeKeyScope := &changeAccount.KeyScope + accountName := changeAccount.AccountName // Query the account's number using the account name. // @@ -544,6 +548,33 @@ func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( return tx, nil } +// determineChangeSource determines the source for the transaction's change +// output. If a source is specified in the intent, it is used. Otherwise, a +// default is determined based on the input source or the wallet's default +// account. When falling back to the default account, the P2TR (Taproot) key +// scope is used. +func (w *Wallet) determineChangeSource(intent *TxIntent) *ScopedAccount { + // If a change source is specified in the intent, use it. + if intent.ChangeSource != nil { + return intent.ChangeSource + } + + // If the inputs are from a specific account, use that for change. + if policy, ok := intent.Inputs.(*InputsPolicy); ok { + if account, ok := policy.Source.(*ScopedAccount); ok { + return account + } + } + + // Otherwise, use the default account. + // TODO(yy): The default key scope is currently hardcoded to P2TR + // (Taproot). This should be made configurable. + return &ScopedAccount{ + AccountName: waddrmgr.DefaultAccountName, + KeyScope: waddrmgr.KeyScopeBIP0086, + } +} + // createInputSource creates a txauthor.InputSource that will be used to select // inputs for a transaction. It acts as a dispatcher, delegating to either the // manual or policy-based input source creator based on the type of the intent's diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index b667d8a8d4..7b1835e703 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -327,6 +327,82 @@ type unsupportedCoinSource struct{} func (u *unsupportedCoinSource) isCoinSource() {} +// TestDetermineChangeSource tests the behavior of the determineChangeSource +// method, ensuring that it correctly selects a change source based on the +// transaction intent. It covers scenarios where the change source is +// explicitly provided, derived from the input policy, or falls back to the +// default P2TR account. +func TestDetermineChangeSource(t *testing.T) { + t.Parallel() + + w, _ := testWalletWithMocks(t) + + // Define a set of accounts to be reused across test cases. + explicitChangeSource := &ScopedAccount{ + AccountName: "explicit", + KeyScope: waddrmgr.KeyScopeBIP0044, + } + policyAccountSource := &ScopedAccount{ + AccountName: "policy", + KeyScope: waddrmgr.KeyScopeBIP0049Plus, + } + defaultAccountSource := &ScopedAccount{ + AccountName: waddrmgr.DefaultAccountName, + KeyScope: waddrmgr.KeyScopeBIP0086, + } + + testCases := []struct { + name string + intent *TxIntent + expectedSource *ScopedAccount + }{ + { + name: "explicit change source", + intent: &TxIntent{ + ChangeSource: explicitChangeSource, + }, + expectedSource: explicitChangeSource, + }, + { + name: "nil change source with policy account", + intent: &TxIntent{ + Inputs: &InputsPolicy{ + Source: policyAccountSource, + }, + ChangeSource: nil, + }, + expectedSource: policyAccountSource, + }, + { + name: "nil change source with manual inputs", + intent: &TxIntent{ + Inputs: &InputsManual{}, + ChangeSource: nil, + }, + expectedSource: defaultAccountSource, + }, + { + name: "nil change source with non-account policy", + intent: &TxIntent{ + Inputs: &InputsPolicy{ + Source: &CoinSourceUTXOs{}, + }, + ChangeSource: nil, + }, + expectedSource: defaultAccountSource, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + source := w.determineChangeSource(tc.intent) + require.Equal(t, tc.expectedSource, source) + }) + } +} + type mockReadBucket struct { walletdb.ReadBucket } @@ -985,6 +1061,128 @@ func TestCreateTransaction(t *testing.T) { }, }, { + name: "success nil change source manual inputs", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: nil, + FeeRate: 1000, + }, + setupMocks: func(m *mockers) { + accountStore := &mockAccountStore{} + m.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0086, + ).Return(accountStore, nil) + + // Should look up the default account + accountStore.On("LookupAccount", + mock.Anything, "default", + ).Return(uint32(0), nil) + + accountProps := &waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + } + accountStore.On("AccountProperties", + mock.Anything, uint32(0), + ).Return(accountProps, nil) + + accountStore.On( + "NextInternalAddresses", mock.Anything, + uint32(0), uint32(1), + ).Return( + []waddrmgr.ManagedAddress{ + mockChangeAddr, + }, nil, + ) + + m.txStore.On("GetUtxo", + mock.Anything, validUTXO, + ).Return(credit, nil) + }, + }, + { + name: "success nil change source policy inputs", + intent: &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsPolicy{ + Source: &ScopedAccount{ + AccountName: "test-account", + KeyScope: waddrmgr. + KeyScopeBIP0086, + }, + }, + ChangeSource: nil, + FeeRate: 1000, + }, + setupMocks: func(m *mockers) { + accountStore := &mockAccountStore{} + m.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0086, + ).Return(accountStore, nil) + + // Should look up the "test-account" for the + // change source. + accountStore.On("LookupAccount", + mock.Anything, "test-account", + ).Return(uint32(1), nil) + + accountProps := &waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: "test-account", + } + accountStore.On("AccountProperties", + mock.Anything, uint32(1), + ).Return(accountProps, nil) + + accountStore.On( + "NextInternalAddresses", mock.Anything, + uint32(1), uint32(1), + ).Return( + []waddrmgr.ManagedAddress{ + mockChangeAddr, + }, nil, + ) + + // Mocks for createPolicyInputSource. + m.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ) + + // We need to return the credit for the + // test-account. + changePubKey := changeKey.PubKey() + testAddr, err := btcutil.NewAddressPubKey( + changePubKey.SerializeCompressed(), + &chainParams, + ) + require.NoError(t, err) + testPkScript, err := txscript.PayToAddrScript( + testAddr, + ) + require.NoError(t, err) + credit.PkScript = testPkScript + + // We'll also need to set up the address store + // to know about the test account. + mockAddr := &mockManagedAddress{} + mockAddr.On("Account").Return(uint32(1)) + accountStore.On("Address", mock.Anything, + testAddr).Return(mockAddr, nil) + m.addrStore.On("AddrAccount", mock.Anything, + testAddr, + ).Return(accountStore, uint32(1), nil) + accountStore.On("Scope").Return( + waddrmgr.KeyScopeBIP0086, + ) + + m.txStore.On("UnspentOutputs", + mock.Anything, + ).Return([]wtxmgr.Credit{*credit}, nil) + }, + }, { name: "invalid intent", intent: &TxIntent{ Outputs: []wire.TxOut{}, // No outputs From ed3bf22be41b13ccdf585d6831694fd992f807ba Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Oct 2025 08:36:25 +0800 Subject: [PATCH 112/691] wallet: add nil check for `*wire.MsgTx` --- wallet/tx_publisher.go | 12 ++++++++++++ wallet/tx_publisher_test.go | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 9adc208755..273f54c8e0 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -54,6 +54,10 @@ var _ TxPublisher = (*Wallet)(nil) func (w *Wallet) CheckMempoolAcceptance(_ context.Context, tx *wire.MsgTx) error { + if tx == nil { + return ErrTxCannotBeNil + } + // TODO(yy): thread context through. chainClient, err := w.requireChainClient() if err != nil { @@ -99,6 +103,10 @@ func (w *Wallet) CheckMempoolAcceptance(_ context.Context, func (w *Wallet) Broadcast(ctx context.Context, tx *wire.MsgTx, label string) error { + if tx == nil { + return ErrTxCannotBeNil + } + // We'll start by checking if the tx is acceptable to the mempool. err := w.checkMempool(ctx, tx) if errors.Is(err, errAlreadyBroadcasted) { @@ -149,6 +157,10 @@ var ( // errAlreadyBroadcasted is a sentinel error used to indicate that a tx // has already been broadcast. errAlreadyBroadcasted = errors.New("tx already broadcasted") + + // ErrTxCannotBeNil is returned when a nil transaction is passed to a + // function. + ErrTxCannotBeNil = errors.New("tx cannot be nil") ) // checkMempool is a helper function that checks if a tx is acceptable to the diff --git a/wallet/tx_publisher_test.go b/wallet/tx_publisher_test.go index 73918874c1..ddbf7981ec 100644 --- a/wallet/tx_publisher_test.go +++ b/wallet/tx_publisher_test.go @@ -53,24 +53,33 @@ func TestCheckMempoolAcceptance(t *testing.T) { testCases := []struct { name string + tx *wire.MsgTx rpcResult []*btcjson.TestMempoolAcceptResult rpcErr error expectedErr error }{ + { + name: "nil tx", + tx: nil, + expectedErr: ErrTxCannotBeNil, + }, { name: "accepted", + tx: tx, rpcResult: mempoolAcceptResultAllowed, rpcErr: nil, expectedErr: nil, }, { name: "rejected", + tx: tx, rpcResult: mempoolAcceptResultRejected, rpcErr: nil, expectedErr: errInsufficientFee, }, { name: "rpc error", + tx: tx, rpcResult: nil, rpcErr: errRpc, expectedErr: errRpc, @@ -82,20 +91,24 @@ func TestCheckMempoolAcceptance(t *testing.T) { t.Parallel() w, m := testWalletWithMocks(t) - m.chain.On("TestMempoolAccept", - mock.Anything, mock.Anything, - ).Return(tc.rpcResult, tc.rpcErr) + if tc.tx != nil { + m.chain.On("TestMempoolAccept", + mock.Anything, mock.Anything, + ).Return(tc.rpcResult, tc.rpcErr) + } // We only need to mock the MapRPCErr function if the // RPC call is expected to succeed but the tx is // rejected. - if tc.rpcErr == nil && !tc.rpcResult[0].Allowed { + if tc.rpcErr == nil && tc.rpcResult != nil && + !tc.rpcResult[0].Allowed { + m.chain.On("MapRPCErr", mock.Anything, ).Return(errInsufficientFee) } - err := w.CheckMempoolAcceptance(ctx, tx) + err := w.CheckMempoolAcceptance(ctx, tc.tx) require.ErrorIs(t, err, tc.expectedErr) }) } @@ -900,3 +913,16 @@ func TestBroadcastPublishFailsRemoveFails(t *testing.T) { require.Contains(t, err.Error(), errPublish.Error()) require.Contains(t, err.Error(), errRemove.Error()) } + +// TestBroadcastNilTx tests that the Broadcast method returns an error when a +// nil transaction is passed. +func TestBroadcastNilTx(t *testing.T) { + t.Parallel() + + ctx := context.Background() + label := testTxLabel + w, _ := testWalletWithMocks(t) + + err := w.Broadcast(ctx, nil, label) + require.ErrorIs(t, err, ErrTxCannotBeNil) +} From 403472db2dd335bf178f6aee431655cb3e132149 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Oct 2025 08:02:13 +0800 Subject: [PATCH 113/691] wallet: fix linter `maintidx` We now break the `TestCreateTransaction` into smaller tests to fix this linter error. --- wallet/tx_creator_test.go | 543 +++++++++++++++++++++++--------------- 1 file changed, 323 insertions(+), 220 deletions(-) diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index 7b1835e703..980de7bda7 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -1,7 +1,6 @@ package wallet import ( - "context" "errors" "testing" @@ -963,15 +962,14 @@ func TestCreateInputSource(t *testing.T) { } } -// TestCreateTransaction provides an end-to-end test of the CreateTransaction -// method. It covers the main success path for creating a transaction with -// manually specified inputs, ensuring that all dependencies are called as -// expected. It also includes failure cases to verify that errors, such as an -// invalid transaction intent or a database-level account lookup failure, are -// handled correctly and propagated to the caller. -func TestCreateTransaction(t *testing.T) { +// TestCreateTransactionSuccessManualInputs tests the success path for creating +// a transaction with manually specified inputs. +func TestCreateTransactionSuccessManualInputs(t *testing.T) { t.Parallel() + // Arrange. + w, mocks := testWalletWithMocks(t) + privKey, err := btcec.NewPrivateKey() require.NoError(t, err) p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( @@ -982,7 +980,6 @@ func TestCreateTransaction(t *testing.T) { validPkScript, err := txscript.PayToAddrScript(p2wkhAddr) require.NoError(t, err) - // Common variables for test cases validOutput := wire.TxOut{Value: 10000, PkScript: validPkScript} validUTXO := wire.OutPoint{Hash: [32]byte{1}, Index: 0} @@ -1009,235 +1006,341 @@ func TestCreateTransaction(t *testing.T) { PkScript: []byte{4, 5, 6}, } - testCases := []struct { - name string - intent *TxIntent - setupMocks func(m *mockers) - expectedErr error - }{ - { - name: "success manual inputs", - intent: &TxIntent{ - Outputs: []wire.TxOut{validOutput}, - Inputs: &InputsManual{ - UTXOs: []wire.OutPoint{validUTXO}, - }, - ChangeSource: &ScopedAccount{ - AccountName: "default", - KeyScope: waddrmgr.KeyScopeBIP0086, - }, - FeeRate: 1000, - }, - setupMocks: func(m *mockers) { - accountStore := &mockAccountStore{} - m.addrStore.On("FetchScopedKeyManager", - waddrmgr.KeyScopeBIP0086, - ).Return(accountStore, nil) + intent := &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0086, + }, + FeeRate: 1000, + } - accountStore.On("LookupAccount", - mock.Anything, "default", - ).Return(uint32(0), nil) + accountStore := &mockAccountStore{} + mocks.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0086).Return(accountStore, nil) - accountProps := &waddrmgr.AccountProperties{ - AccountNumber: 0, - AccountName: "default", - } - accountStore.On("AccountProperties", - mock.Anything, uint32(0), - ).Return(accountProps, nil) - - accountStore.On( - "NextInternalAddresses", mock.Anything, - uint32(0), uint32(1), - ).Return( - []waddrmgr.ManagedAddress{ - mockChangeAddr, - }, nil, - ) + accountStore.On("LookupAccount", + mock.Anything, "default", + ).Return(uint32(0), nil) - m.txStore.On("GetUtxo", - mock.Anything, validUTXO, - ).Return(credit, nil) - }, - }, - { - name: "success nil change source manual inputs", - intent: &TxIntent{ - Outputs: []wire.TxOut{validOutput}, - Inputs: &InputsManual{ - UTXOs: []wire.OutPoint{validUTXO}, - }, - ChangeSource: nil, - FeeRate: 1000, - }, - setupMocks: func(m *mockers) { - accountStore := &mockAccountStore{} - m.addrStore.On("FetchScopedKeyManager", - waddrmgr.KeyScopeBIP0086, - ).Return(accountStore, nil) + accountProps := &waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + } + accountStore.On("AccountProperties", + mock.Anything, uint32(0), + ).Return(accountProps, nil) + + accountStore.On("NextInternalAddresses", + mock.Anything, uint32(0), uint32(1), + ).Return( + []waddrmgr.ManagedAddress{ + mockChangeAddr, + }, nil, + ) - // Should look up the default account - accountStore.On("LookupAccount", - mock.Anything, "default", - ).Return(uint32(0), nil) + mocks.txStore.On("GetUtxo", + mock.Anything, validUTXO, + ).Return(credit, nil) - accountProps := &waddrmgr.AccountProperties{ - AccountNumber: 0, - AccountName: "default", - } - accountStore.On("AccountProperties", - mock.Anything, uint32(0), - ).Return(accountProps, nil) - - accountStore.On( - "NextInternalAddresses", mock.Anything, - uint32(0), uint32(1), - ).Return( - []waddrmgr.ManagedAddress{ - mockChangeAddr, - }, nil, - ) + // Act. + tx, err := w.CreateTransaction(t.Context(), intent) - m.txStore.On("GetUtxo", - mock.Anything, validUTXO, - ).Return(credit, nil) - }, - }, - { - name: "success nil change source policy inputs", - intent: &TxIntent{ - Outputs: []wire.TxOut{validOutput}, - Inputs: &InputsPolicy{ - Source: &ScopedAccount{ - AccountName: "test-account", - KeyScope: waddrmgr. - KeyScopeBIP0086, - }, - }, - ChangeSource: nil, - FeeRate: 1000, - }, - setupMocks: func(m *mockers) { - accountStore := &mockAccountStore{} - m.addrStore.On("FetchScopedKeyManager", - waddrmgr.KeyScopeBIP0086, - ).Return(accountStore, nil) + // Assert. + require.NoError(t, err) + require.NotNil(t, tx) +} - // Should look up the "test-account" for the - // change source. - accountStore.On("LookupAccount", - mock.Anything, "test-account", - ).Return(uint32(1), nil) - - accountProps := &waddrmgr.AccountProperties{ - AccountNumber: 1, - AccountName: "test-account", - } - accountStore.On("AccountProperties", - mock.Anything, uint32(1), - ).Return(accountProps, nil) - - accountStore.On( - "NextInternalAddresses", mock.Anything, - uint32(1), uint32(1), - ).Return( - []waddrmgr.ManagedAddress{ - mockChangeAddr, - }, nil, - ) +// TestCreateTransactionSuccessNilChangeSourceManualInputs tests the success +// path for creating a transaction with manually specified inputs and a nil +// change source. +func TestCreateTransactionSuccessNilChangeSourceManualInputs(t *testing.T) { + t.Parallel() - // Mocks for createPolicyInputSource. - m.chain.On("BlockStamp").Return( - &waddrmgr.BlockStamp{}, nil, - ) + // Arrange. + w, mocks := testWalletWithMocks(t) - // We need to return the credit for the - // test-account. - changePubKey := changeKey.PubKey() - testAddr, err := btcutil.NewAddressPubKey( - changePubKey.SerializeCompressed(), - &chainParams, - ) - require.NoError(t, err) - testPkScript, err := txscript.PayToAddrScript( - testAddr, - ) - require.NoError(t, err) - credit.PkScript = testPkScript - - // We'll also need to set up the address store - // to know about the test account. - mockAddr := &mockManagedAddress{} - mockAddr.On("Account").Return(uint32(1)) - accountStore.On("Address", mock.Anything, - testAddr).Return(mockAddr, nil) - m.addrStore.On("AddrAccount", mock.Anything, - testAddr, - ).Return(accountStore, uint32(1), nil) - accountStore.On("Scope").Return( - waddrmgr.KeyScopeBIP0086, - ) + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(privKey.PubKey().SerializeCompressed()), + &chainParams, + ) + require.NoError(t, err) + validPkScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) - m.txStore.On("UnspentOutputs", - mock.Anything, - ).Return([]wtxmgr.Credit{*credit}, nil) - }, - }, { - name: "invalid intent", - intent: &TxIntent{ - Outputs: []wire.TxOut{}, // No outputs - }, - setupMocks: func(m *mockers) {}, - expectedErr: ErrNoTxOutputs, + validOutput := wire.TxOut{Value: 10000, PkScript: validPkScript} + validUTXO := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + + changeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + changeAddr, err := btcutil.NewAddressPubKey( + changeKey.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + + mockChangeAddr := &mockManagedAddress{} + mockChangeAddr.On("Address").Return(changeAddr) + mockChangeAddr.On("Internal").Return(true) + mockChangeAddr.On("Compressed").Return(true) + mockChangeAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + mockChangeAddr.On("InternalAccount").Return(uint32(0)) + mockChangeAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0086, waddrmgr.DerivationPath{}, true, + ) + + credit := &wtxmgr.Credit{ + OutPoint: validUTXO, + Amount: btcutil.Amount(50000), // Generous amount + PkScript: []byte{4, 5, 6}, + } + + intent := &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, }, - { - name: "account not found", - intent: &TxIntent{ - Outputs: []wire.TxOut{validOutput}, - Inputs: &InputsManual{ - UTXOs: []wire.OutPoint{validUTXO}, - }, - ChangeSource: &ScopedAccount{ - AccountName: "unknown", - KeyScope: waddrmgr.KeyScopeBIP0086, - }, - FeeRate: 1000, - }, - setupMocks: func(m *mockers) { - accountStore := &mockAccountStore{} - m.addrStore.On("FetchScopedKeyManager", - waddrmgr.KeyScopeBIP0086).Return( - accountStore, nil, - ) - errNotFound := waddrmgr.ManagerError{ - ErrorCode: waddrmgr.ErrAccountNotFound, - } - accountStore.On("LookupAccount", - mock.Anything, "unknown", - ).Return(uint32(0), errNotFound) + ChangeSource: nil, + FeeRate: 1000, + } + + accountStore := &mockAccountStore{} + mocks.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0086, + ).Return(accountStore, nil) + + // Should look up the default account + accountStore.On("LookupAccount", + mock.Anything, "default", + ).Return(uint32(0), nil) + + accountProps := &waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + } + accountStore.On("AccountProperties", + mock.Anything, uint32(0), + ).Return(accountProps, nil) + + accountStore.On("NextInternalAddresses", + mock.Anything, uint32(0), uint32(1), + ).Return( + []waddrmgr.ManagedAddress{ + mockChangeAddr, + }, nil, + ) + + mocks.txStore.On("GetUtxo", + mock.Anything, validUTXO, + ).Return(credit, nil) + + // Act. + tx, err := w.CreateTransaction(t.Context(), intent) + + // Assert. + require.NoError(t, err) + require.NotNil(t, tx) +} + +// TestCreateTransactionSuccessNilChangeSourcePolicyInputs tests the success +// path for creating a transaction with policy-based inputs and a nil change +// source. +func TestCreateTransactionSuccessNilChangeSourcePolicyInputs(t *testing.T) { + t.Parallel() + + // Arrange. + w, mocks := testWalletWithMocks(t) + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(privKey.PubKey().SerializeCompressed()), + &chainParams, + ) + require.NoError(t, err) + validPkScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + validOutput := wire.TxOut{Value: 10000, PkScript: validPkScript} + validUTXO := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + + changeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + changeAddr, err := btcutil.NewAddressPubKey( + changeKey.PubKey().SerializeCompressed(), &chainParams, + ) + require.NoError(t, err) + + mockChangeAddr := &mockManagedAddress{} + mockChangeAddr.On("Address").Return(changeAddr) + mockChangeAddr.On("Internal").Return(true) + mockChangeAddr.On("Compressed").Return(true) + mockChangeAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + mockChangeAddr.On("InternalAccount").Return(uint32(0)) + mockChangeAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0086, waddrmgr.DerivationPath{}, true, + ) + + credit := &wtxmgr.Credit{ + OutPoint: validUTXO, + Amount: btcutil.Amount(50000), // Generous amount + PkScript: []byte{4, 5, 6}, + } + + intent := &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsPolicy{ + Source: &ScopedAccount{ + AccountName: "test-account", + KeyScope: waddrmgr.KeyScopeBIP0086, }, - expectedErr: ErrAccountNotFound, }, + ChangeSource: nil, + FeeRate: 1000, } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() + accountStore := &mockAccountStore{} + mocks.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0086, + ).Return(accountStore, nil) + + // Should look up the "test-account" for the change source. + accountStore.On("LookupAccount", + mock.Anything, "test-account", + ).Return(uint32(1), nil) + + accountProps := &waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: "test-account", + } + accountStore.On("AccountProperties", + mock.Anything, uint32(1), + ).Return(accountProps, nil) + + accountStore.On( + "NextInternalAddresses", mock.Anything, + uint32(1), uint32(1), + ).Return( + []waddrmgr.ManagedAddress{ + mockChangeAddr, + }, nil, + ) - w, mocks := testWalletWithMocks(t) - tc.setupMocks(mocks) + // Mocks for createPolicyInputSource. + mocks.chain.On("BlockStamp").Return( + &waddrmgr.BlockStamp{}, nil, + ) - tx, err := w.CreateTransaction( - context.Background(), tc.intent, - ) + // We need to return the credit for the test-account. + testAddr, err := btcutil.NewAddressPubKey( + changeKey.PubKey().SerializeCompressed(), + &chainParams, + ) + require.NoError(t, err) + testPkScript, err := txscript.PayToAddrScript( + testAddr, + ) + require.NoError(t, err) - require.ErrorIs(t, err, tc.expectedErr) + credit.PkScript = testPkScript - if err == nil { - require.NotNil(t, tx) - } else { - require.Nil(t, tx) - } - }) + // We'll also need to set up the address store to know about the test + // account. + mockAddr := &mockManagedAddress{} + mockAddr.On("Account").Return(uint32(1)) + accountStore.On("Address", + mock.Anything, testAddr, + ).Return(mockAddr, nil) + mocks.addrStore.On("AddrAccount", + mock.Anything, testAddr, + ).Return(accountStore, uint32(1), nil) + accountStore.On("Scope").Return(waddrmgr.KeyScopeBIP0086) + + mocks.txStore.On("UnspentOutputs", + mock.Anything, + ).Return([]wtxmgr.Credit{*credit}, nil) + + // Act. + tx, err := w.CreateTransaction(t.Context(), intent) + + // Assert. + require.NoError(t, err) + require.NotNil(t, tx) +} + +// TestCreateTransactionInvalidIntent tests that an error is returned when an +// invalid transaction intent is provided. +func TestCreateTransactionInvalidIntent(t *testing.T) { + t.Parallel() + + // Arrange. + w, _ := testWalletWithMocks(t) + + intent := &TxIntent{ + Outputs: []wire.TxOut{}, // No outputs + } + + // Act. + tx, err := w.CreateTransaction(t.Context(), intent) + + // Assert. + require.ErrorIs(t, err, ErrNoTxOutputs) + require.Nil(t, tx) +} + +// TestCreateTransactionAccountNotFound tests that an error is returned when +// the specified account is not found. +func TestCreateTransactionAccountNotFound(t *testing.T) { + t.Parallel() + + // Arrange. + w, mocks := testWalletWithMocks(t) + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(privKey.PubKey().SerializeCompressed()), + &chainParams, + ) + require.NoError(t, err) + validPkScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + validOutput := wire.TxOut{Value: 10000, PkScript: validPkScript} + validUTXO := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + + intent := &TxIntent{ + Outputs: []wire.TxOut{validOutput}, + Inputs: &InputsManual{ + UTXOs: []wire.OutPoint{validUTXO}, + }, + ChangeSource: &ScopedAccount{ + AccountName: "unknown", + KeyScope: waddrmgr.KeyScopeBIP0086, + }, + FeeRate: 1000, } + + accountStore := &mockAccountStore{} + mocks.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0086).Return( + accountStore, nil, + ) + errNotFound := waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAccountNotFound, + } + accountStore.On("LookupAccount", + mock.Anything, "unknown", + ).Return(uint32(0), errNotFound) + + // Act. + tx, err := w.CreateTransaction(t.Context(), intent) + + // Assert. + require.ErrorIs(t, err, ErrAccountNotFound) + require.Nil(t, tx) } From af4d1721e48f667c015605bdf5718d17c996dc4e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 31 Oct 2025 13:03:49 +0800 Subject: [PATCH 114/691] wallet: fix linter `cyclop` --- wallet/tx_creator.go | 89 +++++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 39 deletions(-) diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index 34e6f54d44..aedd1b9c30 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -421,55 +421,21 @@ func validateTxIntent(intent *TxIntent) error { return nil } -// CreateTransaction creates a new unsigned transaction spending unspent outputs -// to the given outputs. It is the main implementation of the TxCreator -// interface. The method will produce a valid, unsigned transaction, which can -// then be passed to the Signer interface to be signed. -func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( - *txauthor.AuthoredTx, error) { - - // Check that the intent is not nil. - if intent == nil { - return nil, ErrNilTxIntent - } - - // If no input source is specified, an auto coin selection with the - // default account will be used. - if intent.Inputs == nil { - log.Debug("No input source specified, using default policy " + - "for automatic coin selection") - - intent.Inputs = &InputsPolicy{} - } - - err := validateTxIntent(intent) - if err != nil { - return nil, err - } - +// prepareTxAuthSources creates the input and change sources required to +// author a transaction. +func (w *Wallet) prepareTxAuthSources(intent *TxIntent) ( + txauthor.InputSource, *txauthor.ChangeSource, error) { // Determine the change source. If not specified, a default will be // used. changeAccount := w.determineChangeSource(intent) - // The addrMgrWithChangeSource function of the wallet creates a new - // change address. The address manager uses OnCommit on the walletdb tx - // to update the in-memory state of the account state. But because the - // commit happens _after_ the account manager internal lock has been - // released, there is a chance for the address index to be accessed - // concurrently, even though the closure in OnCommit re-acquires the - // lock. To avoid this issue, we surround the whole address creation - // process with a lock. - w.newAddrMtx.Lock() - defer w.newAddrMtx.Unlock() - var ( changeSource *txauthor.ChangeSource inputSource txauthor.InputSource ) - // We perform the core logic of creating the input and change sources // within a single database transaction to ensure atomicity. - err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { + err := walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { changeKeyScope := &changeAccount.KeyScope accountName := changeAccount.AccountName @@ -511,6 +477,51 @@ func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( return nil }) + if err != nil { + return nil, nil, err + } + + return inputSource, changeSource, nil +} + +// CreateTransaction creates a new unsigned transaction spending unspent outputs +// to the given outputs. It is the main implementation of the TxCreator +// interface. The method will produce a valid, unsigned transaction, which can +// then be passed to the Signer interface to be signed. +func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( + *txauthor.AuthoredTx, error) { + + // Check that the intent is not nil. + if intent == nil { + return nil, ErrNilTxIntent + } + + // If no input source is specified, an auto coin selection with the + // default account will be used. + if intent.Inputs == nil { + log.Debug("No input source specified, using default policy " + + "for automatic coin selection") + + intent.Inputs = &InputsPolicy{} + } + + err := validateTxIntent(intent) + if err != nil { + return nil, err + } + + // The addrMgrWithChangeSource function of the wallet creates a new + // change address. The address manager uses OnCommit on the walletdb tx + // to update the in-memory state of the account state. But because the + // commit happens _after_ the account manager internal lock has been + // released, there is a chance for the address index to be accessed + // concurrently, even though the closure in OnCommit re-acquires the + // lock. To avoid this issue, we surround the whole address creation + // process with a lock. + w.newAddrMtx.Lock() + defer w.newAddrMtx.Unlock() + + inputSource, changeSource, err := w.prepareTxAuthSources(intent) if err != nil { return nil, err } From c80b3a8fa92a0547ddf4467d2702a16c1ac23534 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 17 Nov 2025 22:55:01 +0800 Subject: [PATCH 115/691] wallet: rename `confirms -> calcConf` and `confirmed -> hasMinConfs` --- wallet/account_manager.go | 2 +- wallet/createtx.go | 4 ++-- wallet/tx_creator.go | 4 ++-- wallet/utxo_manager.go | 15 ++------------- wallet/utxo_manager_test.go | 6 +++--- wallet/utxos.go | 2 +- wallet/wallet.go | 33 +++++++++++++++++---------------- 7 files changed, 28 insertions(+), 38 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index c589c9fdf5..4273b8f2f7 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -513,7 +513,7 @@ func (w *Wallet) Balance(_ context.Context, conf uint32, for _, utxo := range utxos { // Skip any UTXOs that have not yet reached the required // number of confirmations. - if !confirmed(conf, utxo.Height, syncBlock.Height) { + if !hasMinConfs(conf, utxo.Height, syncBlock.Height) { continue } diff --git a/wallet/createtx.go b/wallet/createtx.go index 9c88739ebc..f1435d4920 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -377,12 +377,12 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, // Only include this output if it meets the required number of // confirmations. Coinbase transactions must have reached // maturity before their outputs may be spent. - if !confirmed(minconf, output.Height, bs.Height) { + if !hasMinConfs(minconf, output.Height, bs.Height) { continue } if output.FromCoinBase { target := w.chainParams.CoinbaseMaturity - if !confirmed( + if !hasMinConfs( uint32(target), output.Height, bs.Height, ) { diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index aedd1b9c30..160541f4f7 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -795,10 +795,10 @@ func (w *Wallet) getEligibleUTXOsFromList(dbtx walletdb.ReadTx, // A UTXO is only eligible if it has reached the required // number of confirmations. - if !confirmed(minconf, credit.Height, bs.Height) { + if !hasMinConfs(minconf, credit.Height, bs.Height) { // Calculate the number of confirmations for the // warning message. - confs := calcConf(bs.Height, credit.Height) + confs := calcConf(credit.Height, bs.Height) log.Warnf("Skipping user-specified UTXO %v "+ "because it has %d confs but needs %d", diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 12d461ae5c..6724fd975a 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -181,7 +181,7 @@ func (w *Wallet) ListUnspent(_ context.Context, // Iterate through each UTXO to apply filters and enrich it with // address-specific details. for _, output := range unspent { - confs := calcConf(currentHeight, output.Height) + confs := calcConf(output.Height, currentHeight) log.Tracef("Checking utxo[%v]: current height=%v, "+ "confirm height=%v, conf=%v", output.OutPoint, @@ -260,17 +260,6 @@ func (w *Wallet) ListUnspent(_ context.Context, return utxos, err } -// calcConf calculates the current confirmation status based on the wallet's -// synced block height. -func calcConf(currentHeight, outputHeight int32) int32 { - confs := int32(0) - if outputHeight != -1 { - confs = currentHeight - outputHeight - } - - return confs -} - // GetUtxo returns the output information for a given outpoint. // // This method provides a detailed view of a single UTXO, identified by its @@ -340,7 +329,7 @@ func (w *Wallet) GetUtxo(_ context.Context, return wtxmgr.ErrUtxoNotFound } - confs := calcConf(currentHeight, output.Height) + confs := calcConf(output.Height, currentHeight) // Extract the address from the UTXO's public key script. // For multi-address scripts, the first address is used. diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index 26e66ccdd1..eebffcc065 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -78,7 +78,7 @@ func TestListUnspent(t *testing.T) { PkScript: pkScriptDefault, BlockMeta: wtxmgr.BlockMeta{ Block: wtxmgr.Block{ - Height: currentHeight - minConf, + Height: currentHeight - minConf + 1, }, }, } @@ -91,7 +91,7 @@ func TestListUnspent(t *testing.T) { PkScript: pkScriptTest, BlockMeta: wtxmgr.BlockMeta{ Block: wtxmgr.Block{ - Height: currentHeight - maxConf, + Height: currentHeight - maxConf + 1, }, }, } @@ -236,7 +236,7 @@ func TestGetUtxo(t *testing.T) { PkScript: pkScriptDefault, BlockMeta: wtxmgr.BlockMeta{ Block: wtxmgr.Block{ - Height: currentHeight - 1, + Height: currentHeight, }, }, } diff --git a/wallet/utxos.go b/wallet/utxos.go index 85bb6ceda4..806442a2b2 100644 --- a/wallet/utxos.go +++ b/wallet/utxos.go @@ -35,7 +35,7 @@ type OutputSelectionPolicy struct { func (p *OutputSelectionPolicy) meetsRequiredConfs(txHeight, curHeight int32) bool { - return confirmed( + return hasMinConfs( //nolint:gosec uint32(p.RequiredConfirmations), txHeight, curHeight, ) diff --git a/wallet/wallet.go b/wallet/wallet.go index a24d128db4..559efc25f8 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1751,13 +1751,13 @@ func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balan } bals.Total += output.Amount - if output.FromCoinBase && !confirmed( + if output.FromCoinBase && !hasMinConfs( uint32(w.chainParams.CoinbaseMaturity), output.Height, syncBlock.Height, ) { bals.ImmatureReward += output.Amount - } else if confirmed( + } else if hasMinConfs( //nolint:gosec uint32(confirms), output.Height, syncBlock.Height, @@ -2118,8 +2118,9 @@ func (c CreditCategory) String() string { // this package at a later time. func RecvCategory(details *wtxmgr.TxDetails, syncHeight int32, net *chaincfg.Params) CreditCategory { if blockchain.IsCoinBaseTx(&details.MsgTx) { - if confirmed(uint32(net.CoinbaseMaturity), details.Block.Height, - syncHeight) { + if hasMinConfs(uint32(net.CoinbaseMaturity), + details.Block.Height, syncHeight) { + return CreditGenerate } return CreditImmature @@ -2146,7 +2147,7 @@ func listTransactions(tx walletdb.ReadTx, details *wtxmgr.TxDetails, blockHashStr = details.Block.Hash.String() blockTime = details.Block.Time.Unix() confirmations = int64( - confirms(details.Block.Height, syncHeight), + calcConf(details.Block.Height, syncHeight), ) } @@ -2618,7 +2619,7 @@ func (w *Wallet) GetTransaction(txHash chainhash.Hash) (*GetTransactionResult, bestBlock := w.SyncedTo() blockHeight := txDetail.Block.Height - res.Confirmations = confirms( + res.Confirmations = calcConf( blockHeight, bestBlock.Height, ) } @@ -2682,7 +2683,7 @@ func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope, } for i := range unspentOutputs { output := &unspentOutputs[i] - if !confirmed( + if !hasMinConfs( //nolint:gosec uint32(requiredConfs), output.Height, syncBlock.Height, @@ -2691,7 +2692,7 @@ func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope, continue } - if output.FromCoinBase && !confirmed( + if output.FromCoinBase && !hasMinConfs( uint32(w.ChainParams().CoinbaseMaturity), output.Height, syncBlock.Height, ) { @@ -2794,7 +2795,7 @@ func (w *Wallet) ListUnspentDeprecated(minconf, maxconf int32, // Outputs with fewer confirmations than the minimum or // more confs than the maximum are excluded. - confs := confirms(output.Height, syncBlock.Height) + confs := calcConf(output.Height, syncBlock.Height) if confs < minconf || confs > maxconf { continue } @@ -2804,7 +2805,7 @@ func (w *Wallet) ListUnspentDeprecated(minconf, maxconf int32, target := uint32( w.ChainParams().CoinbaseMaturity, ) - if !confirmed( + if !hasMinConfs( target, output.Height, syncBlock.Height, ) { @@ -3291,10 +3292,10 @@ func (w *Wallet) newChangeAddress(addrmgrNs walletdb.ReadWriteBucket, return addrs[0].Address(), nil } -// confirmed checks whether a transaction at height txHeight has met minconf +// hasMinConfs checks whether a transaction at height txHeight has met minconf // confirmations for a blockchain at height curHeight. -func confirmed(minconf uint32, txHeight, curHeight int32) bool { - confs := confirms(txHeight, curHeight) +func hasMinConfs(minconf uint32, txHeight, curHeight int32) bool { + confs := calcConf(txHeight, curHeight) if confs < 0 { return false } @@ -3302,10 +3303,10 @@ func confirmed(minconf uint32, txHeight, curHeight int32) bool { return uint32(confs) >= minconf } -// confirms returns the number of confirmations for a transaction given its +// calcConf returns the number of confirmations for a transaction given its // containing block height and the current best block height. Unconfirmed // transactions have a height of -1 and are considered to have 0 confirmations. -func confirms(txHeight, curHeight int32) int32 { +func calcConf(txHeight, curHeight int32) int32 { switch { // Unconfirmed transactions have 0 confirmations. case txHeight == -1: @@ -3390,7 +3391,7 @@ func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, res := &results[acctIndex] res.TotalReceived += cred.Amount - confs := confirms( + confs := calcConf( detail.Block.Height, syncBlock.Height, ) From 81073dd20dfb9b4a3a8ea06133bc4243286553eb Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Sun, 16 Nov 2025 20:14:54 +0000 Subject: [PATCH 116/691] wallet: precompute owned_addrs <-> output_indices structure --- wallet/tx_publisher.go | 68 +++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 273f54c8e0..9566311b2d 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -219,6 +219,18 @@ type creditInfo struct { addr btcutil.Address } +// ownedAddrInfo holds information about a wallet-owned address and the +// transaction output indices that pay to it. +type ownedAddrInfo struct { + // managedAddr represents the managed address. + managedAddr waddrmgr.ManagedAddress + + // outputIndices contains the transaction output indices that contain + // this address. The indices are not guaranteed to be sorted in any + // order. + outputIndices []uint32 +} + // addTxToWallet adds a tx to the wallet's database. This function is a critical // part of the wallet's transaction processing pipeline and is designed for high // performance and atomicity. It follows a four-stage process: @@ -271,34 +283,31 @@ func (w *Wallet) addTxToWallet(tx *wire.MsgTx, // Stage 3: Prepare a definitive "write plan". This plan is created in // memory and contains all the information needed for the final atomic // database update. - var ( - creditsToWrite []creditInfo - ourAddrs []btcutil.Address - ) + // + // Pre-allocate slices with exact capacity to avoid reallocations. + // We know the exact number of credits from the total output indices + // across all owned addresses. + var totalCredits int + for _, info := range ownedAddrs { + totalCredits += len(info.outputIndices) + } - // The nested loop structure here is critical. It iterates through the - // original transaction outputs and uses the `ownedAddrs` map as a - // quick lookup table. This correctly handles the edge case where a - // single transaction has multiple outputs paying to the same address, - // as it ensures a distinct entry in the `creditsToWrite` slice is - // created for each individual output. For example, if a transaction - // has two outputs (index 0 and 1) that both pay to `addr_A`, this - // loop will create two separate entries in `creditsToWrite`, one for - // each index, ensuring both UTXOs are correctly credited. - for index, addrs := range txOutAddrs { - for _, addr := range addrs { - ma, ok := ownedAddrs[addr] - if !ok { - continue - } + creditsToWrite := make([]creditInfo, 0, totalCredits) + ourAddrs := make([]btcutil.Address, 0, len(ownedAddrs)) + // Iterate directly over owned addresses and their pre-computed output + // indices. This correctly handles the edge case where a single + // transaction has multiple outputs paying to the same address. + for addr, info := range ownedAddrs { + for _, index := range info.outputIndices { creditsToWrite = append(creditsToWrite, creditInfo{ index: index, - ma: ma, + ma: info.managedAddr, addr: addr, }) - ourAddrs = append(ourAddrs, addr) } + + ourAddrs = append(ourAddrs, addr) } // Stage 4: Atomically execute the write plan. This is the only stage @@ -408,22 +417,22 @@ func (w *Wallet) extractTxAddrs(tx *wire.MsgTx) map[uint32][]btcutil.Address { // performed only once for each unique address. func (w *Wallet) filterOwnedAddresses( txOutAddrs map[uint32][]btcutil.Address) ( - map[btcutil.Address]waddrmgr.ManagedAddress, error) { + map[btcutil.Address]ownedAddrInfo, error) { - ownedAddrs := make(map[btcutil.Address]waddrmgr.ManagedAddress) + ownedAddrs := make(map[btcutil.Address]ownedAddrInfo) // Pre-deduplicate addresses outside the DB transaction. - uniqueAddrs := make(map[btcutil.Address]struct{}) - for _, addrs := range txOutAddrs { + uniqueAddrs := make(map[btcutil.Address][]uint32) + for index, addrs := range txOutAddrs { for _, addr := range addrs { - uniqueAddrs[addr] = struct{}{} + uniqueAddrs[addr] = append(uniqueAddrs[addr], index) } } err := walletdb.View(w.db, func(dbTx walletdb.ReadTx) error { addrmgrNs := dbTx.ReadBucket(waddrmgrNamespaceKey) - for addr := range uniqueAddrs { + for addr, indices := range uniqueAddrs { ma, err := w.addrStore.Address(addrmgrNs, addr) // If the address is not found, it simply means @@ -440,7 +449,10 @@ func (w *Wallet) filterOwnedAddresses( return err } - ownedAddrs[addr] = ma + ownedAddrs[addr] = ownedAddrInfo{ + managedAddr: ma, + outputIndices: indices, + } } return nil From 042f7a01a62b74290d665e8b3805997a86924158 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Sun, 16 Nov 2025 20:16:15 +0000 Subject: [PATCH 117/691] wallet: adapt chain backend mock for later benchmarking --- wallet/mock.go | 125 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 8 deletions(-) diff --git a/wallet/mock.go b/wallet/mock.go index 893fed746f..fad58fa86b 100644 --- a/wallet/mock.go +++ b/wallet/mock.go @@ -2,6 +2,9 @@ package wallet import ( "context" + "errors" + "maps" + "sync" "time" "github.com/btcsuite/btcd/btcjson" @@ -12,50 +15,107 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" ) +var ( + // errTxnAlreadyInMempool is returned when a transaction already exists + // in the mempool. + errTxnAlreadyInMempool = "txn-already-in-mempool" + + // ErrNotImplemented is returned when a mock method is not implemented. + ErrNotImplemented = errors.New("not implemented") +) + type mockChainClient struct { getBestBlockHeight int32 getBlockHashFunc func() (*chainhash.Hash, error) getBlockHeader *wire.BlockHeader + + // mempool tracks transactions that have been broadcast to simulate + // mempool behavior for benchmarks. + mempool map[chainhash.Hash]*wire.MsgTx + + // mu protects concurrent reads and writes to mempool. + mu sync.RWMutex } var _ chain.Interface = (*mockChainClient)(nil) func (m *mockChainClient) Start(_ context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.mempool == nil { + m.mempool = make(map[chainhash.Hash]*wire.MsgTx) + } + return nil } func (m *mockChainClient) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + + m.mempool = nil } func (m *mockChainClient) WaitForShutdown() {} +// ResetMempool clears all transactions from the mock mempool. +func (m *mockChainClient) ResetMempool() { + m.mu.Lock() + defer m.mu.Unlock() + + m.mempool = make(map[chainhash.Hash]*wire.MsgTx) +} + func (m *mockChainClient) GetBestBlock() (*chainhash.Hash, int32, error) { return nil, m.getBestBlockHeight, nil } func (m *mockChainClient) GetBlock(*chainhash.Hash) (*wire.MsgBlock, error) { - return nil, nil + return nil, ErrNotImplemented } func (m *mockChainClient) GetBlockHash(int64) (*chainhash.Hash, error) { if m.getBlockHashFunc != nil { return m.getBlockHashFunc() } - return nil, nil + + return nil, ErrNotImplemented } func (m *mockChainClient) GetBlockHeader(*chainhash.Hash) (*wire.BlockHeader, error) { + return m.getBlockHeader, nil } +func (m *mockChainClient) GetMempool() (map[chainhash.Hash]*wire.MsgTx, error) { + // Acquire read lock non-exclusively. It allows concurrent readers and + // blocks writers. + m.mu.RLock() + defer m.mu.RUnlock() + + // Return a shallow copy of the map to avoid TOCTOU + // (time-of-check-to-time-of-use) races. Returning m.mempool directly + // would share the map reference - after RUnlock(), concurrent writes + // could modify the map structure during caller's iteration causing: + // "fatal error: concurrent map iteration and map write". + // Note: This is a shallow copy - the *wire.MsgTx pointers are shared. + // We assume transactions are not mutated after creation. + result := make(map[chainhash.Hash]*wire.MsgTx, len(m.mempool)) + maps.Copy(result, m.mempool) + + return result, nil +} + func (m *mockChainClient) IsCurrent() bool { return false } func (m *mockChainClient) FilterBlocks(*chain.FilterBlocksRequest) ( *chain.FilterBlocksResponse, error) { - return nil, nil + + return nil, ErrNotImplemented } func (m *mockChainClient) BlockStamp() (*waddrmgr.BlockStamp, error) { @@ -66,13 +126,30 @@ func (m *mockChainClient) BlockStamp() (*waddrmgr.BlockStamp, error) { }, nil } -func (m *mockChainClient) SendRawTransaction(*wire.MsgTx, bool) ( - *chainhash.Hash, error) { - return nil, nil +func (m *mockChainClient) SendRawTransaction(tx *wire.MsgTx, + allowHighFees bool) (*chainhash.Hash, error) { + + // Acquire write lock exclusively. It blocks all readers and writers. + m.mu.Lock() + defer m.mu.Unlock() + + txHash := tx.TxHash() + + // Reject duplicate transactions to isolate the external behavior of + // real chain backends. This is important for reliable testing and + // benchmarking handling in broadcast APIs. + if _, exists := m.mempool[txHash]; exists { + return nil, chain.ErrTxAlreadyInMempool + } + + m.mempool[txHash] = tx + + return &txHash, nil } func (m *mockChainClient) Rescan(*chainhash.Hash, []btcutil.Address, map[wire.OutPoint]btcutil.Address) error { + return nil } @@ -99,9 +176,41 @@ func (m *mockChainClient) BackEnd() string { func (m *mockChainClient) TestMempoolAccept(txns []*wire.MsgTx, maxFeeRate float64) ([]*btcjson.TestMempoolAcceptResult, error) { - return nil, nil + // Acquire read lock non-exclusively. It allows concurrent readers and + // blocks writers. + m.mu.RLock() + defer m.mu.RUnlock() + + // Return acceptance result for each transaction. + results := make([]*btcjson.TestMempoolAcceptResult, len(txns)) + for i := range txns { + txHash := txns[i].TxHash() + result := &btcjson.TestMempoolAcceptResult{ + Txid: txHash.String(), + } + + // Check if transaction already exists in mempool. + if _, exists := m.mempool[txHash]; exists { + result.Allowed = false + result.RejectReason = errTxnAlreadyInMempool + } else { + result.Allowed = true + } + + results[i] = result + } + + return results, nil } func (m *mockChainClient) MapRPCErr(err error) error { - return nil + if err == nil { + return nil + } + + if err.Error() == errTxnAlreadyInMempool { + return chain.ErrTxAlreadyInMempool + } + + return err } From 5c7d4c41b5e9374702992ed0279e32b10210c8c5 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Sun, 16 Nov 2025 20:17:41 +0000 Subject: [PATCH 118/691] wallet: rename `mock.go` to `chain_mock_test.go` --- wallet/{mock.go => chain_mock_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename wallet/{mock.go => chain_mock_test.go} (100%) diff --git a/wallet/mock.go b/wallet/chain_mock_test.go similarity index 100% rename from wallet/mock.go rename to wallet/chain_mock_test.go From 88a57b46e04aee7cfd799e08df1939027794624f Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Sun, 16 Nov 2025 20:18:13 +0000 Subject: [PATCH 119/691] wallet: refactor benchmarking framework --- wallet/account_manager_benchmark_test.go | 562 ++++++++++++++++------- wallet/address_manager_benchmark_test.go | 513 +++++++++++++++------ wallet/benchmark_helpers_test.go | 505 +++++++++++++------- wallet/utxo_manager_benchmark_test.go | 407 +++++++++++----- 4 files changed, 1407 insertions(+), 580 deletions(-) diff --git a/wallet/account_manager_benchmark_test.go b/wallet/account_manager_benchmark_test.go index 0fe0c0ff4e..b7c1d4dea8 100644 --- a/wallet/account_manager_benchmark_test.go +++ b/wallet/account_manager_benchmark_test.go @@ -13,25 +13,55 @@ import ( // multiple dataset sizes. Test names start with dataset size to group API // comparisons for benchstat analysis. func BenchmarkListAccountsByScopeAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - addressGrowth: constantGrowth, - utxoGrowth: exponentialGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -44,13 +74,13 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -72,25 +102,55 @@ func BenchmarkListAccountsByScopeAPI(b *testing.B) { // sizes. Test names start with dataset size to group API comparisons for // benchstat analysis. func BenchmarkListAccountsAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - addressGrowth: constantGrowth, - utxoGrowth: exponentialGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := waddrmgr.DefaultKeyScopes - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = waddrmgr.DefaultKeyScopes + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -103,13 +163,13 @@ func BenchmarkListAccountsAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -129,27 +189,57 @@ func BenchmarkListAccountsAPI(b *testing.B) { // multiple dataset sizes. Test names start with dataset size to group API // comparisons for benchstat analysis. func BenchmarkListAccountsByNameAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - addressGrowth: constantGrowth, - utxoGrowth: exponentialGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = waddrmgr.DefaultKeyScopes ) - scopes := waddrmgr.DefaultKeyScopes - for _, size := range benchmarkSizes { - accountName, _ := generateAccountName(size.numAccounts, scopes) + for i := 0; i <= maxGrowthIteration; i++ { + accountName, _ := generateAccountName(accountGrowth[i], scopes) + + name := fmt.Sprintf("%0*d-Accounts-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -164,13 +254,13 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -192,23 +282,50 @@ func BenchmarkListAccountsByNameAPI(b *testing.B) { // names start with dataset size to group API comparisons for benchstat // analysis. func BenchmarkNewAccountAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ - accountGrowth: linearGrowth, - addressGrowth: constantGrowth, - utxoGrowth: constantGrowth, - maxIterations: 10, - startIndex: 0, - }) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} - - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 10 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -232,13 +349,13 @@ func BenchmarkNewAccountAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -268,25 +385,57 @@ func BenchmarkNewAccountAPI(b *testing.B) { // using identical account lookups across multiple dataset sizes. Test names // start with dataset size to group API comparisons for benchstat analysis. func BenchmarkGetAccountAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ - accountGrowth: exponentialGrowth, - addressGrowth: constantGrowth, - utxoGrowth: exponentialGrowth, - maxIterations: 14, - startIndex: 0, - }) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} - - for _, size := range benchmarkSizes { - accountName, _ := generateAccountName(size.numAccounts, scopes) - - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + accountName, _ := generateAccountName(accountGrowth[i], scopes) + + name := fmt.Sprintf("%0*d-Accounts-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -301,13 +450,13 @@ func BenchmarkGetAccountAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -329,28 +478,55 @@ func BenchmarkGetAccountAPI(b *testing.B) { // dataset sizes. Test names start with dataset size to group API comparisons // for benchstat analysis. func BenchmarkRenameAccountAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ - accountGrowth: exponentialGrowth, - addressGrowth: constantGrowth, - utxoGrowth: constantGrowth, - maxIterations: 11, - startIndex: 0, - }) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} - - for _, size := range benchmarkSizes { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 11 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + ) + + for i := 0; i <= maxGrowthIteration; i++ { accountName, accountNumber := generateAccountName( - size.numAccounts, scopes, + accountGrowth[i], scopes, ) - newName := accountName + "-renamed" + newAccountName := accountName + "-renamed" - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + name := fmt.Sprintf("%0*d-Accounts", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -359,9 +535,12 @@ func BenchmarkRenameAccountAPI(b *testing.B) { count := 0 for b.Loop() { - newName2 := fmt.Sprintf("%s-%d", newName, count) + newAccountName2 := fmt.Sprintf("%s-%d", + newAccountName, count) + err := w.RenameAccountDeprecated( - scopes[0], accountNumber, newName2, + scopes[0], accountNumber, + newAccountName2, ) require.NoError(b, err) @@ -376,32 +555,36 @@ func BenchmarkRenameAccountAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) + newAccountName := accountName + "-renamed" + b.ReportAllocs() b.ResetTimer() count := 0 for b.Loop() { - newName2 := fmt.Sprintf("%s-%d", newName, count) + newAccountName2 := fmt.Sprintf("%s-%d", + newAccountName, count) + err := w.RenameAccount( b.Context(), scopes[0], accountName, - newName2, + newAccountName2, ) require.NoError(b, err) // Rename back to original to keep the benchmark // idempotent. err = w.RenameAccount( - b.Context(), scopes[0], newName2, + b.Context(), scopes[0], newAccountName2, accountName, ) require.NoError(b, err) @@ -416,26 +599,59 @@ func BenchmarkRenameAccountAPI(b *testing.B) { // using identical balance lookups across multiple dataset sizes. Test names // start with dataset size to group API comparisons for benchstat analysis. func BenchmarkGetBalanceAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ - accountGrowth: linearGrowth, - addressGrowth: constantGrowth, - utxoGrowth: exponentialGrowth, - maxIterations: 14, - startIndex: 0, - }) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} - confirmations := int32(0) - - for _, size := range benchmarkSizes { - accountName, _ := generateAccountName(size.numAccounts, scopes) - - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + + confirmations = int32(0) + ) + + for i := 0; i <= maxGrowthIteration; i++ { + accountName, _ := generateAccountName(accountGrowth[i], scopes) + + name := fmt.Sprintf("%0*d-Accounts-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -451,13 +667,13 @@ func BenchmarkGetBalanceAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -480,28 +696,56 @@ func BenchmarkGetBalanceAPI(b *testing.B) { // across multiple dataset sizes. Test names start with dataset size to group // API comparisons for benchstat analysis. func BenchmarkImportAccountAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes(benchmarkConfig{ - accountGrowth: linearGrowth, - addressGrowth: constantGrowth, - utxoGrowth: constantGrowth, - maxIterations: 10, - startIndex: 0, - }) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} - dryRun := false - - for _, size := range benchmarkSizes { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 10 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + dryRun = false + ) + + for i := 0; i <= maxGrowthIteration; i++ { accountKey, masterFingerprint, addrT := generateTestExtendedKey( - b, size.numAccounts, + b, accountGrowth[i], ) - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + name := fmt.Sprintf("%0*d-Accounts", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -526,13 +770,13 @@ func BenchmarkImportAccountAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index 98b18eeb8f..8032ea38af 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -1,6 +1,7 @@ package wallet import ( + "fmt" "testing" "github.com/btcsuite/btcwallet/waddrmgr" @@ -12,30 +13,66 @@ import ( // dataset sizes. Test names start with dataset size to group API comparisons // for benchstat analysis. func BenchmarkListAddressesAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - utxoGrowth: exponentialGrowth, - addressGrowth: exponentialGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} - addrType := waddrmgr.PubKeyHash - for _, size := range benchmarkSizes { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + + addrType = waddrmgr.PubKeyHash + ) + + for i := 0; i <= maxGrowthIteration; i++ { accountName, accountNumber := generateAccountName( - size.numAccounts, scopes, + accountGrowth[i], scopes, ) - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -50,13 +87,13 @@ func BenchmarkListAddressesAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -78,30 +115,60 @@ func BenchmarkListAddressesAPI(b *testing.B) { // dataset sizes. Test names start with dataset size to group API comparisons // for benchstat analysis. func BenchmarkAddressInfoAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - utxoGrowth: constantGrowth, - addressGrowth: exponentialGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) testAddr := getTestAddress( - b, bw.Wallet, size.numAccounts, + b, bw.Wallet, accountGrowth[i], ) b.ReportAllocs() @@ -113,18 +180,18 @@ func BenchmarkAddressInfoAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) testAddr := getTestAddress( - b, bw.Wallet, size.numAccounts, + b, bw.Wallet, accountGrowth[i], ) b.ReportAllocs() @@ -145,30 +212,61 @@ func BenchmarkAddressInfoAPI(b *testing.B) { // demonstrates the trade-off between performance (O(1) vs O(n)) and safety // (preventing address reuse and BIP44 gap limit violations). func BenchmarkGetUnusedAddressAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - utxoGrowth: constantGrowth, - addressGrowth: exponentialGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} - addrType := waddrmgr.PubKeyHash - for _, size := range benchmarkSizes { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + + addrType = waddrmgr.PubKeyHash + ) + + for i := 0; i <= maxGrowthIteration; i++ { accountName, accountNumber := generateAccountName( - size.numAccounts, scopes, + accountGrowth[i], scopes, ) - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -187,13 +285,13 @@ func BenchmarkGetUnusedAddressAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -221,30 +319,61 @@ func BenchmarkGetUnusedAddressAPI(b *testing.B) { // API comparisons for benchstat analysis. The benchmark demonstrates that the // new API maintains performance parity with the deprecated API. func BenchmarkNewAddressAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - utxoGrowth: constantGrowth, - addressGrowth: exponentialGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} - addrType := waddrmgr.PubKeyHash - for _, size := range benchmarkSizes { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0044} + + addrType = waddrmgr.PubKeyHash + ) + + for i := 0; i <= maxGrowthIteration; i++ { accountName, accountNumber := generateAccountName( - size.numAccounts, scopes, + accountGrowth[i], scopes, ) - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -259,13 +388,13 @@ func BenchmarkNewAddressAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -289,26 +418,57 @@ func BenchmarkNewAddressAPI(b *testing.B) { // comparisons for benchstat analysis. The benchmark demonstrates that the new // API maintains performance parity with the deprecated API. func BenchmarkImportPublicKeyAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - utxoGrowth: constantGrowth, - addressGrowth: linearGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} - addrType := waddrmgr.WitnessPubKey - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + addrType = waddrmgr.WitnessPubKey + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -320,7 +480,7 @@ func BenchmarkImportPublicKeyAPI(b *testing.B) { // Generate a unique key for each iteration to // avoid in-memory cache collision and for an // idempotent benchmark iteration test. - seedIndex := size.numAccounts + iterCount + seedIndex := accountGrowth[i] + iterCount key, _, _ := generateTestExtendedKey( b, seedIndex, ) @@ -336,13 +496,13 @@ func BenchmarkImportPublicKeyAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -354,7 +514,7 @@ func BenchmarkImportPublicKeyAPI(b *testing.B) { // Generate a unique key for each iteration to // avoid in-memory cache collision and for an // idempotent benchmark iteration test. - seedIndex := size.numAccounts + iterCount + seedIndex := accountGrowth[i] + iterCount key, _, _ := generateTestExtendedKey( b, seedIndex, ) @@ -378,27 +538,59 @@ func BenchmarkImportPublicKeyAPI(b *testing.B) { // to group API comparisons for benchstat analysis. The benchmark demonstrates // that the new API maintains performance parity with the deprecated API. func BenchmarkImportTaprootScriptAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - utxoGrowth: constantGrowth, - addressGrowth: linearGrowth, - maxIterations: 10, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 10 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0086} + + witnessVersion = 1 + + isSecretScript = false ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0086} - witnessVersion := 1 - isSecretScript := false - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -411,7 +603,7 @@ func BenchmarkImportTaprootScriptAPI(b *testing.B) { // iteration to avoid in-memory cache collision // and for an idempotent benchmark iteration // test. - seedIndex := size.numAccounts + iterCount + seedIndex := accountGrowth[i] + iterCount key, _, _ := generateTestExtendedKey( b, seedIndex, ) @@ -431,13 +623,13 @@ func BenchmarkImportTaprootScriptAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { w := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -450,7 +642,7 @@ func BenchmarkImportTaprootScriptAPI(b *testing.B) { // iteration to avoid in-memory cache collision // and for an idempotent benchmark iteration // test. - seedIndex := size.numAccounts + iterCount + seedIndex := accountGrowth[i] + iterCount key, _, _ := generateTestExtendedKey( b, seedIndex, ) @@ -476,30 +668,65 @@ func BenchmarkImportTaprootScriptAPI(b *testing.B) { // comparisons for benchstat analysis. The benchmark demonstrates that the new // API maintains performance parity with the deprecated API. func BenchmarkScriptForOutputAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: constantGrowth, - utxoGrowth: constantGrowth, - addressGrowth: exponentialGrowth, - maxIterations: 10, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 10 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) testAddr := getTestAddress( - b, bw.Wallet, size.numAccounts, + b, bw.Wallet, accountGrowth[i], ) testTxOut := generateTestTxOut(b, testAddr) @@ -514,18 +741,18 @@ func BenchmarkScriptForOutputAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) testAddr := getTestAddress( - b, bw.Wallet, size.numAccounts, + b, bw.Wallet, accountGrowth[i], ) testTxOut := generateTestTxOut(b, testAddr) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 3f1638da08..2c01b447f7 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -30,116 +30,73 @@ var errAccountNotFound = errors.New("account not found") type growthFunc func(i int) int // constantGrowth returns a constant value regardless of iteration. +// +// Use when: The parameter is a control variable not under test and should +// remain fixed across all iterations. +// +// Example: accountGrowth when testing transaction complexity (not account +// scaling). +// +// Note: Ideal for CI as it produces predictable, stable results for regression +// detection. +// +// Result: 5, 5, 5, 5, 5... func constantGrowth(i int) int { return 5 } -// linearGrowth scales the parameter value linearly. +// linearGrowth scales the parameter value linearly with arithmetic progression. +// +// Use when: Testing gradual scaling behavior, O(n) or O(n²) algorithms, or +// when detailed granularity is needed across a moderate range. +// +// Example: Transaction I/O counts, address counts, or database record counts +// where you want to see how performance degrades proportionally. +// +// Note: Safe for CI when used with limited range (e.g., i = 0..9 yields 5..50) +// for regression detection. Avoid for O(log n) algorithms as x grows linearly +// while y grows logarithmically, making regressions harder to detect. +// +// Result: 5, 10, 15, 20, 25, 30, 35... func linearGrowth(i int) int { return 5 + (i * 5) } -// exponentialGrowth scales the parameter value exponentially. +// exponentialGrowth scales the parameter value exponentially (powers of 2). +// +// Use when: Stress testing scalability limits, testing concurrency levels, or +// quickly covering a wide range from small to large values. Works well for +// algorithms with O(log n) complexity as it creates a linear relationship when +// plotted (e.g., y = log₂(x) when x grows exponentially, y grows linearly). +// +// Example: Concurrent worker counts, cache sizes, or finding performance +// breaking points. +// +// Note: Avoid running in CI due to large values and long execution times. Use +// for local performance analysis only. +// +// Result: 1, 2, 4, 8, 16, 32, 64, 128, 256... func exponentialGrowth(i int) int { return 1 << i } -// benchmarkDataSize represents the test data size for a single benchmark -// iteration. -type benchmarkDataSize struct { - // numAccounts is the number of accounts to create. - numAccounts int - - // numUTXOs is the number of UTXOs to create. - numUTXOs int - - // numAddresses is the number of addresses to create. - numAddresses int -} - -// benchmarkNamingInfo holds metadata for generating benchmark names. -type benchmarkNamingInfo struct { - // maxAccounts is the maximum number of accounts in the benchmark - // series. That would helpful in determining the dynamic padding for the - // account digits - maxAccounts int - - // maxUTXOs is the maximum number of UTXOs in the benchmark series. That - // would helpful in determining the dynamic padding for the UTXO digits. - maxUTXOs int - - // maxAddresses is the maximum number of addresses in the benchmark - // series. That would helpful in determining the dynamic padding for the - // address digits. - maxAddresses int -} - -// name returns a dynamically generated benchmark name based on accounts, -// UTXOs, and addresses. Uses dynamic padding based on maximum values for -// proper sorting in visualization tools. -func (b benchmarkDataSize) name(namingInfo benchmarkNamingInfo) string { - accountDigits := len(strconv.Itoa(namingInfo.maxAccounts)) - - name := fmt.Sprintf("%0*d-Accounts", accountDigits, b.numAccounts) - - if b.numAddresses > 0 { - addressDigits := len(strconv.Itoa(namingInfo.maxAddresses)) - name += fmt.Sprintf("-%0*d-Addresses", addressDigits, - b.numAddresses) +// mapRange maps fn over indices [start..end] (inclusive) and returns the +// results. This provides functional-style array generation for benchmarks. +// +//nolint:unparam // Different benchmarks may intentionally use different values +func mapRange(start, end int, fn growthFunc) []int { + result := make([]int, end-start+1) + for i := range result { + result[i] = fn(start + i) } - if b.numUTXOs > 0 { - utxoDigits := len(strconv.Itoa(namingInfo.maxUTXOs)) - name += fmt.Sprintf("-%0*d-UTXOs", utxoDigits, b.numUTXOs) - } - - return name -} - -// benchmarkConfig holds configuration for benchmark wallet setup. -type benchmarkConfig struct { - // accountGrowth is the function to use to grow the number of accounts. - accountGrowth growthFunc - - // utxoGrowth is the function to use to grow the number of UTXOs. - utxoGrowth growthFunc - - // addressGrowth is the function to use to grow the number of addresses. - addressGrowth growthFunc - - // maxIterations is the maximum number of iterations to run. - maxIterations int - - // startIndex is the index to start the benchmark at. - startIndex int + return result } -// generateBenchmarkSizes creates benchmark data sizes programmatically. -func generateBenchmarkSizes( - config benchmarkConfig) ([]benchmarkDataSize, benchmarkNamingInfo) { - - var sizes []benchmarkDataSize - - // Calculate maximum values for proper padding. - maxAccounts := config.accountGrowth(config.maxIterations) - maxUTXOs := config.utxoGrowth(config.maxIterations) - maxAddresses := config.addressGrowth(config.maxIterations) - - namingInfo := benchmarkNamingInfo{ - maxAccounts: maxAccounts, - maxUTXOs: maxUTXOs, - maxAddresses: maxAddresses, - } - - for i := config.startIndex; i <= config.maxIterations; i++ { - sizes = append(sizes, benchmarkDataSize{ - numAccounts: config.accountGrowth(i), - numUTXOs: config.utxoGrowth(i), - numAddresses: config.addressGrowth(i), - }) - } - - return sizes, namingInfo +// decimalWidth returns the number of characters in the decimal representation +// of given value. +func decimalWidth(value int) int { + return len(strconv.Itoa(value)) } // benchmarkWalletConfig holds configuration for benchmark wallet setup. @@ -150,25 +107,42 @@ type benchmarkWalletConfig struct { // numAccounts is the number of accounts to create. numAccounts int - // numUTXOs is the number of UTXOs to create. - numUTXOs int + // numWalletTxs is the number of wallet transactions to create. + numWalletTxs int // numAddresses is the number of addresses to create. numAddresses int + + // numTxInputs is the number of inputs per transaction. If 0, defaults + // to 1 input per transaction. + numTxInputs int + + // numTxOutputs is the number of outputs per transaction. If 0, + // defaults to 1 output per transaction. + numTxOutputs int } -// benchmarkWallet holds a wallet and its created UTXO outpoints. +// benchmarkWallet holds a wallet and its created wallet transactions. type benchmarkWallet struct { *Wallet - outpoints []wire.OutPoint + // confirmedTxs contains confirmed wallet transactions created during + // benchmark setup. These are spending transactions with both debits + // (inputs) and credits (outputs) that have been mined in blocks. + confirmedTxs []*wire.MsgTx + + // unconfirmedTxs contains unconfirmed wallet transactions created + // during benchmark setup. These are spending transactions with both + // debits (inputs) and credits (outputs) that are in the mempool. + unconfirmedTxs []*wire.MsgTx } // setupBenchmarkWallet creates a wallet with test data based on the provided // configuration. It distributes accounts evenly across the specified scopes -// and returns the wallet along with the outpoints of all created UTXOs. +// and returns the wallet along with the outpoints of all created UTXOs. If +// config.miner is provided, the wallet is connected to the btcd node via RPC. func setupBenchmarkWallet(tb testing.TB, - config benchmarkWalletConfig) *benchmarkWallet { + cfg benchmarkWalletConfig) *benchmarkWallet { tb.Helper() @@ -180,24 +154,35 @@ func setupBenchmarkWallet(tb testing.TB, require.False(tb, setupT.Failed(), "testWallet setup failed") addresses := createTestAccounts( - tb, w, config.scopes, config.numAccounts, - config.numAddresses, + tb, w, cfg.scopes, cfg.numAccounts, cfg.numAddresses, ) - outpoints := createTestUTXOs(tb, w, addresses, config.numUTXOs) - - // Sync wallet to the block height where UTXOs were created. - setSyncedToHeight(tb, w, 1) + var txsResult *testWalletTxsResult + if cfg.numWalletTxs > 0 { + txsResult = createTestWalletTxs( + tb, w, addresses, cfg.numWalletTxs, cfg.numTxInputs, + cfg.numTxOutputs, + ) + } else { + // Return empty result if no transactions requested. + txsResult = &testWalletTxsResult{ + confirmed: []*wire.MsgTx{}, + unconfirmed: []*wire.MsgTx{}, + } + } return &benchmarkWallet{ - Wallet: w, - outpoints: outpoints, + Wallet: w, + confirmedTxs: txsResult.confirmed, + unconfirmedTxs: txsResult.unconfirmed, } } // setSyncedToHeight updates the wallet's synced block height. This is useful // for benchmark tests to ensure confirmation calculations work correctly. -func setSyncedToHeight(tb testing.TB, w *Wallet, height int32) { +func setSyncedToHeight(tb testing.TB, w *Wallet, height int32, + hash chainhash.Hash) { + tb.Helper() err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { @@ -205,7 +190,7 @@ func setSyncedToHeight(tb testing.TB, w *Wallet, height int32) { return w.addrStore.SetSyncedTo(addrmgrNs, &waddrmgr.BlockStamp{ Height: height, - Hash: chainhash.Hash{}, + Hash: hash, }) }) require.NoError(tb, err, "failed to set synced height to %d", height) @@ -284,93 +269,269 @@ func createAccountsInScope(w *Wallet, tx walletdb.ReadWriteTx, return nil } -// createTestUTXOs creates the specified number of test UTXOs using the provided -// addresses for benchmark data setup. It returns the outpoints of all created -// UTXOs. -func createTestUTXOs(tb testing.TB, w *Wallet, - addresses []waddrmgr.ManagedAddress, numUTXOs int) []wire.OutPoint { +// testWalletTxsResult holds the result of creating test wallet transactions. +type testWalletTxsResult struct { + // confirmed contains confirmed spending transactions. + confirmed []*wire.MsgTx + + // unconfirmed contains unconfirmed spending transactions. + unconfirmed []*wire.MsgTx + + // highestBlockMeta is the metadata for the highest block containing + // confirmed transactions. + highestBlockMeta wtxmgr.BlockMeta +} + +// createTestWalletTxs creates diverse test wallet transactions with both +// confirmed and unconfirmed transaction history. The goal is diversity for more +// comprehensive benchmark testing. The function creates four passes of +// transactions: +// 1. Initial confirmed UTXOs for confirmed spending txs (credits only) +// 2. Confirmed spending transactions (debits + credits, mined in blocks) +// 3. Initial confirmed UTXOs for unconfirmed spending txs (credits only) +// 4. Unconfirmed spending transactions (debits + credits, unmined/mempool) +// +// Each set of spending transactions uses separate UTXOs to avoid double-spend +// conflicts. numInputs and numOutputs control transaction complexity. Returns +// both confirmed and unconfirmed spending transactions. +func createTestWalletTxs(tb testing.TB, w *Wallet, + addresses []waddrmgr.ManagedAddress, numTxs, + numInputs, numOutputs int) *testWalletTxsResult { tb.Helper() - var outpoints []wire.OutPoint + var ( + txsConfirmed []*wire.MsgTx + txsUnconfirmed []*wire.MsgTx + highestBlockMeta wtxmgr.BlockMeta + ) err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) msgTx := TstTx.MsgTx() - blockMeta := &wtxmgr.BlockMeta{ - Block: wtxmgr.Block{ - Hash: chainhash.Hash{}, - Height: 1, - }, - Time: time.Now(), - } + var ( + initialCreditsConfirmed []*wire.MsgTx + prevOutpointsConfirmed []wire.OutPoint + ) - for i := 0; i < numUTXOs && i < len(addresses); i++ { - newMsgTx := wire.NewMsgTx(msgTx.Version) - addr := addresses[i%len(addresses)] + var ( + baseBlockHeight int32 = 1 + mined = true + ) - pkScript, err := txscript.PayToAddrScript( - addr.Address(), - ) - if err != nil { - return err - } + // First pass: Create initial UTXOs (credits only, no debits). + initialCreditsConfirmed, highestBlockMeta = createTxBatch( + tb, w, txmgrNs, addrmgrNs, addresses, numTxs, + msgTx.Version, baseBlockHeight, 200000, nil, 0, + numOutputs, mined, + ) - // Add a dummy tx output to make it valid. - amount := btcutil.Amount(100000 + i*1000) - txOut := wire.NewTxOut(int64(amount), pkScript) - newMsgTx.AddTxOut(txOut) + prevOutpointsConfirmed = txsToOutpoints( + initialCreditsConfirmed, + ) - // Add a dummy tx input to make it valid. - prevHash := chainhash.Hash{} - prevHash[0] = byte(i) - txIn := wire.NewTxIn( - wire.NewOutPoint(&prevHash, 0), nil, nil, - ) - newMsgTx.AddTxIn(txIn) + // Second pass: Create confirmed spending transactions + baseBlockHeight = highestBlockMeta.Height + 1 + txsConfirmed, highestBlockMeta = createTxBatch( + tb, w, txmgrNs, addrmgrNs, addresses, numTxs, + msgTx.Version, baseBlockHeight, 100000, + prevOutpointsConfirmed, numInputs, numOutputs, mined, + ) - rec, err := wtxmgr.NewTxRecordFromMsgTx( - newMsgTx, time.Now(), - ) - if err != nil { - return err - } + // Third pass: Create initial UTXOs for unconfirmed spending + // txs. + baseBlockHeight = highestBlockMeta.Height + 1 + initialCreditsConfirmed, highestBlockMeta = createTxBatch( + tb, w, txmgrNs, addrmgrNs, addresses, numTxs, + msgTx.Version, baseBlockHeight, 200000, nil, 0, + numOutputs, mined, + ) - err = w.txStore.InsertTx(txmgrNs, rec, blockMeta) - if err != nil { - return err - } + prevOutpointsConfirmed = txsToOutpoints(initialCreditsConfirmed) - // Mark the output as unspent. - err = w.txStore.AddCredit( - txmgrNs, rec, blockMeta, 0, false, + // Fourth pass: Create unconfirmed spending transactions. + baseBlockHeight = -1 + mined = false + txsUnconfirmed, _ = createTxBatch( + tb, w, txmgrNs, addrmgrNs, addresses, numTxs, + msgTx.Version, baseBlockHeight, 110000, + prevOutpointsConfirmed, numInputs, numOutputs, mined, + ) + + return nil + }) + + require.NoError(tb, err, "failed to create test wallet txs: %v", err) + + // Sync wallet to the highest block containing confirmed transactions. + setSyncedToHeight( + tb, w, highestBlockMeta.Height, + highestBlockMeta.Hash, + ) + + return &testWalletTxsResult{ + confirmed: txsConfirmed, + unconfirmed: txsUnconfirmed, + highestBlockMeta: highestBlockMeta, + } +} + +// txsToOutpoints converts all transaction outputs to outpoints. For X txs with +// Y outputs per tx outputs, returns X*Y outpoints. +func txsToOutpoints(txs []*wire.MsgTx) []wire.OutPoint { + var outpoints []wire.OutPoint + for _, tx := range txs { + txHash := tx.TxHash() + for j := range tx.TxOut { + outpoints = append( + outpoints, wire.OutPoint{ + Hash: txHash, + Index: uint32(j), + }, ) - if err != nil { - return err + } + } + + return outpoints +} + +// createTxBatch is a helper that creates a batch of transactions. +// If prevOutpoints is nil, creates receiving transactions (credits only). +// If prevOutpoints is provided, creates spending transactions +// (debits + credits). If mined is true, each transaction is placed in its own +// block (blockHeight + i). If mined is false, transactions are unmined +// (unconfirmed). numInputs and numOutputs control transaction complexity; if 0, +// defaults to 1 input and 1 output per transaction. Returns the created +// transactions and the block metadata for the highest block (only meaningful if +// mined is true). +func createTxBatch(tb testing.TB, w *Wallet, txmgrNs, + addrmgrNs walletdb.ReadWriteBucket, addresses []waddrmgr.ManagedAddress, + count int, txVersion int32, startBlockHeight int32, baseAmount int64, + prevOutpoints []wire.OutPoint, numInputs, numOutputs int, + mined bool) ([]*wire.MsgTx, wtxmgr.BlockMeta) { + + tb.Helper() + + // Default to 1 input and 1 output if not specified. + if numInputs == 0 { + numInputs = 1 + } + + if numOutputs == 0 { + numOutputs = 1 + } + + var ( + transactions []*wire.MsgTx + lastBlockMeta wtxmgr.BlockMeta + ) + + for i := 0; i < count && i < len(addresses); i++ { + var blockMeta *wtxmgr.BlockMeta + if mined { + // Each transaction goes in its own block with unique + // hash. + blockHash := chainhash.Hash{} + blockHash[0] = byte(startBlockHeight + int32(i)) + blockHash[1] = byte((startBlockHeight + int32(i)) >> 8) + + blockMeta = &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: blockHash, + Height: startBlockHeight + int32(i), + }, + Time: time.Now(), } + lastBlockMeta = *blockMeta + } + + tx := buildTxForBatch( + tb, addresses, txVersion, i, baseAmount, + prevOutpoints, numInputs, numOutputs, + ) - err = w.addrStore.MarkUsed( - addrmgrNs, addr.Address(), + rec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + require.NoError(tb, err) + + err = w.txStore.InsertTx(txmgrNs, rec, blockMeta) + require.NoError(tb, err) + + // Add credits for all outputs belonging to our wallet. + for j := range numOutputs { + err = w.txStore.AddCredit( + txmgrNs, rec, blockMeta, uint32(j), false, ) - if err != nil { - return err - } + require.NoError(tb, err) + } - // Store the actual outpoint for later use. - outpoints = append(outpoints, wire.OutPoint{ - Hash: rec.Hash, - Index: 0, - }) + // Mark all addresses as used. + for j := range numOutputs { + addr := addresses[(i+j)%len(addresses)] + err = w.addrStore.MarkUsed(addrmgrNs, addr.Address()) + require.NoError(tb, err) } - return nil - }) + transactions = append(transactions, tx) + } - require.NoError(tb, err, "failed to create test UTXOs: %v", err) + return transactions, lastBlockMeta +} - return outpoints +// buildTxForBatch creates a single transaction with the specified inputs and +// outputs. +func buildTxForBatch(tb testing.TB, addresses []waddrmgr.ManagedAddress, + txVersion int32, i int, baseAmount int64, prevOutpoints []wire.OutPoint, + numInputs, numOutputs int) *wire.MsgTx { + + tb.Helper() + + tx := wire.NewMsgTx(txVersion) + + // Add multiple outputs to our wallet (creates credits). + for j := range numOutputs { + addr := addresses[(i+j)%len(addresses)] + pkScript, err := txscript.PayToAddrScript(addr.Address()) + require.NoError(tb, err) + + // Add random jitter based on timestamp to ensure unique + // transaction hashes across benchmark runs, preventing + // duplicate transaction errors when the same test data is + // created multiple times. This is necessary for + // representative benchmarking. + randomJitter := time.Now().UnixNano() % 1000 + amount := btcutil.Amount( + baseAmount + int64(i*1000+j*100) + randomJitter, + ) + txOut := wire.NewTxOut(int64(amount), pkScript) + tx.AddTxOut(txOut) + } + + // Add multiple inputs - either external or from our wallet. + for j := range numInputs { + outpointIdx := i*numInputs + j + if prevOutpoints != nil && outpointIdx < len(prevOutpoints) { + // Spend from our previous UTXO (creates debit). + txIn := wire.NewTxIn( + &prevOutpoints[outpointIdx], nil, nil, + ) + tx.AddTxIn(txIn) + } else { + // External input (no debit). Needed for tx to be + // syntactically valid. + prevHash := chainhash.Hash{} + prevHash[0] = byte(i) + prevHash[1] = byte(j) + txIn := wire.NewTxIn( + wire.NewOutPoint(&prevHash, uint32(j)), nil, + nil, + ) + tx.AddTxIn(txIn) + } + } + + return tx } // generateAccountName generates a consistent account name and number for diff --git a/wallet/utxo_manager_benchmark_test.go b/wallet/utxo_manager_benchmark_test.go index b28f1dfe19..74fd093a0a 100644 --- a/wallet/utxo_manager_benchmark_test.go +++ b/wallet/utxo_manager_benchmark_test.go @@ -1,6 +1,7 @@ package wallet import ( + "fmt" "math" "testing" "time" @@ -15,29 +16,65 @@ import ( // multiple dataset sizes. Test names start with dataset size to group API // comparisons for benchstat analysis. func BenchmarkGetUtxoAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - utxoGrowth: exponentialGrowth, - addressGrowth: linearGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) - testOutpoint := getTestUtxoOutpoint(bw.outpoints) + outpoints := txsToOutpoints(bw.confirmedTxs) + testOutpoint := getTestUtxoOutpoint(outpoints) b.ReportAllocs() b.ResetTimer() @@ -50,17 +87,18 @@ func BenchmarkGetUtxoAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) - testOutpoint := getTestUtxoOutpoint(bw.outpoints) + outpoints := txsToOutpoints(bw.confirmedTxs) + testOutpoint := getTestUtxoOutpoint(outpoints) b.ReportAllocs() b.ResetTimer() @@ -80,29 +118,66 @@ func BenchmarkGetUtxoAPI(b *testing.B) { // across multiple dataset sizes. Test names start with dataset size to group // API comparisons for benchstat analysis. func BenchmarkListUnspentAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: linearGrowth, - utxoGrowth: exponentialGrowth, - addressGrowth: linearGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} - minConfs := 0 - maxConfs := math.MaxInt32 - for _, size := range benchmarkSizes { - accountName, _ := generateAccountName(size.numAccounts, scopes) + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + minConfs = 0 + + maxConfs = math.MaxInt32 + ) + + for i := 0; i <= maxGrowthIteration; i++ { + accountName, _ := generateAccountName(accountGrowth[i], scopes) + + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -118,13 +193,13 @@ func BenchmarkListUnspentAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) @@ -150,31 +225,69 @@ func BenchmarkListUnspentAPI(b *testing.B) { // testing across different dataset sizes helps identify any database bucket // depth effects or positional bias as the UTXO set grows. func BenchmarkLeaseOutputAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: constantGrowth, - utxoGrowth: linearGrowth, - addressGrowth: constantGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + lockID = wtxmgr.LockID{0x01, 0x02, 0x03, 0x04} + + duration = time.Hour ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} - lockID := wtxmgr.LockID{0x01, 0x02, 0x03, 0x04} - duration := time.Hour - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) - testOutpoint := getTestUtxoOutpoint(bw.outpoints) + outpoints := txsToOutpoints(bw.confirmedTxs) + testOutpoint := getTestUtxoOutpoint(outpoints) b.ReportAllocs() b.ResetTimer() @@ -187,17 +300,18 @@ func BenchmarkLeaseOutputAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) - testOutpoint := getTestUtxoOutpoint(bw.outpoints) + outpoints := txsToOutpoints(bw.confirmedTxs) + testOutpoint := getTestUtxoOutpoint(outpoints) b.ReportAllocs() b.ResetTimer() @@ -219,31 +333,69 @@ func BenchmarkLeaseOutputAPI(b *testing.B) { // depth effects or positional bias as the UTXO set grows. Outputs must be // leased before they can be released. func BenchmarkReleaseOutputAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: constantGrowth, - utxoGrowth: linearGrowth, - addressGrowth: constantGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + lockID = wtxmgr.LockID{0x01, 0x02, 0x03, 0x04} + + duration = time.Hour ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} - lockID := wtxmgr.LockID{0x01, 0x02, 0x03, 0x04} - duration := time.Hour - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) - testOutpoint := getTestUtxoOutpoint(bw.outpoints) + outpoints := txsToOutpoints(bw.confirmedTxs) + testOutpoint := getTestUtxoOutpoint(outpoints) b.ReportAllocs() b.ResetTimer() @@ -261,17 +413,18 @@ func BenchmarkReleaseOutputAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) - testOutpoint := getTestUtxoOutpoint(bw.outpoints) + outpoints := txsToOutpoints(bw.confirmedTxs) + testOutpoint := getTestUtxoOutpoint(outpoints) b.ReportAllocs() b.ResetTimer() @@ -298,31 +451,70 @@ func BenchmarkReleaseOutputAPI(b *testing.B) { // while the new API returns minimal lock metadata in a single scan. Performance // difference scales with the number of leased outputs. func BenchmarkListLeasedOutputsAPI(b *testing.B) { - benchmarkSizes, namingInfo := generateBenchmarkSizes( - benchmarkConfig{ - accountGrowth: constantGrowth, - utxoGrowth: exponentialGrowth, - addressGrowth: constantGrowth, - maxIterations: 14, - startIndex: 0, - }, + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // maxGrowthIteration is the maximum iteration index for the + // growth sequence. + maxGrowthIteration = 14 ) - scopes := []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} - duration := time.Hour - for _, size := range benchmarkSizes { - b.Run(size.name(namingInfo)+"/0-Before", func(b *testing.B) { + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + duration = time.Hour + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("%0*d-Accounts-%0*d-Addresses-%0*d-UTXOs", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name+"/0-Before", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) // Lease all outputs to maximize the N+1 query impact. - leaseAllOutputs(b, bw.Wallet, bw.outpoints, duration) + leaseAllOutputs( + b, bw.Wallet, txsToOutpoints(bw.confirmedTxs), + duration, + ) b.ReportAllocs() b.ResetTimer() @@ -333,18 +525,21 @@ func BenchmarkListLeasedOutputsAPI(b *testing.B) { } }) - b.Run(size.name(namingInfo)+"/1-After", func(b *testing.B) { + b.Run(name+"/1-After", func(b *testing.B) { bw := setupBenchmarkWallet( b, benchmarkWalletConfig{ scopes: scopes, - numAccounts: size.numAccounts, - numAddresses: size.numAddresses, - numUTXOs: size.numUTXOs, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], }, ) // Lease all outputs to maximize the N+1 query impact. - leaseAllOutputs(b, bw.Wallet, bw.outpoints, duration) + leaseAllOutputs( + b, bw.Wallet, txsToOutpoints(bw.confirmedTxs), + duration, + ) b.ReportAllocs() b.ResetTimer() From 5883ef97bf47e7df57a417aaba2f6950ee775f1e Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Sun, 16 Nov 2025 20:19:07 +0000 Subject: [PATCH 120/691] wallet: benchmark `Broadcast` API serially --- wallet/tx_publisher_benchmark_test.go | 252 ++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 wallet/tx_publisher_benchmark_test.go diff --git a/wallet/tx_publisher_benchmark_test.go b/wallet/tx_publisher_benchmark_test.go new file mode 100644 index 0000000000..7b5d982675 --- /dev/null +++ b/wallet/tx_publisher_benchmark_test.go @@ -0,0 +1,252 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// BenchmarkBroadcastAPI benchmarks the Broadcast API against the legacy +// PublishTransaction API using identical test data under sequential load. +// Test names start with transaction pool size to group API comparisons for +// benchstat analysis. +// +// Time Complexity Analysis: +// Broadcast is a write operation with amortized cost. The time complexity is +// O(n + m·log(k)) where: +// - n: number of transaction outputs (address extraction) +// - m: number of unique addresses extracted from outputs +// - k: total number of addresses in the wallet (B-tree lookup) +// +// The API is optimized with a 4-stage pipeline: +// 1. Extract: O(n) - CPU-intensive address extraction (no DB locks) +// 2. Filter: O(m·log(k)) - Read-only DB transaction to filter owned addresses +// 3. Plan: O(n·m) - In-memory write plan preparation (typically O(n) as m≈1-2) +// 4. Execute: O(c) - Atomic write transaction +// (c = owned outputs, typically c << n) +// +// This design ensures DB locks are held only during minimal read/write +// operations, maximizing throughput under concurrent load. +func BenchmarkBroadcastAPI(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // endGrowthIteration is the maximum iteration index for the + // growth sequence. + endGrowthIteration = 5 + ) + + var ( + // accountGrowth uses constantGrowth since account count doesn't + // affect the Broadcast API's time complexity. + accountGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // addressGrowth uses linearGrowth to test O(log k) wallet + // address lookup scaling. As the address count grows linearly, + // the filterOwnedAddresses lookup time should grow + // logarithmically due to B-tree indexing. + addressGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + linearGrowth, + ) + + // txPoolGrowth uses linearGrowth to establish baseline + // transaction pool size. This represents the number of + // unconfirmed transactions being broadcast, stressing the + // idempotency checks and mempool state management. + txPoolGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + linearGrowth, + ) + + // txIOGrowth uses linearGrowth for both inputs and outputs to + // test the O(n) address extraction and O(n·m) write plan + // preparation costs. As transaction complexity grows linearly, + // processing time should scale linearly with output count. + txIOGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + linearGrowth, + ) + + txPoolGrowthPadding = decimalWidth( + txPoolGrowth[len(txPoolGrowth)-1], + ) + + txIOGrowthPadding = decimalWidth( + txIOGrowth[len(txIOGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + chainBackend = &mockChainClient{} + ) + + err := chainBackend.Start(b.Context()) + require.NoError(b, err) + b.Cleanup(chainBackend.Stop) + + for i := 0; i <= endGrowthIteration; i++ { + name := fmt.Sprintf("TxPool-%0*d-Addrs-%0*d-Ins-%0*d-Outs-%0*d", + txPoolGrowthPadding, txPoolGrowth[i], + addressGrowthPadding, addressGrowth[i], + txIOGrowthPadding, txIOGrowth[i], + txIOGrowthPadding, txIOGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: txPoolGrowth[i], + numTxInputs: txIOGrowth[i], + numTxOutputs: txIOGrowth[i], + }, + ) + bw.chainClient = chainBackend + + var ( + beforeResult map[chainhash.Hash]*wire.MsgTx + afterResult map[chainhash.Hash]*wire.MsgTx + ) + + b.Run("0-Before", func(b *testing.B) { + result := make(map[chainhash.Hash]*wire.MsgTx) + baselineResult := make( + map[chainhash.Hash]*wire.MsgTx, + ) + + broadcastLabel := "sequential-before" + + // Clear mempool to ensure clean state for + // benchmark baseline. + chainBackend.ResetMempool() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; b.Loop(); i++ { + index := i % len(bw.unconfirmedTxs) + tx := bw.unconfirmedTxs[index] + + err := bw.PublishTransaction( + tx, broadcastLabel, + ) + require.NoError(b, err) + + result, err = chainBackend.GetMempool() + require.NoError(b, err) + + // Capture baseline after each + // transaction in the first cycle. This + // ensures we get the complete mempool + // state after all transactions are + // published, since benchmark iteration + // count varies based on runtime + // performance. + if i < len(bw.unconfirmedTxs) { + baselineResult = result + } + } + + require.Equal( + b, baselineResult, result, + "PublishTransaction API should be "+ + "idempotent", + ) + + beforeResult = result + }) + + b.Run("1-After", func(b *testing.B) { + result := make(map[chainhash.Hash]*wire.MsgTx) + baselineResult := make( + map[chainhash.Hash]*wire.MsgTx, + ) + + broadcastLabel := "sequential-after" + + // Clear mempool to ensure clean state for + // benchmark baseline. + chainBackend.ResetMempool() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; b.Loop(); i++ { + index := i % len(bw.unconfirmedTxs) + tx := bw.unconfirmedTxs[index] + + err := bw.Broadcast( + b.Context(), tx, broadcastLabel, + ) + require.NoError(b, err) + + result, err = chainBackend.GetMempool() + require.NoError(b, err) + + // Capture baseline after each + // transaction in the first cycle. This + // ensures we get the complete mempool + // state after all transactions are + // published, since benchmark iteration + // count varies based on runtime + // performance. + if i < len(bw.unconfirmedTxs) { + baselineResult = result + } + } + + require.Equal( + b, baselineResult, result, + "PublishTransaction API should be "+ + "idempotent", + ) + + afterResult = result + }) + + assertBroadcastAPIsEquivalent( + b, beforeResult, afterResult, + ) + }) + } +} + +// assertBroadcastAPIsEquivalent verifies that PublishTransaction (legacy) and +// Broadcast (new) produce equivalent results by comparing the transactions +// that ended up in the mock mempool. +func assertBroadcastAPIsEquivalent(b *testing.B, + before, after map[chainhash.Hash]*wire.MsgTx) { + + b.Helper() + + require.NotNil(b, before) + require.NotNil(b, after) + + // require.Equal uses reflect.DeepEqual internally which compares maps + // by matching corresponding keys to deeply equal values, regardless of + // iteration order as stated in the official go package dev docs. + require.Equal( + b, before, after, + "PublishTransaction and Broadcast APIs should produce "+ + "equivalent mempool state", + ) +} From 88f2c0859b0388077f5f63fb8a7998cdec675777 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Sun, 16 Nov 2025 20:20:36 +0000 Subject: [PATCH 121/691] wallet: benchmark `Broadcast` API concurrently --- wallet/tx_publisher_benchmark_test.go | 177 ++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/wallet/tx_publisher_benchmark_test.go b/wallet/tx_publisher_benchmark_test.go index 7b5d982675..aba2779ddb 100644 --- a/wallet/tx_publisher_benchmark_test.go +++ b/wallet/tx_publisher_benchmark_test.go @@ -230,6 +230,183 @@ func BenchmarkBroadcastAPI(b *testing.B) { } } +// BenchmarkBroadcastAPIConcurrently benchmarks the Broadcast API against the +// legacy PublishTransaction API using identical test data under concurrent +// load. Test names start with transaction pool size to group API comparisons +// for benchstat analysis. +// +// Time Complexity Analysis: +// Under concurrent load, the API maintains the same per-transaction complexity +// of O(n + m·log(k)) as the sequential benchmark, where: +// - n: number of transaction outputs (address extraction) +// - m: number of unique addresses extracted from outputs +// - k: total number of addresses in the wallet (B-tree lookup) +// +// The 4-stage pipeline design provides excellent concurrent performance: +// 1. Extract: O(n) - Parallel CPU work, no contention +// 2. Filter: O(m·log(k)) - Read-only transactions, minimal lock contention +// 3. Plan: O(n·m) - Parallel in-memory work, no contention +// 4. Execute: O(c) - Short write transactions reduce lock contention +// +// This benchmark stresses the lock contention characteristics during Stage 2 +// (read locks) and Stage 4 (write locks), demonstrating scalability under +// concurrent broadcast operations. +func BenchmarkBroadcastAPIConcurrently(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // endGrowthIteration is the maximum iteration index for the + // growth sequence. + endGrowthIteration = 5 + ) + + var ( + // accountGrowth uses constantGrowth since account count doesn't + // affect the Broadcast API's time complexity. + accountGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // addressGrowth uses linearGrowth to test O(log k) wallet + // address lookup scaling. As the address count grows linearly, + // the filterOwnedAddresses lookup time should grow + // logarithmically due to B-tree indexing. + addressGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + linearGrowth, + ) + + // txPoolGrowth uses linearGrowth to establish baseline + // transaction pool size. This represents the number of + // unconfirmed transactions being broadcast, stressing the + // idempotency checks and mempool state management. + txPoolGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + linearGrowth, + ) + + // txIOGrowth uses linearGrowth for both inputs and outputs to + // test the O(n) address extraction and O(n·m) write plan + // preparation costs. As transaction complexity grows linearly, + // processing time should scale linearly with output count. + txIOGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + linearGrowth, + ) + + txPoolGrowthPadding = decimalWidth( + txPoolGrowth[len(txPoolGrowth)-1], + ) + + txIOGrowthPadding = decimalWidth( + txIOGrowth[len(txIOGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + chainBackend = &mockChainClient{} + ) + + err := chainBackend.Start(b.Context()) + require.NoError(b, err) + b.Cleanup(chainBackend.Stop) + + for i := 0; i <= endGrowthIteration; i++ { + name := fmt.Sprintf("TxPool-%0*d-Addrs-%0*d-Ins-%0*d-Outs-%0*d", + txPoolGrowthPadding, txPoolGrowth[i], + addressGrowthPadding, addressGrowth[i], + txIOGrowthPadding, txIOGrowth[i], + txIOGrowthPadding, txIOGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: txPoolGrowth[i], + numTxInputs: txIOGrowth[i], + numTxOutputs: txIOGrowth[i], + }, + ) + bw.chainClient = chainBackend + + var ( + beforeResult map[chainhash.Hash]*wire.MsgTx + afterResult map[chainhash.Hash]*wire.MsgTx + ) + + b.Run("0-Before", func(b *testing.B) { + broadcastLabel := "concurrent-before" + + // Clear mempool to ensure clean state for + // benchmark baseline. + chainBackend.ResetMempool() + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + j := len(bw.unconfirmedTxs) + for i := 0; pb.Next(); i++ { + k := i % j + tx := bw.unconfirmedTxs[k] + err := bw.PublishTransaction( + tx, broadcastLabel, + ) + require.NoError(b, err) + } + }) + + var err error + + beforeResult, err = chainBackend.GetMempool() + require.NoError(b, err) + }) + + b.Run("1-After", func(b *testing.B) { + broadcastAfter := "concurrent-after" + + // Clear mempool to ensure clean state for + // benchmark baseline. + chainBackend.ResetMempool() + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + j := len(bw.unconfirmedTxs) + for i := 0; pb.Next(); i++ { + k := i % j + tx := bw.unconfirmedTxs[k] + err := bw.Broadcast( + b.Context(), tx, + broadcastAfter, + ) + require.NoError(b, err) + } + }) + + var err error + + afterResult, err = chainBackend.GetMempool() + require.NoError(b, err) + }) + + assertBroadcastAPIsEquivalent( + b, beforeResult, afterResult, + ) + }) + } +} + // assertBroadcastAPIsEquivalent verifies that PublishTransaction (legacy) and // Broadcast (new) produce equivalent results by comparing the transactions // that ended up in the mock mempool. From 1f4262568e5a12b2fab8d97b96a51f499e16a7c0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Sep 2025 19:07:08 +0800 Subject: [PATCH 122/691] unit: add unit package This commit introduces a new package, 'pkg/unit', to provide idiomatic, type-safe units for handling common Bitcoin quantities. This code is largely copied from 'lnd' as we find it useful to have these fundamental types defined here instead of in higher-level applications. In complex Bitcoin applications, it is crucial to handle different units of measurement safely and consistently. Raw integer types can lead to ambiguity and errors (e.g., is a fee rate in sat/byte, sat/vbyte, or sat/kw?). This new package establishes a canonical set of types to be used within 'btcwallet' and any application that consumes it. By using these types, developers can avoid conversion errors and make their code more readable and self-documenting. The package provides units for: * Transaction Size: 'WeightUnit' and 'VByte' for handling transaction weight and virtual size according to SegWit (BIP-141) standards. * Fee Rates: 'SatPerVByte', 'SatPerKVByte', and 'SatPerKWeight' for expressing fee rates in the most common industry formats. --- pkg/unit/README.md | 21 ++++++ pkg/unit/rates.go | 148 ++++++++++++++++++++++++++++++++++++++++ pkg/unit/rates_test.go | 127 ++++++++++++++++++++++++++++++++++ pkg/unit/txsize.go | 44 ++++++++++++ pkg/unit/txsize_test.go | 22 ++++++ 5 files changed, 362 insertions(+) create mode 100644 pkg/unit/README.md create mode 100644 pkg/unit/rates.go create mode 100644 pkg/unit/rates_test.go create mode 100644 pkg/unit/txsize.go create mode 100644 pkg/unit/txsize_test.go diff --git a/pkg/unit/README.md b/pkg/unit/README.md new file mode 100644 index 0000000000..cd42fcd28c --- /dev/null +++ b/pkg/unit/README.md @@ -0,0 +1,21 @@ +# btcwallet/unit + +This package provides a set of idiomatic, type-safe units for handling common +Bitcoin quantities like transaction sizes, weights, and fee rates. + +## Purpose + +In complex Bitcoin applications, it is crucial to handle different units of +measurement safely and consistently. Raw integer types can lead to ambiguity and +errors (e.g., is a fee rate in sat/byte, sat/vbyte, or sat/kw?). + +This package establishes a canonical set of types to be used within `btcwallet` +and by any application that consumes it. By using these types, developers can +avoid conversion errors and make their code more readable and self-documenting. + +## Provided Units + +- **Transaction Size**: `WeightUnit` and `VByte` for handling transaction + weight and virtual size according to SegWit (BIP-141) standards. +- **Fee Rates**: `SatPerVByte`, `SatPerKVByte`, and `SatPerKWeight` for + expressing fee rates in the most common industry formats. diff --git a/pkg/unit/rates.go b/pkg/unit/rates.go new file mode 100644 index 0000000000..9570029f77 --- /dev/null +++ b/pkg/unit/rates.go @@ -0,0 +1,148 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +// Package unit provides a set of types for dealing with bitcoin units. +package unit + +import ( + "fmt" + "log/slog" + "math" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcutil" +) + +const ( + // SatsPerKilo is the number of satoshis in a kilo-satoshi. + SatsPerKilo = 1000 +) + +// SatPerVByte represents a fee rate in sat/vbyte. +type SatPerVByte btcutil.Amount + +// NewSatPerVByte creates a new fee rate in sat/vb. +func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { + if vb == 0 { + return 0 + } + + return SatPerVByte(fee.MulF64(1 / float64(vb))) +} + +// FeePerKWeight converts the current fee rate from sat/vb to sat/kw. +func (s SatPerVByte) FeePerKWeight() SatPerKWeight { + return SatPerKWeight(s * SatsPerKilo / blockchain.WitnessScaleFactor) +} + +// FeePerKVByte converts the current fee rate from sat/vb to sat/kvb. +func (s SatPerVByte) FeePerKVByte() SatPerKVByte { + return SatPerKVByte(s * SatsPerKilo) +} + +// String returns a human-readable string of the fee rate. +func (s SatPerVByte) String() string { + return fmt.Sprintf("%v sat/vb", int64(s)) +} + +// SatPerKVByte represents a fee rate in sat/kb. +type SatPerKVByte btcutil.Amount + +// NewSatPerKVByte creates a new fee rate in sat/kvb. +func NewSatPerKVByte(fee btcutil.Amount, kvb VByte) SatPerKVByte { + if kvb == 0 { + return 0 + } + + return SatPerKVByte(fee.MulF64(SatsPerKilo / float64(kvb))) +} + +// FeeForVSize calculates the fee resulting from this fee rate and the given +// vsize in vbytes. +func (s SatPerKVByte) FeeForVSize(vbytes VByte) btcutil.Amount { + return btcutil.Amount(s) * + btcutil.Amount(safeUint64ToInt64(uint64(vbytes))) / SatsPerKilo +} + +// FeePerKWeight converts the current fee rate from sat/kb to sat/kw. +func (s SatPerKVByte) FeePerKWeight() SatPerKWeight { + return SatPerKWeight(s / blockchain.WitnessScaleFactor) +} + +// String returns a human-readable string of the fee rate. +func (s SatPerKVByte) String() string { + return fmt.Sprintf("%v sat/kvb", int64(s)) +} + +// SatPerKWeight represents a fee rate in sat/kw. +type SatPerKWeight btcutil.Amount + +// NewSatPerKWeight creates a new fee rate in sat/kw. +func NewSatPerKWeight(fee btcutil.Amount, wu WeightUnit) SatPerKWeight { + if wu == 0 { + return 0 + } + + return SatPerKWeight(fee.MulF64(SatsPerKilo / float64(wu))) +} + +// FeeForWeight calculates the fee resulting from this fee rate and the given +// weight in weight units (wu). +func (s SatPerKWeight) FeeForWeight(wu WeightUnit) btcutil.Amount { + // The resulting fee is rounded down, as specified in BOLT#03. + return btcutil.Amount(s) * + btcutil.Amount(safeUint64ToInt64(uint64(wu))) / SatsPerKilo +} + +// FeeForWeightRoundUp calculates the fee resulting from this fee rate and the +// given weight in weight units (wu), rounding up to the nearest satoshi. +func (s SatPerKWeight) FeeForWeightRoundUp( + wu WeightUnit) btcutil.Amount { + + // The rounding logic is based on the ceiling division formula: + // (numerator + denominator - 1) / denominator + // + // This ensures that any fractional part of the fee is rounded up to + // the next whole satoshi. + fee := btcutil.Amount(s) * btcutil.Amount(safeUint64ToInt64(uint64(wu))) + fee += SatsPerKilo - 1 + + return fee / SatsPerKilo +} + +// FeeForVByte calculates the fee resulting from this fee rate and the given +// size in vbytes (vb). +func (s SatPerKWeight) FeeForVByte(vb VByte) btcutil.Amount { + return s.FeePerKVByte().FeeForVSize(vb) +} + +// FeePerKVByte converts the current fee rate from sat/kw to sat/kb. +func (s SatPerKWeight) FeePerKVByte() SatPerKVByte { + return SatPerKVByte(s * blockchain.WitnessScaleFactor) +} + +// FeePerVByte converts the current fee rate from sat/kw to sat/vb. +func (s SatPerKWeight) FeePerVByte() SatPerVByte { + return SatPerVByte(s * blockchain.WitnessScaleFactor / SatsPerKilo) +} + +// String returns a human-readable string of the fee rate. +func (s SatPerKWeight) String() string { + return fmt.Sprintf("%v sat/kw", int64(s)) +} + +// safeUint64ToInt64 converts a uint64 to an int64, capping at math.MaxInt64. +// This is used to silence gosec warnings about integer overflows. In practice, +// the values being converted are transaction weights or sizes, which are +// limited by consensus rules and are not expected to overflow an int64. +func safeUint64ToInt64(u uint64) int64 { + if u > math.MaxInt64 { + slog.Warn("Capping uint64 value to math.MaxInt64", + slog.Uint64("old", u), slog.Int64("new", math.MaxInt64)) + + return math.MaxInt64 + } + + return int64(u) +} diff --git a/pkg/unit/rates_test.go b/pkg/unit/rates_test.go new file mode 100644 index 0000000000..053a1d3bdf --- /dev/null +++ b/pkg/unit/rates_test.go @@ -0,0 +1,127 @@ +package unit + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/stretchr/testify/require" +) + +// TestFeeRateConversions checks that the conversion between the different fee +// rate units is correct. +func TestFeeRateConversions(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + rate any + expectedVB SatPerVByte + expectedKVB SatPerKVByte + expectedKW SatPerKWeight + expectedSats btcutil.Amount + }{ + { + name: "1 sat/vb", + rate: SatPerVByte(1), + expectedVB: SatPerVByte(1), + expectedKVB: SatPerKVByte(1000), + expectedKW: SatPerKWeight(250), + expectedSats: 1, + }, + { + name: "1000 sat/kvb", + rate: SatPerKVByte(1000), + expectedVB: SatPerVByte(1), + expectedKVB: SatPerKVByte(1000), + expectedKW: SatPerKWeight(250), + expectedSats: 1000, + }, + { + name: "250 sat/kw", + rate: SatPerKWeight(250), + expectedVB: SatPerVByte(1), + expectedKVB: SatPerKVByte(1000), + expectedKW: SatPerKWeight(250), + expectedSats: 250, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + switch r := tc.rate.(type) { + case SatPerVByte: + require.Equal(t, tc.expectedVB, r) + require.Equal( + t, tc.expectedKVB, r.FeePerKVByte(), + ) + require.Equal( + t, tc.expectedKW, r.FeePerKWeight(), + ) + require.Equal( + t, tc.expectedSats, btcutil.Amount(r), + ) + + case SatPerKVByte: + require.Equal( + t, tc.expectedVB, + r.FeePerKWeight().FeePerVByte(), + ) + require.Equal(t, tc.expectedKVB, r) + require.Equal( + t, tc.expectedKW, r.FeePerKWeight(), + ) + require.Equal( + t, tc.expectedSats, btcutil.Amount(r), + ) + + case SatPerKWeight: + require.Equal( + t, tc.expectedVB, r.FeePerVByte(), + ) + require.Equal( + t, tc.expectedKVB, r.FeePerKVByte(), + ) + require.Equal(t, tc.expectedKW, r) + require.Equal( + t, tc.expectedSats, btcutil.Amount(r), + ) + } + }) + } +} + +// TestFeeForWeightRoundUp checks that the FeeForWeightRoundUp method correctly +// rounds up the fee for a given weight. +func TestFeeForWeightRoundUp(t *testing.T) { + t.Parallel() + + feeRate := SatPerVByte(1).FeePerKWeight() + txWeight := WeightUnit(674) // 674 weight units is 168.5 vb. + + require.EqualValues(t, 168, feeRate.FeeForWeight(txWeight)) + require.EqualValues(t, 169, feeRate.FeeForWeightRoundUp(txWeight)) +} + +// TestNewFeeRateConstructors checks that the New* fee rate constructors work +// as expected. +func TestNewFeeRateConstructors(t *testing.T) { + t.Parallel() + + // Test NewSatPerKWeight. + fee := btcutil.Amount(1000) + wu := WeightUnit(1000) + expectedRate := SatPerKWeight(1000) + require.Equal(t, expectedRate, NewSatPerKWeight(fee, wu)) + + // Test NewSatPerVByte. + vb := VByte(250) + expectedRateVB := SatPerVByte(4) + require.Equal(t, expectedRateVB, NewSatPerVByte(fee, vb)) + + // Test NewSatPerKVByte. + kvb := VByte(1) + expectedRateKVB := SatPerKVByte(1000000) + require.Equal(t, expectedRateKVB, NewSatPerKVByte(fee, kvb)) +} diff --git a/pkg/unit/txsize.go b/pkg/unit/txsize.go new file mode 100644 index 0000000000..0db3834d0e --- /dev/null +++ b/pkg/unit/txsize.go @@ -0,0 +1,44 @@ +package unit + +import ( + "fmt" + "math" + + "github.com/btcsuite/btcd/blockchain" +) + +// WeightUnit defines a unit to express the transaction size. One weight unit +// is 1/4_000_000 of the max block size. The tx weight is calculated using +// `Base tx size * 3 + Total tx size`. +// - Base tx size is size of the transaction serialized without the witness +// data. +// - Total tx size is the transaction size in bytes serialized according +// #BIP144. +type WeightUnit uint64 + +// ToVB converts a value expressed in weight units to virtual bytes. +func (wu WeightUnit) ToVB() VByte { + // According to BIP141: Virtual transaction size is defined as + // Transaction weight / 4 (rounded up to the next integer). + return VByte(math.Ceil(float64(wu) / blockchain.WitnessScaleFactor)) +} + +// String returns the string representation of the weight unit. +func (wu WeightUnit) String() string { + return fmt.Sprintf("%d wu", wu) +} + +// VByte defines a unit to express the transaction size. One virtual byte is +// 1/4th of a weight unit. The tx virtual bytes is calculated using `TxWeight / +// 4`. +type VByte uint64 + +// ToWU converts a value expressed in virtual bytes to weight units. +func (vb VByte) ToWU() WeightUnit { + return WeightUnit(vb * blockchain.WitnessScaleFactor) +} + +// String returns the string representation of the virtual byte. +func (vb VByte) String() string { + return fmt.Sprintf("%d vb", vb) +} diff --git a/pkg/unit/txsize_test.go b/pkg/unit/txsize_test.go new file mode 100644 index 0000000000..83e16b4d94 --- /dev/null +++ b/pkg/unit/txsize_test.go @@ -0,0 +1,22 @@ +package unit + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestTxSizeConversion checks that the conversion between weight units and +// virtual bytes is correct. +func TestTxSizeConversion(t *testing.T) { + t.Parallel() + + // Create a test weight of 1000 wu. + wu := WeightUnit(1000) + + // 1000 wu should be equal to 250 vb. + require.Equal(t, VByte(250), wu.ToVB()) + + // 250 vb should be equal to 1000 wu. + require.Equal(t, wu, VByte(250).ToWU()) +} From 83cf1fc57626124af31bad7e17d786ca78dd3680 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Sep 2025 19:11:46 +0800 Subject: [PATCH 123/691] wallet: introduce `TxReader` interface --- wallet/tx_reader.go | 130 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 wallet/tx_reader.go diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go new file mode 100644 index 0000000000..62622695d1 --- /dev/null +++ b/wallet/tx_reader.go @@ -0,0 +1,130 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "context" + "errors" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/pkg/unit" +) + +var ( + // ErrTxNotFound is returned when a transaction is not found in the + // store. + ErrTxNotFound = errors.New("tx not found") +) + +// TxReader provides an interface for querying tx history. +type TxReader interface { + // GetTx returns a detailed description of a tx given its tx hash. + GetTx(ctx context.Context, txHash chainhash.Hash) (*TxDetail, error) + + // ListTxns returns a list of all txns which are relevant to the wallet + // over a given block range. + ListTxns(ctx context.Context, startHeight, endHeight int32) ( + []*TxDetail, error) +} + +// Output contains details for a tx output. +type Output struct { + // Addresses are the addresses associated with the output script. + Addresses []btcutil.Address + + // PkScript is the raw output script. + PkScript []byte + + // Index is the index of the output in the tx. + Index int + + // Amount is the value of the output. + Amount btcutil.Amount + + // Type is the script class of the output. + Type txscript.ScriptClass + + // IsOurs is true if the output is controlled by the wallet. + IsOurs bool +} + +// PrevOut describes a tx input. +type PrevOut struct { + // OutPoint is the unique reference to the output being spent. + OutPoint wire.OutPoint + + // IsOurs is true if the input spends an output controlled by the + // wallet. + IsOurs bool +} + +// BlockDetails contains details about the block that includes a tx. +type BlockDetails struct { + // Hash is the hash of the block. + Hash chainhash.Hash + + // Height is the height of the block. + Height int32 + + // Timestamp is the unix timestamp of the block. + Timestamp int64 +} + +// TxDetail describes a tx relevant to a wallet. This is a flattened +// and information-dense structure designed to be returned by the TxReader +// interface. +type TxDetail struct { + // Hash is the tx hash. + Hash chainhash.Hash + + // RawTx is the serialized tx. + RawTx []byte + + // Value is the net value of this tx (in satoshis) from the + // POV of the wallet. + Value btcutil.Amount + + // Fee is the total fee in satoshis paid by this tx. + // + // NOTE: This is only calculated if all inputs are known to the wallet. + // Otherwise, it will be zero. + // + // TODO(yy): This should also be calculated for txns with external + // inputs. This requires adding a `GetRawTransaction` method to the + // `chain.Interface`. + Fee btcutil.Amount + + // FeeRate is the fee rate of the tx in sat/vbyte. + // + // NOTE: This is only calculated if all inputs are known to the wallet. + // Otherwise, it will be zero. + FeeRate unit.SatPerVByte + + // Weight is the tx's weight. + Weight unit.WeightUnit + + // Confirmations is the number of confirmations this tx has. + // This will be 0 for unconfirmed txns. + Confirmations int32 + + // Block contains details of the block that includes this tx. + Block *BlockDetails + + // ReceivedTime is the time the tx was received by the wallet. + ReceivedTime time.Time + + // Outputs contains data for each tx output. + Outputs []Output + + // PrevOuts are the inputs for the tx. + PrevOuts []PrevOut + + // Label is an optional tx label. + Label string +} From 40043f865ae39873a8dd5158b03f7b85b32d7adc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Sep 2025 19:15:51 +0800 Subject: [PATCH 124/691] wallet: implement `GetTx` This commit introduces a new `TxReader` interface and its first implementation, `GetTx`. This provides a modern and efficient way to query for transaction details. The new `GetTx` method is a significant improvement over the previous approach that relied on functions like `makeTxSummary`. The key advantages are: 1. **Clear Separation of Concerns**: The `TxReader` interface decouples transaction querying logic from the main `Wallet` struct, leading to cleaner and more maintainable code. 2. **Client-Focused Data Structure**: It returns a `TxDetail` struct, which is a flattened, information-dense DTO designed for easy consumption. It includes pre-calculated, useful fields like the net value from the wallet's perspective, transaction fee, and confirmation count. 3. **Efficiency**: This implementation is more performant as it avoids unnecessary database actions. It fetches all required data in a single, efficient database lookup and then performs all data transformations in memory. This contrasts with the older `makeTxSummary` pattern, which could trigger multiple subsequent database queries to assemble the final summary. --- wallet/tx_reader.go | 235 ++++++++++++++++++++++++++++++++++ wallet/tx_reader_test.go | 269 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 504 insertions(+) create mode 100644 wallet/tx_reader_test.go diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go index 62622695d1..1ea899b9a8 100644 --- a/wallet/tx_reader.go +++ b/wallet/tx_reader.go @@ -7,13 +7,18 @@ package wallet import ( "context" "errors" + "fmt" + "math" "time" + "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/pkg/unit" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" ) var ( @@ -128,3 +133,233 @@ type TxDetail struct { // Label is an optional tx label. Label string } + +// GetTx returns a detailed description of a tx given its tx hash. +// +// NOTE: This method is part of the TxReader interface. +// +// Time complexity: O(log n + I + O), where n is the number of +// transactions in the database, I is the number of inputs, and O is the +// number of outputs. The lookup is dominated by a key-based B-tree lookup +// in the database and the processing of the transaction's inputs and +// outputs. +func (w *Wallet) GetTx(_ context.Context, txHash chainhash.Hash) ( + *TxDetail, error) { + + txDetails, err := w.fetchTxDetails(&txHash) + if err != nil { + return nil, err + } + + bestBlock := w.SyncedTo() + currentHeight := bestBlock.Height + + return w.buildTxDetail(txDetails, currentHeight), nil +} + +// fetchTxDetails fetches the tx details for the given tx hash +// from the wallet's tx store. +func (w *Wallet) fetchTxDetails(txHash *chainhash.Hash) ( + *wtxmgr.TxDetails, error) { + + var txDetails *wtxmgr.TxDetails + + err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + var err error + + txDetails, err = w.txStore.TxDetails(txmgrNs, txHash) + if err != nil { + return fmt.Errorf("failed to fetch tx details: %w", err) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to view wallet db: %w", err) + } + + // TxDetails will return nil when the tx is not found. + // + // TODO(yy): We should instead return an error when the tx cannot be + // found in the db. + if txDetails == nil { + return nil, ErrTxNotFound + } + + return txDetails, nil +} + +// buildTxDetail builds a TxDetail from the given wtxmgr.TxDetails. +func (w *Wallet) buildTxDetail(txDetails *wtxmgr.TxDetails, + currentHeight int32) *TxDetail { + + details := w.buildBasicTxDetail(txDetails) + + w.populateBlockDetails(details, txDetails, currentHeight) + w.calculateValueAndFee(details, txDetails) + w.populateOutputs(details, txDetails) + w.populatePrevOuts(details, txDetails) + + return details +} + +// buildBasicTxDetail builds the basic TxDetail from the given wtxmgr.TxDetails. +func (w *Wallet) buildBasicTxDetail(txDetails *wtxmgr.TxDetails) *TxDetail { + txWeight := blockchain.GetTransactionWeight( + btcutil.NewTx(&txDetails.MsgTx), + ) + + return &TxDetail{ + Hash: txDetails.Hash, + RawTx: txDetails.SerializedTx, + Label: txDetails.Label, + ReceivedTime: txDetails.Received, + Weight: safeInt64ToWeightUnit(txWeight), + } +} + +// populateBlockDetails populates the block details for the given TxDetail. +func (w *Wallet) populateBlockDetails(details *TxDetail, + txDetails *wtxmgr.TxDetails, currentHeight int32) { + + height := txDetails.Block.Height + if height == -1 { + return + } + + details.Block = &BlockDetails{ + Hash: txDetails.Block.Hash, + Height: txDetails.Block.Height, + Timestamp: txDetails.Block.Time.Unix(), + } + + details.Confirmations = calcConf(height, currentHeight) +} + +// calculateValueAndFee calculates the value and fee for the given TxDetail. +func (w *Wallet) calculateValueAndFee(details *TxDetail, + txDetails *wtxmgr.TxDetails) { + + var balanceDelta btcutil.Amount + for _, debit := range txDetails.Debits { + balanceDelta -= debit.Amount + } + + for _, credit := range txDetails.Credits { + balanceDelta += credit.Amount + } + + details.Value = balanceDelta + + // If not all inputs are ours, we can't calculate the total fee. + // txDetails.Debits contains only our inputs, while + // txDetails.MsgTx.TxIn contains all inputs. If they differ, some + // inputs belong to external wallets and we don't know their input + // values. + if len(txDetails.Debits) != len(txDetails.MsgTx.TxIn) { + return + } + + var totalInput btcutil.Amount + for _, debit := range txDetails.Debits { + totalInput += debit.Amount + } + + var totalOutput btcutil.Amount + for _, txOut := range txDetails.MsgTx.TxOut { + totalOutput += btcutil.Amount(txOut.Value) + } + + details.Fee = totalInput - totalOutput + details.FeeRate = unit.NewSatPerVByte( + details.Fee, details.Weight.ToVB(), + ) +} + +// populateOutputs populates the outputs for the given TxDetail. +func (w *Wallet) populateOutputs(details *TxDetail, + txDetails *wtxmgr.TxDetails) { + + isOurAddress := make(map[uint32]bool) + for _, credit := range txDetails.Credits { + isOurAddress[credit.Index] = true + } + + for i, txOut := range txDetails.MsgTx.TxOut { + sc, outAddresses, _, err := txscript.ExtractPkScriptAddrs( + txOut.PkScript, w.chainParams, + ) + + var addresses []btcutil.Address + if err != nil { + log.Warnf("Cannot extract addresses from pkScript for "+ + "tx %v, output %d: %v", details.Hash, i, err) + } else { + addresses = outAddresses + } + + idx, ok := safeIntToUint32(i) + if !ok { + log.Warnf("Output index %d out of uint32 range", i) + continue + } + + details.Outputs = append( + details.Outputs, Output{ + Type: sc, + Addresses: addresses, + PkScript: txOut.PkScript, + Index: i, + Amount: btcutil.Amount(txOut.Value), + IsOurs: isOurAddress[idx], + }, + ) + } +} + +// populatePrevOuts populates the previous outputs for the given TxDetail. +func (w *Wallet) populatePrevOuts(details *TxDetail, + txDetails *wtxmgr.TxDetails) { + + isOurOutput := make(map[uint32]bool) + for _, debit := range txDetails.Debits { + isOurOutput[debit.Index] = true + } + + for i, txIn := range txDetails.MsgTx.TxIn { + idx, ok := safeIntToUint32(i) + if !ok { + log.Warnf("Input index %d out of uint32 range", i) + continue + } + + details.PrevOuts = append( + details.PrevOuts, PrevOut{ + OutPoint: txIn.PreviousOutPoint, + IsOurs: isOurOutput[idx], + }, + ) + } +} + +// safeInt64ToWeightUnit converts an int64 to a unit.WeightUnit, ensuring the +// value is non-negative. +func safeInt64ToWeightUnit(w int64) unit.WeightUnit { + if w < 0 { + return 0 + } + + return unit.WeightUnit(w) +} + +// safeIntToUint32 converts an int to a uint32, returning false if the +// conversion would overflow. +func safeIntToUint32(i int) (uint32, bool) { + if i < 0 || i > math.MaxUint32 { + return 0, false + } + + return uint32(i), true +} diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go new file mode 100644 index 0000000000..4d669a4eb4 --- /dev/null +++ b/wallet/tx_reader_test.go @@ -0,0 +1,269 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcwallet/pkg/unit" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestBuildTxDetail tests the buildTxDetail function. +func TestBuildTxDetail(t *testing.T) { + t.Parallel() + + // Create the various test cases. + minedDetails, minedTxDetail := createMinedTxDetail(t) + unminedDetails, unminedTxDetail := createUnminedTxDetail(t) + unminedNoFeeDetails, unminedNoFeeTxDetail := createUnminedTxDetail(t) + unminedNoFeeDetails.Debits = nil + unminedNoFeeTxDetail.Fee = 0 + unminedNoFeeTxDetail.FeeRate = 0 + unminedNoFeeTxDetail.Value = unminedNoFeeDetails.Credits[0].Amount + + unminedNoFeeDetails.Credits[1].Amount + unminedNoFeeTxDetail.PrevOuts[0].IsOurs = false + + testCases := []struct { + name string + details *wtxmgr.TxDetails + expectedTxDetail *TxDetail + }{ + { + name: "mined tx", + details: minedDetails, + expectedTxDetail: minedTxDetail, + }, + { + name: "unmined tx", + details: unminedDetails, + expectedTxDetail: unminedTxDetail, + }, + { + name: "unmined tx no fee", + details: unminedNoFeeDetails, + expectedTxDetail: unminedNoFeeTxDetail, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Arrange: Create a test wallet with mocks. + w, _ := testWalletWithMocks(t) + currentHeight := int32(100) + + // Act: Build the TxDetail. + result := w.buildTxDetail(tc.details, currentHeight) + + // Assert: Check that the correct details are returned. + require.Equal(t, tc.expectedTxDetail, result) + }) + } +} + +// TestGetTxSuccess tests the GetTx method of the wallet for success scenarios. +func TestGetTxSuccess(t *testing.T) { + t.Parallel() + + minedDetails, minedTxDetail := createMinedTxDetail(t) + unminedDetails, unminedTxDetail := createUnminedTxDetail(t) + + testCases := []struct { + name string + mockDetails *wtxmgr.TxDetails + expectedTxDetail *TxDetail + }{ + { + name: "mined tx", + mockDetails: minedDetails, + expectedTxDetail: minedTxDetail, + }, + { + name: "unmined tx", + mockDetails: unminedDetails, + expectedTxDetail: unminedTxDetail, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Arrange: Create a test wallet with mocks. + w, mocks := testWalletWithMocks(t) + mocks.addrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{ + Height: 100, + }, + ) + mocks.txStore.On("TxDetails", mock.Anything, TstTxHash). + Return(tc.mockDetails, nil).Once() + + // Act: Get the transaction. + details, err := w.GetTx(t.Context(), *TstTxHash) + + // Assert: Check that the correct details are returned. + require.NoError(t, err) + require.Equal(t, tc.expectedTxDetail, details) + }) + } +} + +// TestGetTxNotFound tests that GetTx returns the correct error when a +// transaction is not found. +func TestGetTxNotFound(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet with mocks and mock the TxDetails call + // to return nil, simulating a non-existing tx. + w, mocks := testWalletWithMocks(t) + mocks.txStore.On("TxDetails", mock.Anything, TstTxHash). + Return(nil, nil).Once() + + // Act: Attempt to get the transaction. + _, err := w.GetTx(t.Context(), *TstTxHash) + + // Assert that the correct error is returned. + require.ErrorIs(t, err, ErrTxNotFound) +} + +// createUnminedTxDetail creates a test transaction that sends funds from the +// wallet to two of its own addresses. The transaction is unmined and has no +// confirmations. +// +// The transaction details are as follows: +// - The transaction has one input, which is owned by the wallet. +// - The transaction has two outputs, both of which are owned by the wallet. +// - The total value of the outputs (totalCredits) is the sum of the two +// output amounts. +// - The total value of the inputs (debitAmt) is the sum of the credits plus +// a fee. +// - The net value of the transaction from the wallet's perspective (Value) is +// totalCredits - debitAmt, which is equal to -fee. +func createUnminedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) { + t.Helper() + + // Create a deterministic timestamp for the test tx record. + txTime := time.Unix(1616161616, 0) + rec, err := wtxmgr.NewTxRecord(TstSerializedTx, txTime) + require.NoError(t, err) + + // Deserialize the test transaction to avoid using a global variable. + tx, err := btcutil.NewTxFromBytes(TstSerializedTx) + require.NoError(t, err) + + msgTx := tx.MsgTx() + + // The credits are the sum of all outputs of the test tx. + var totalCredits btcutil.Amount + for _, txOut := range msgTx.TxOut { + totalCredits += btcutil.Amount(txOut.Value) + } + + // The debit amount is the total credit amount plus a fee. + fee := btcutil.Amount(1000) + debitAmt := totalCredits + fee + testLabel := "test" + + out0Amt := btcutil.Amount(msgTx.TxOut[0].Value) + out1Amt := btcutil.Amount(msgTx.TxOut[1].Value) + + // Create a fully populated TxDetail for the unmined case. + unminedDetails := &wtxmgr.TxDetails{ + TxRecord: *rec, + Block: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: -1, + }, + }, + Credits: []wtxmgr.CreditRecord{ + { + Index: 0, + Amount: out0Amt, + }, + { + Index: 1, + Amount: out1Amt, + }, + }, + Debits: []wtxmgr.DebitRecord{ + { + Index: 0, + Amount: debitAmt, + }, + }, + Label: testLabel, + } + + // Manually build the expected outputs for the test tx. + expectedOutputs := make([]Output, len(msgTx.TxOut)) + for i, txOut := range msgTx.TxOut { + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + txOut.PkScript, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + expectedOutputs[i] = Output{ + Type: 2, + Addresses: addrs, + PkScript: txOut.PkScript, + Index: i, + Amount: btcutil.Amount(txOut.Value), + IsOurs: true, + } + } + + // Manually build the expected previous outputs for the test tx. + expectedPrevOuts := []PrevOut{ + { + OutPoint: msgTx.TxIn[0].PreviousOutPoint, + IsOurs: true, + }, + } + + // Define the expected TxDetail for the unmined case. + unminedTxDetail := &TxDetail{ + Hash: *TstTxHash, + RawTx: TstSerializedTx, + Label: testLabel, + Value: totalCredits - debitAmt, + Fee: fee, + FeeRate: 4, + Confirmations: 0, + Weight: unit.WeightUnit(blockchain.GetTransactionWeight( + btcutil.NewTx(&rec.MsgTx), + )), + ReceivedTime: txTime, + Outputs: expectedOutputs, + PrevOuts: expectedPrevOuts, + } + + return unminedDetails, unminedTxDetail +} + +// createMinedTxDetail builds on createUnminedTxDetail to create a mined +// transaction. The transaction has one confirmation. +func createMinedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) { + t.Helper() + + minedDetails, minedTxDetail := createUnminedTxDetail(t) + minedDetails.Block.Height = 100 + minedDetails.Block.Time = time.Unix(1616161617, 0) + minedTxDetail.Confirmations = 1 + minedTxDetail.Block = &BlockDetails{ + Height: 100, + Timestamp: minedDetails.Block.Time.Unix(), + } + + return minedDetails, minedTxDetail +} From 351a4f29c00fd5568c2dc4cb5ad845228da425eb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Sep 2025 19:18:42 +0800 Subject: [PATCH 125/691] wallet: implement `ListTxns` This commit implements the `ListTxns` method on the `TxReader` interface, providing a modern and efficient way to list transactions over a block range. This new function is a significant improvement over the older `GetTransactions` for several reasons: 1. **Efficiency**: `ListTxns` is far more performant. It uses `txStore.RangeTransactions` to stream transaction details from the database and then uses the in-memory `buildTxDetail` function for transformation. This avoids the N+1 query problem present in `GetTransactions`, which repeatedly called `makeTxSummary` and triggered numerous redundant database lookups in a loop. 2. **Cleaner API and Filtering**: The new function provides a simpler, more direct API for querying a specific block range. The database query is more precise, removing the need for manual post-filtering of results in Go. 3. **Consistent and Rich Return Type**: `ListTxns` returns a slice of `*TxDetail`, the same information-dense structure used by `GetTx`. This provides callers with a consistent, detailed, and client-friendly data format for all transaction queries. --- wallet/tx_reader.go | 65 ++++++++++++++++++++++++++++++++++++++++ wallet/tx_reader_test.go | 39 ++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go index 1ea899b9a8..42ddeeb93a 100644 --- a/wallet/tx_reader.go +++ b/wallet/tx_reader.go @@ -38,6 +38,10 @@ type TxReader interface { []*TxDetail, error) } +// A compile-time assertion to ensure that Wallet implements the TxReader +// interface. +var _ TxReader = (*Wallet)(nil) + // Output contains details for a tx output. type Output struct { // Addresses are the addresses associated with the output script. @@ -157,6 +161,67 @@ func (w *Wallet) GetTx(_ context.Context, txHash chainhash.Hash) ( return w.buildTxDetail(txDetails, currentHeight), nil } +// ListTxns returns a list of all txns which are relevant to the +// wallet over a given block range. The block range is inclusive of the +// start and end heights. +// +// The underlying transaction store allows for reverse iteration, so if +// startHeight > endHeight, the transactions will be returned in reverse +// order. +// +// The special height -1 may be used to include unmined transactions. For +// example, to get all transactions from block 100 to the current tip including +// unmined, use a startHeight of 100 and an endHeight of -1. To get all +// transactions in the wallet, use a startHeight of 0 and an endHeight of -1. +// +// NOTE: This method is part of the TxReader interface. +// +// Time complexity: O(B + N), where B is the number of blocks in the +// range and N is the total number of inputs and outputs across all +// transactions in the range. +func (w *Wallet) ListTxns(_ context.Context, startHeight, + endHeight int32) ([]*TxDetail, error) { + + bestBlock := w.SyncedTo() + currentHeight := bestBlock.Height + + // We'll first fetch all the transaction records from the database + // within a single database transaction. This is done to minimize the + // time we hold the database lock. + var records []wtxmgr.TxDetails + + err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + err := w.txStore.RangeTransactions( + txmgrNs, startHeight, endHeight, + func(d []wtxmgr.TxDetails) (bool, error) { + records = append(records, d...) + + return false, nil + }, + ) + if err != nil { + return fmt.Errorf("tx range failed: %w", err) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to view wallet db: %w", err) + } + + // Now that we have all the records, we can build the detailed + // response without holding the database lock. + details := make([]*TxDetail, 0, len(records)) + for _, detail := range records { + txDetail := w.buildTxDetail(&detail, currentHeight) + details = append(details, txDetail) + } + + return details, nil +} + // fetchTxDetails fetches the tx details for the given tx hash // from the wallet's tx store. func (w *Wallet) fetchTxDetails(txHash *chainhash.Hash) ( diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go index 4d669a4eb4..193930b195 100644 --- a/wallet/tx_reader_test.go +++ b/wallet/tx_reader_test.go @@ -137,6 +137,45 @@ func TestGetTxNotFound(t *testing.T) { require.ErrorIs(t, err, ErrTxNotFound) } +// TestListTxnsSuccess tests the ListTxns method of the wallet. +func TestListTxnsSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet with mocks and a mock tx record. + w, mocks := testWalletWithMocks(t) + _, expectedTxDetail := createMinedTxDetail(t) + + mocks.addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{ + Height: 100, + }) + + // Set up the mock for the tx store. We use .Run to execute the + // callback function that's passed in as an argument to the mock. + mocks.txStore.On("RangeTransactions", + mock.Anything, mock.Anything, mock.Anything, mock.Anything, + ).Run(func(args mock.Arguments) { + // Get the callback function from the arguments. + f, ok := args.Get(3).(func([]wtxmgr.TxDetails) (bool, error)) + require.True(t, ok) + + // Create the mock details to pass to the callback. + minedDetails, _ := createMinedTxDetail(t) + details := []wtxmgr.TxDetails{*minedDetails} + + // Call the callback. + _, err := f(details) + require.NoError(t, err) + }).Return(nil).Once() + + // Act: List txns. + details, err := w.ListTxns(t.Context(), 0, 1000) + + // Assert: Check that the correct details are returned. + require.NoError(t, err) + require.Len(t, details, 1) + require.Equal(t, expectedTxDetail, details[0]) +} + // createUnminedTxDetail creates a test transaction that sends funds from the // wallet to two of its own addresses. The transaction is unmined and has no // confirmations. From 8977a9ac427eea1f4171c7f09a65139b2a523c3a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Sep 2025 19:09:44 +0800 Subject: [PATCH 126/691] wallet: add `TxWriter` interface and implementation This commit introduces a new `TxWriter` interface that provides methods for updating wallet transactions. The first method on this interface is `LabelTx`, which allows a caller to add or overwrite a label for a given transaction. The `Wallet` type now implements this interface. This change also includes unit tests to verify the functionality of labeling a transaction, including success and error cases. --- wallet/tx_writer.go | 55 +++++++++++++++++++++++++++++++++++++ wallet/tx_writer_test.go | 59 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 wallet/tx_writer.go create mode 100644 wallet/tx_writer_test.go diff --git a/wallet/tx_writer.go b/wallet/tx_writer.go new file mode 100644 index 0000000000..1faafdb7cf --- /dev/null +++ b/wallet/tx_writer.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcwallet/walletdb" +) + +// TxWriter provides an interface for updating wallet txns. +type TxWriter interface { + // LabelTx adds a label to a tx. If a label already exists, it will be + // overwritten. + LabelTx(ctx context.Context, hash chainhash.Hash, label string) error +} + +// A compile time check to ensure that Wallet implements the interface. +var _ TxWriter = (*Wallet)(nil) + +// LabelTx adds a label to a tx. If a label already exists, it will be +// overwritten. +func (w *Wallet) LabelTx(_ context.Context, + hash chainhash.Hash, label string) error { + + err := walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { + txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) + + // Check that the transaction is known to the wallet. + details, err := w.txStore.TxDetails(txmgrNs, &hash) + if err != nil { + return fmt.Errorf("failed to get tx details: %w", err) + } + + if details == nil { + return ErrTxNotFound + } + + err = w.txStore.PutTxLabel(txmgrNs, hash, label) + if err != nil { + return fmt.Errorf("failed to put tx label: %w", err) + } + + return nil + }) + if err != nil { + return fmt.Errorf("failed to update wallet db: %w", err) + } + + return nil +} diff --git a/wallet/tx_writer_test.go b/wallet/tx_writer_test.go new file mode 100644 index 0000000000..0cdc144889 --- /dev/null +++ b/wallet/tx_writer_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestLabelTxSuccess tests that we can successfully label a transaction. +func TestLabelTxSuccess(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock the TxDetails call to simulate a known transaction. + // We return a non-nil TxDetails to pass the check. + mocks.txStore.On("TxDetails", mock.Anything, TstTxHash). + Return(&wtxmgr.TxDetails{}, nil).Once() + + // Arrange: Mock the PutTxLabel call. We expect it to be called with + // the new label. + newLabel := "new label" + mocks.txStore.On("PutTxLabel", mock.Anything, *TstTxHash, newLabel). + Return(nil).Once() + + // Act: Call the LabelTx function. + err := w.LabelTx(t.Context(), *TstTxHash, newLabel) + + // Assert: Check that there was no error and that the mocks were called + // as expected. + require.NoError(t, err) + mocks.txStore.AssertExpectations(t) +} + +// TestLabelTxNotFound tests that we get an error when we try to label a tx +// that is not known to the wallet. +func TestLabelTxNotFound(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock the TxDetails call to return nil, simulating a tx + // that is not known to the wallet. + mocks.txStore.On("TxDetails", mock.Anything, TstTxHash). + Return(nil, nil).Once() + + // Act: Attempt to label a tx that is not known to the wallet. + err := w.LabelTx(t.Context(), *TstTxHash, "some label") + + // Assert: Check that the correct error is returned. + require.ErrorIs(t, err, ErrTxNotFound) + mocks.txStore.AssertExpectations(t) +} From c55b1e7e54deab51b14cb6e0ef724dabe75e6cc9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 15 Oct 2025 02:57:20 +0800 Subject: [PATCH 127/691] unit: refactor fee rate types to structs This commit refactors the fee rate types (SatPerVByte, SatPerKVByte, and SatPerKWeight) from type aliases to structs that embed btcutil.Amount. This change improves type safety by preventing accidental type confusion between different fee rate units and btcutil.Amount. Type aliases are literally just aliases, so a function expecting SatPerVByte could also accept Amount, and vice versa. By using a struct, we ensure that this cannot happen and maintain stronger type safety. --- pkg/unit/rates.go | 50 ++++++++++++++++++++++++---------------- pkg/unit/rates_test.go | 38 +++++++++++++++--------------- wallet/tx_reader_test.go | 4 ++-- 3 files changed, 51 insertions(+), 41 deletions(-) diff --git a/pkg/unit/rates.go b/pkg/unit/rates.go index 9570029f77..2dec8462dc 100644 --- a/pkg/unit/rates.go +++ b/pkg/unit/rates.go @@ -20,78 +20,86 @@ const ( ) // SatPerVByte represents a fee rate in sat/vbyte. -type SatPerVByte btcutil.Amount +type SatPerVByte struct { + btcutil.Amount +} // NewSatPerVByte creates a new fee rate in sat/vb. func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { if vb == 0 { - return 0 + return SatPerVByte{0} } - return SatPerVByte(fee.MulF64(1 / float64(vb))) + return SatPerVByte{fee.MulF64(1 / float64(vb))} } // FeePerKWeight converts the current fee rate from sat/vb to sat/kw. func (s SatPerVByte) FeePerKWeight() SatPerKWeight { - return SatPerKWeight(s * SatsPerKilo / blockchain.WitnessScaleFactor) + return SatPerKWeight{ + s.Amount * SatsPerKilo / blockchain.WitnessScaleFactor, + } } // FeePerKVByte converts the current fee rate from sat/vb to sat/kvb. func (s SatPerVByte) FeePerKVByte() SatPerKVByte { - return SatPerKVByte(s * SatsPerKilo) + return SatPerKVByte{s.Amount * SatsPerKilo} } // String returns a human-readable string of the fee rate. func (s SatPerVByte) String() string { - return fmt.Sprintf("%v sat/vb", int64(s)) + return fmt.Sprintf("%v sat/vb", int64(s.Amount)) } // SatPerKVByte represents a fee rate in sat/kb. -type SatPerKVByte btcutil.Amount +type SatPerKVByte struct { + btcutil.Amount +} // NewSatPerKVByte creates a new fee rate in sat/kvb. func NewSatPerKVByte(fee btcutil.Amount, kvb VByte) SatPerKVByte { if kvb == 0 { - return 0 + return SatPerKVByte{0} } - return SatPerKVByte(fee.MulF64(SatsPerKilo / float64(kvb))) + return SatPerKVByte{fee.MulF64(SatsPerKilo / float64(kvb))} } // FeeForVSize calculates the fee resulting from this fee rate and the given // vsize in vbytes. func (s SatPerKVByte) FeeForVSize(vbytes VByte) btcutil.Amount { - return btcutil.Amount(s) * + return s.Amount * btcutil.Amount(safeUint64ToInt64(uint64(vbytes))) / SatsPerKilo } // FeePerKWeight converts the current fee rate from sat/kb to sat/kw. func (s SatPerKVByte) FeePerKWeight() SatPerKWeight { - return SatPerKWeight(s / blockchain.WitnessScaleFactor) + return SatPerKWeight{s.Amount / blockchain.WitnessScaleFactor} } // String returns a human-readable string of the fee rate. func (s SatPerKVByte) String() string { - return fmt.Sprintf("%v sat/kvb", int64(s)) + return fmt.Sprintf("%v sat/kvb", int64(s.Amount)) } // SatPerKWeight represents a fee rate in sat/kw. -type SatPerKWeight btcutil.Amount +type SatPerKWeight struct { + btcutil.Amount +} // NewSatPerKWeight creates a new fee rate in sat/kw. func NewSatPerKWeight(fee btcutil.Amount, wu WeightUnit) SatPerKWeight { if wu == 0 { - return 0 + return SatPerKWeight{0} } - return SatPerKWeight(fee.MulF64(SatsPerKilo / float64(wu))) + return SatPerKWeight{fee.MulF64(SatsPerKilo / float64(wu))} } // FeeForWeight calculates the fee resulting from this fee rate and the given // weight in weight units (wu). func (s SatPerKWeight) FeeForWeight(wu WeightUnit) btcutil.Amount { // The resulting fee is rounded down, as specified in BOLT#03. - return btcutil.Amount(s) * + return s.Amount * btcutil.Amount(safeUint64ToInt64(uint64(wu))) / SatsPerKilo } @@ -105,7 +113,7 @@ func (s SatPerKWeight) FeeForWeightRoundUp( // // This ensures that any fractional part of the fee is rounded up to // the next whole satoshi. - fee := btcutil.Amount(s) * btcutil.Amount(safeUint64ToInt64(uint64(wu))) + fee := s.Amount * btcutil.Amount(safeUint64ToInt64(uint64(wu))) fee += SatsPerKilo - 1 return fee / SatsPerKilo @@ -119,17 +127,19 @@ func (s SatPerKWeight) FeeForVByte(vb VByte) btcutil.Amount { // FeePerKVByte converts the current fee rate from sat/kw to sat/kb. func (s SatPerKWeight) FeePerKVByte() SatPerKVByte { - return SatPerKVByte(s * blockchain.WitnessScaleFactor) + return SatPerKVByte{s.Amount * blockchain.WitnessScaleFactor} } // FeePerVByte converts the current fee rate from sat/kw to sat/vb. func (s SatPerKWeight) FeePerVByte() SatPerVByte { - return SatPerVByte(s * blockchain.WitnessScaleFactor / SatsPerKilo) + return SatPerVByte{ + s.Amount * blockchain.WitnessScaleFactor / SatsPerKilo, + } } // String returns a human-readable string of the fee rate. func (s SatPerKWeight) String() string { - return fmt.Sprintf("%v sat/kw", int64(s)) + return fmt.Sprintf("%v sat/kw", int64(s.Amount)) } // safeUint64ToInt64 converts a uint64 to an int64, capping at math.MaxInt64. diff --git a/pkg/unit/rates_test.go b/pkg/unit/rates_test.go index 053a1d3bdf..6ac74646b6 100644 --- a/pkg/unit/rates_test.go +++ b/pkg/unit/rates_test.go @@ -22,26 +22,26 @@ func TestFeeRateConversions(t *testing.T) { }{ { name: "1 sat/vb", - rate: SatPerVByte(1), - expectedVB: SatPerVByte(1), - expectedKVB: SatPerKVByte(1000), - expectedKW: SatPerKWeight(250), + rate: SatPerVByte{1}, + expectedVB: SatPerVByte{1}, + expectedKVB: SatPerKVByte{1000}, + expectedKW: SatPerKWeight{250}, expectedSats: 1, }, { name: "1000 sat/kvb", - rate: SatPerKVByte(1000), - expectedVB: SatPerVByte(1), - expectedKVB: SatPerKVByte(1000), - expectedKW: SatPerKWeight(250), + rate: SatPerKVByte{1000}, + expectedVB: SatPerVByte{1}, + expectedKVB: SatPerKVByte{1000}, + expectedKW: SatPerKWeight{250}, expectedSats: 1000, }, { name: "250 sat/kw", - rate: SatPerKWeight(250), - expectedVB: SatPerVByte(1), - expectedKVB: SatPerKVByte(1000), - expectedKW: SatPerKWeight(250), + rate: SatPerKWeight{250}, + expectedVB: SatPerVByte{1}, + expectedKVB: SatPerKVByte{1000}, + expectedKW: SatPerKWeight{250}, expectedSats: 250, }, } @@ -60,7 +60,7 @@ func TestFeeRateConversions(t *testing.T) { t, tc.expectedKW, r.FeePerKWeight(), ) require.Equal( - t, tc.expectedSats, btcutil.Amount(r), + t, tc.expectedSats, r.Amount, ) case SatPerKVByte: @@ -73,7 +73,7 @@ func TestFeeRateConversions(t *testing.T) { t, tc.expectedKW, r.FeePerKWeight(), ) require.Equal( - t, tc.expectedSats, btcutil.Amount(r), + t, tc.expectedSats, r.Amount, ) case SatPerKWeight: @@ -85,7 +85,7 @@ func TestFeeRateConversions(t *testing.T) { ) require.Equal(t, tc.expectedKW, r) require.Equal( - t, tc.expectedSats, btcutil.Amount(r), + t, tc.expectedSats, r.Amount, ) } }) @@ -97,7 +97,7 @@ func TestFeeRateConversions(t *testing.T) { func TestFeeForWeightRoundUp(t *testing.T) { t.Parallel() - feeRate := SatPerVByte(1).FeePerKWeight() + feeRate := SatPerVByte{1}.FeePerKWeight() txWeight := WeightUnit(674) // 674 weight units is 168.5 vb. require.EqualValues(t, 168, feeRate.FeeForWeight(txWeight)) @@ -112,16 +112,16 @@ func TestNewFeeRateConstructors(t *testing.T) { // Test NewSatPerKWeight. fee := btcutil.Amount(1000) wu := WeightUnit(1000) - expectedRate := SatPerKWeight(1000) + expectedRate := SatPerKWeight{1000} require.Equal(t, expectedRate, NewSatPerKWeight(fee, wu)) // Test NewSatPerVByte. vb := VByte(250) - expectedRateVB := SatPerVByte(4) + expectedRateVB := SatPerVByte{4} require.Equal(t, expectedRateVB, NewSatPerVByte(fee, vb)) // Test NewSatPerKVByte. kvb := VByte(1) - expectedRateKVB := SatPerKVByte(1000000) + expectedRateKVB := SatPerKVByte{1000000} require.Equal(t, expectedRateKVB, NewSatPerKVByte(fee, kvb)) } diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go index 193930b195..58c60a9345 100644 --- a/wallet/tx_reader_test.go +++ b/wallet/tx_reader_test.go @@ -29,7 +29,7 @@ func TestBuildTxDetail(t *testing.T) { unminedNoFeeDetails, unminedNoFeeTxDetail := createUnminedTxDetail(t) unminedNoFeeDetails.Debits = nil unminedNoFeeTxDetail.Fee = 0 - unminedNoFeeTxDetail.FeeRate = 0 + unminedNoFeeTxDetail.FeeRate = unit.SatPerVByte{Amount: 0} unminedNoFeeTxDetail.Value = unminedNoFeeDetails.Credits[0].Amount + unminedNoFeeDetails.Credits[1].Amount unminedNoFeeTxDetail.PrevOuts[0].IsOurs = false @@ -277,7 +277,7 @@ func createUnminedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) { Label: testLabel, Value: totalCredits - debitAmt, Fee: fee, - FeeRate: 4, + FeeRate: unit.SatPerVByte{Amount: 4}, Confirmations: 0, Weight: unit.WeightUnit(blockchain.GetTransactionWeight( btcutil.NewTx(&rec.MsgTx), From c6822202a0ad2e60edb4008f03739b074856a630 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 15 Oct 2025 13:16:44 +0800 Subject: [PATCH 128/691] unit: use big.Rat for fee rates This commit refactors the fee rate types to use big.Rat for precise calculations that support sub-satoshi values. This avoids floating-point inaccuracies while allowing for fractional fee rates. The fee rate structs now embed a *big.Rat, and all associated methods have been updated to use rational arithmetic. Comparison methods (Equal, GreaterThan, etc.) have been added to provide a convenient and type-safe way to compare different fee rates. --- pkg/unit/README.md | 6 +- pkg/unit/rates.go | 207 ++++++++++++++++++++++++++++++++------- pkg/unit/rates_test.go | 133 ++++++++++++++++++------- wallet/tx_reader.go | 2 + wallet/tx_reader_test.go | 18 ++-- 5 files changed, 284 insertions(+), 82 deletions(-) diff --git a/pkg/unit/README.md b/pkg/unit/README.md index cd42fcd28c..3c35f4b585 100644 --- a/pkg/unit/README.md +++ b/pkg/unit/README.md @@ -18,4 +18,8 @@ avoid conversion errors and make their code more readable and self-documenting. - **Transaction Size**: `WeightUnit` and `VByte` for handling transaction weight and virtual size according to SegWit (BIP-141) standards. - **Fee Rates**: `SatPerVByte`, `SatPerKVByte`, and `SatPerKWeight` for - expressing fee rates in the most common industry formats. + expressing fee rates in the most common industry formats. These types use + `math/big.Rat` internally to allow for fractional (sub-satoshi) values, + ensuring precision in fee calculations. These types use + `math/big.Rat` internally to allow for fractional (sub-satoshi) values, + ensuring precision in fee calculations. diff --git a/pkg/unit/rates.go b/pkg/unit/rates.go index 2dec8462dc..650aa329a1 100644 --- a/pkg/unit/rates.go +++ b/pkg/unit/rates.go @@ -6,9 +6,9 @@ package unit import ( - "fmt" "log/slog" "math" + "math/big" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcutil" @@ -17,90 +17,180 @@ import ( const ( // SatsPerKilo is the number of satoshis in a kilo-satoshi. SatsPerKilo = 1000 + + // floatStringPrecision is the number of decimal places to use when + // converting a fee rate to a string. + floatStringPrecision = 2 ) -// SatPerVByte represents a fee rate in sat/vbyte. +// SatPerVByte represents a fee rate in sat/vbyte. The fee rate is encoded +// as a big.Rat to allow for fractional (sub-satoshi) fee rates. type SatPerVByte struct { - btcutil.Amount + *big.Rat } -// NewSatPerVByte creates a new fee rate in sat/vb. +// NewSatPerVByte creates a new fee rate in sat/vb. The given fee and vbytes +// are used to calculate the fee rate. func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { if vb == 0 { - return SatPerVByte{0} + return SatPerVByte{big.NewRat(0, 1)} } - return SatPerVByte{fee.MulF64(1 / float64(vb))} + return SatPerVByte{ + big.NewRat(int64(fee), safeUint64ToInt64(uint64(vb))), + } } // FeePerKWeight converts the current fee rate from sat/vb to sat/kw. func (s SatPerVByte) FeePerKWeight() SatPerKWeight { - return SatPerKWeight{ - s.Amount * SatsPerKilo / blockchain.WitnessScaleFactor, - } + vbToKwRate := big.NewRat(SatsPerKilo, blockchain.WitnessScaleFactor) + kwRate := new(big.Rat).Mul(s.Rat, vbToKwRate) + + return SatPerKWeight{kwRate} } // FeePerKVByte converts the current fee rate from sat/vb to sat/kvb. func (s SatPerVByte) FeePerKVByte() SatPerKVByte { - return SatPerKVByte{s.Amount * SatsPerKilo} + vbToKvbRate := big.NewRat(SatsPerKilo, 1) + kvbRate := new(big.Rat).Mul(s.Rat, vbToKvbRate) + + return SatPerKVByte{kvbRate} } // String returns a human-readable string of the fee rate. func (s SatPerVByte) String() string { - return fmt.Sprintf("%v sat/vb", int64(s.Amount)) + return s.FloatString(floatStringPrecision) + " sat/vb" +} + +// Equal returns true if the fee rate is equal to the other fee rate. +func (s SatPerVByte) Equal(other SatPerVByte) bool { + return s.Cmp(other.Rat) == 0 +} + +// GreaterThan returns true if the fee rate is greater than the other fee rate. +func (s SatPerVByte) GreaterThan(other SatPerVByte) bool { + return s.Cmp(other.Rat) > 0 +} + +// LessThan returns true if the fee rate is less than the other fee rate. +func (s SatPerVByte) LessThan(other SatPerVByte) bool { + return s.Cmp(other.Rat) < 0 } -// SatPerKVByte represents a fee rate in sat/kb. +// GreaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (s SatPerVByte) GreaterThanOrEqual(other SatPerVByte) bool { + return s.Cmp(other.Rat) >= 0 +} + +// LessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (s SatPerVByte) LessThanOrEqual(other SatPerVByte) bool { + return s.Cmp(other.Rat) <= 0 +} + +// SatPerKVByte represents a fee rate in sat/kb. The fee rate is encoded as a +// big.Rat to allow for fractional (sub-satoshi) fee rates. type SatPerKVByte struct { - btcutil.Amount + *big.Rat } -// NewSatPerKVByte creates a new fee rate in sat/kvb. +// NewSatPerKVByte creates a new fee rate in sat/kvb. The given fee and kvbytes +// are used to calculate the fee rate. func NewSatPerKVByte(fee btcutil.Amount, kvb VByte) SatPerKVByte { if kvb == 0 { - return SatPerKVByte{0} + return SatPerKVByte{big.NewRat(0, 1)} } - return SatPerKVByte{fee.MulF64(SatsPerKilo / float64(kvb))} + return SatPerKVByte{ + big.NewRat( + int64(fee)*SatsPerKilo, + safeUint64ToInt64(uint64(kvb)), + ), + } } // FeeForVSize calculates the fee resulting from this fee rate and the given // vsize in vbytes. func (s SatPerKVByte) FeeForVSize(vbytes VByte) btcutil.Amount { - return s.Amount * - btcutil.Amount(safeUint64ToInt64(uint64(vbytes))) / SatsPerKilo + fee := new(big.Rat).Mul( + s.Rat, + big.NewRat(safeUint64ToInt64(uint64(vbytes)), SatsPerKilo), + ) + + return roundToAmount(fee) } // FeePerKWeight converts the current fee rate from sat/kb to sat/kw. func (s SatPerKVByte) FeePerKWeight() SatPerKWeight { - return SatPerKWeight{s.Amount / blockchain.WitnessScaleFactor} + kvbToKwRate := big.NewRat(1, blockchain.WitnessScaleFactor) + kwRate := new(big.Rat).Mul(s.Rat, kvbToKwRate) + + return SatPerKWeight{kwRate} } // String returns a human-readable string of the fee rate. func (s SatPerKVByte) String() string { - return fmt.Sprintf("%v sat/kvb", int64(s.Amount)) + return s.FloatString(floatStringPrecision) + " sat/kvb" +} + +// Equal returns true if the fee rate is equal to the other fee rate. +func (s SatPerKVByte) Equal(other SatPerKVByte) bool { + return s.Cmp(other.Rat) == 0 +} + +// GreaterThan returns true if the fee rate is greater than the other fee rate. +func (s SatPerKVByte) GreaterThan(other SatPerKVByte) bool { + return s.Cmp(other.Rat) > 0 +} + +// LessThan returns true if the fee rate is less than the other fee rate. +func (s SatPerKVByte) LessThan(other SatPerKVByte) bool { + return s.Cmp(other.Rat) < 0 } -// SatPerKWeight represents a fee rate in sat/kw. +// GreaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (s SatPerKVByte) GreaterThanOrEqual(other SatPerKVByte) bool { + return s.Cmp(other.Rat) >= 0 +} + +// LessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (s SatPerKVByte) LessThanOrEqual(other SatPerKVByte) bool { + return s.Cmp(other.Rat) <= 0 +} + +// SatPerKWeight represents a fee rate in sat/kw. The fee rate is encoded as a +// big.Rat to allow for fractional (sub-satoshi) fee rates. type SatPerKWeight struct { - btcutil.Amount + *big.Rat } -// NewSatPerKWeight creates a new fee rate in sat/kw. +// NewSatPerKWeight creates a new fee rate in sat/kw. The given fee and weight +// are used to calculate the fee rate. func NewSatPerKWeight(fee btcutil.Amount, wu WeightUnit) SatPerKWeight { if wu == 0 { - return SatPerKWeight{0} + return SatPerKWeight{big.NewRat(0, 1)} } - return SatPerKWeight{fee.MulF64(SatsPerKilo / float64(wu))} + return SatPerKWeight{ + big.NewRat( + int64(fee)*SatsPerKilo, + safeUint64ToInt64(uint64(wu)), + ), + } } // FeeForWeight calculates the fee resulting from this fee rate and the given // weight in weight units (wu). func (s SatPerKWeight) FeeForWeight(wu WeightUnit) btcutil.Amount { // The resulting fee is rounded down, as specified in BOLT#03. - return s.Amount * - btcutil.Amount(safeUint64ToInt64(uint64(wu))) / SatsPerKilo + fee := new(big.Rat).Mul( + s.Rat, big.NewRat(safeUint64ToInt64(uint64(wu)), SatsPerKilo), + ) + + return btcutil.Amount(new(big.Int).Div(fee.Num(), fee.Denom()).Int64()) } // FeeForWeightRoundUp calculates the fee resulting from this fee rate and the @@ -113,10 +203,17 @@ func (s SatPerKWeight) FeeForWeightRoundUp( // // This ensures that any fractional part of the fee is rounded up to // the next whole satoshi. - fee := s.Amount * btcutil.Amount(safeUint64ToInt64(uint64(wu))) - fee += SatsPerKilo - 1 + feeRat := new(big.Rat).Mul( + s.Rat, big.NewRat(safeUint64ToInt64(uint64(wu)), SatsPerKilo), + ) - return fee / SatsPerKilo + num := feeRat.Num() + den := feeRat.Denom() + num.Add(num, den) + num.Sub(num, big.NewInt(1)) + num.Div(num, den) + + return btcutil.Amount(num.Int64()) } // FeeForVByte calculates the fee resulting from this fee rate and the given @@ -127,19 +224,59 @@ func (s SatPerKWeight) FeeForVByte(vb VByte) btcutil.Amount { // FeePerKVByte converts the current fee rate from sat/kw to sat/kb. func (s SatPerKWeight) FeePerKVByte() SatPerKVByte { - return SatPerKVByte{s.Amount * blockchain.WitnessScaleFactor} + kwToKvbRate := big.NewRat(blockchain.WitnessScaleFactor, 1) + kvbRate := new(big.Rat).Mul(s.Rat, kwToKvbRate) + + return SatPerKVByte{kvbRate} } // FeePerVByte converts the current fee rate from sat/kw to sat/vb. func (s SatPerKWeight) FeePerVByte() SatPerVByte { - return SatPerVByte{ - s.Amount * blockchain.WitnessScaleFactor / SatsPerKilo, - } + kwToVbRate := big.NewRat(blockchain.WitnessScaleFactor, SatsPerKilo) + vbRate := new(big.Rat).Mul(s.Rat, kwToVbRate) + + return SatPerVByte{vbRate} } // String returns a human-readable string of the fee rate. func (s SatPerKWeight) String() string { - return fmt.Sprintf("%v sat/kw", int64(s.Amount)) + return s.FloatString(floatStringPrecision) + " sat/kw" +} + +// Equal returns true if the fee rate is equal to the other fee rate. +func (s SatPerKWeight) Equal(other SatPerKWeight) bool { + return s.Cmp(other.Rat) == 0 +} + +// GreaterThan returns true if the fee rate is greater than the other fee rate. +func (s SatPerKWeight) GreaterThan(other SatPerKWeight) bool { + return s.Cmp(other.Rat) > 0 +} + +// LessThan returns true if the fee rate is less than the other fee rate. +func (s SatPerKWeight) LessThan(other SatPerKWeight) bool { + return s.Cmp(other.Rat) < 0 +} + +// GreaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (s SatPerKWeight) GreaterThanOrEqual(other SatPerKWeight) bool { + return s.Cmp(other.Rat) >= 0 +} + +// LessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (s SatPerKWeight) LessThanOrEqual(other SatPerKWeight) bool { + return s.Cmp(other.Rat) <= 0 +} + +// roundToAmount rounds a big.Rat to the nearest btcutil.Amount (int64), +// with halves rounded away from zero. For example, 2.4 rounds to 2, 2.5 +// rounds to 3, and -2.5 rounds to -3. +func roundToAmount(r *big.Rat) btcutil.Amount { + f, _ := r.Float64() + + return btcutil.Amount(math.Round(f)) } // safeUint64ToInt64 converts a uint64 to an int64, capping at math.MaxInt64. diff --git a/pkg/unit/rates_test.go b/pkg/unit/rates_test.go index 6ac74646b6..2ef57393d4 100644 --- a/pkg/unit/rates_test.go +++ b/pkg/unit/rates_test.go @@ -1,6 +1,7 @@ package unit import ( + "math/big" "testing" "github.com/btcsuite/btcd/btcutil" @@ -22,28 +23,36 @@ func TestFeeRateConversions(t *testing.T) { }{ { name: "1 sat/vb", - rate: SatPerVByte{1}, - expectedVB: SatPerVByte{1}, - expectedKVB: SatPerKVByte{1000}, - expectedKW: SatPerKWeight{250}, + rate: SatPerVByte{big.NewRat(1, 1)}, + expectedVB: SatPerVByte{big.NewRat(1, 1)}, + expectedKVB: SatPerKVByte{big.NewRat(1000, 1)}, + expectedKW: SatPerKWeight{big.NewRat(250, 1)}, expectedSats: 1, }, { name: "1000 sat/kvb", - rate: SatPerKVByte{1000}, - expectedVB: SatPerVByte{1}, - expectedKVB: SatPerKVByte{1000}, - expectedKW: SatPerKWeight{250}, + rate: SatPerKVByte{big.NewRat(1000, 1)}, + expectedVB: SatPerVByte{big.NewRat(1, 1)}, + expectedKVB: SatPerKVByte{big.NewRat(1000, 1)}, + expectedKW: SatPerKWeight{big.NewRat(250, 1)}, expectedSats: 1000, }, { name: "250 sat/kw", - rate: SatPerKWeight{250}, - expectedVB: SatPerVByte{1}, - expectedKVB: SatPerKVByte{1000}, - expectedKW: SatPerKWeight{250}, + rate: SatPerKWeight{big.NewRat(250, 1)}, + expectedVB: SatPerVByte{big.NewRat(1, 1)}, + expectedKVB: SatPerKVByte{big.NewRat(1000, 1)}, + expectedKW: SatPerKWeight{big.NewRat(250, 1)}, expectedSats: 250, }, + { + name: "0.11 sat/vb", + rate: SatPerVByte{big.NewRat(11, 100)}, + expectedVB: SatPerVByte{big.NewRat(11, 100)}, + expectedKVB: SatPerKVByte{big.NewRat(110, 1)}, + expectedKW: SatPerKWeight{big.NewRat(11000, 400)}, + expectedSats: 0, + }, } for _, tc := range testCases { @@ -52,52 +61,94 @@ func TestFeeRateConversions(t *testing.T) { switch r := tc.rate.(type) { case SatPerVByte: - require.Equal(t, tc.expectedVB, r) - require.Equal( - t, tc.expectedKVB, r.FeePerKVByte(), + require.True(t, tc.expectedVB.Equal(r)) + require.True(t, tc.expectedKVB.Equal( + r.FeePerKVByte()), ) - require.Equal( - t, tc.expectedKW, r.FeePerKWeight(), + require.True(t, tc.expectedKW.Equal( + r.FeePerKWeight()), ) + + // The expected sats is the floor of the fee + // rate. + floor := new(big.Int).Div(r.Num(), r.Denom()) require.Equal( - t, tc.expectedSats, r.Amount, + t, tc.expectedSats, + btcutil.Amount(floor.Int64()), ) case SatPerKVByte: - require.Equal( - t, tc.expectedVB, - r.FeePerKWeight().FeePerVByte(), + require.True(t, tc.expectedVB.Equal( + r.FeePerKWeight().FeePerVByte()), ) - require.Equal(t, tc.expectedKVB, r) - require.Equal( - t, tc.expectedKW, r.FeePerKWeight(), + require.True(t, tc.expectedKVB.Equal(r)) + require.True(t, tc.expectedKW.Equal( + r.FeePerKWeight()), ) + floor := new(big.Int).Div(r.Num(), r.Denom()) require.Equal( - t, tc.expectedSats, r.Amount, + t, tc.expectedSats, + btcutil.Amount(floor.Int64()), ) case SatPerKWeight: - require.Equal( - t, tc.expectedVB, r.FeePerVByte(), + require.True(t, tc.expectedVB.Equal( + r.FeePerVByte()), ) - require.Equal( - t, tc.expectedKVB, r.FeePerKVByte(), + require.True(t, tc.expectedKVB.Equal( + r.FeePerKVByte()), ) - require.Equal(t, tc.expectedKW, r) + require.True(t, tc.expectedKW.Equal(r)) + floor := new(big.Int).Div(r.Num(), r.Denom()) require.Equal( - t, tc.expectedSats, r.Amount, + t, tc.expectedSats, + btcutil.Amount(floor.Int64()), ) } }) } } +// TestFeeRateComparisons tests the comparison methods of the fee rate types. +func TestFeeRateComparisons(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to compare. + r1 := SatPerVByte{big.NewRat(1, 1)} + r2 := SatPerVByte{big.NewRat(2, 1)} + r3 := SatPerVByte{big.NewRat(1, 1)} + + // Test Equal. + require.True(t, r1.Equal(r3)) + require.False(t, r1.Equal(r2)) + + // Test GreaterThan. + require.True(t, r2.GreaterThan(r1)) + require.False(t, r1.GreaterThan(r2)) + require.False(t, r1.GreaterThan(r3)) + + // Test LessThan. + require.True(t, r1.LessThan(r2)) + require.False(t, r2.LessThan(r1)) + require.False(t, r1.LessThan(r3)) + + // Test GreaterThanOrEqual. + require.True(t, r2.GreaterThanOrEqual(r1)) + require.True(t, r1.GreaterThanOrEqual(r3)) + require.False(t, r1.GreaterThanOrEqual(r2)) + + // Test LessThanOrEqual. + require.True(t, r1.LessThanOrEqual(r2)) + require.True(t, r1.LessThanOrEqual(r3)) + require.False(t, r2.LessThanOrEqual(r1)) +} + // TestFeeForWeightRoundUp checks that the FeeForWeightRoundUp method correctly // rounds up the fee for a given weight. func TestFeeForWeightRoundUp(t *testing.T) { t.Parallel() - feeRate := SatPerVByte{1}.FeePerKWeight() + feeRate := SatPerVByte{big.NewRat(1, 1)}.FeePerKWeight() txWeight := WeightUnit(674) // 674 weight units is 168.5 vb. require.EqualValues(t, 168, feeRate.FeeForWeight(txWeight)) @@ -112,16 +163,22 @@ func TestNewFeeRateConstructors(t *testing.T) { // Test NewSatPerKWeight. fee := btcutil.Amount(1000) wu := WeightUnit(1000) - expectedRate := SatPerKWeight{1000} - require.Equal(t, expectedRate, NewSatPerKWeight(fee, wu)) + expectedRate := SatPerKWeight{big.NewRat(1000, 1)} + require.Zero( + t, expectedRate.Cmp(NewSatPerKWeight(fee, wu).Rat), + ) // Test NewSatPerVByte. vb := VByte(250) - expectedRateVB := SatPerVByte{4} - require.Equal(t, expectedRateVB, NewSatPerVByte(fee, vb)) + expectedRateVB := SatPerVByte{big.NewRat(4, 1)} + require.Zero( + t, expectedRateVB.Cmp(NewSatPerVByte(fee, vb).Rat), + ) // Test NewSatPerKVByte. kvb := VByte(1) - expectedRateKVB := SatPerKVByte{1000000} - require.Equal(t, expectedRateKVB, NewSatPerKVByte(fee, kvb)) + expectedRateKVB := SatPerKVByte{big.NewRat(1000000, 1)} + require.Zero( + t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat), + ) } diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go index 42ddeeb93a..268cf75cdd 100644 --- a/wallet/tx_reader.go +++ b/wallet/tx_reader.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "math" + "math/big" "time" "github.com/btcsuite/btcd/blockchain" @@ -282,6 +283,7 @@ func (w *Wallet) buildBasicTxDetail(txDetails *wtxmgr.TxDetails) *TxDetail { Label: txDetails.Label, ReceivedTime: txDetails.Received, Weight: safeInt64ToWeightUnit(txWeight), + FeeRate: unit.SatPerVByte{Rat: big.NewRat(0, 1)}, } } diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go index 58c60a9345..0a154b88ec 100644 --- a/wallet/tx_reader_test.go +++ b/wallet/tx_reader_test.go @@ -5,6 +5,7 @@ package wallet import ( + "math/big" "testing" "time" @@ -29,7 +30,7 @@ func TestBuildTxDetail(t *testing.T) { unminedNoFeeDetails, unminedNoFeeTxDetail := createUnminedTxDetail(t) unminedNoFeeDetails.Debits = nil unminedNoFeeTxDetail.Fee = 0 - unminedNoFeeTxDetail.FeeRate = unit.SatPerVByte{Amount: 0} + unminedNoFeeTxDetail.FeeRate = unit.SatPerVByte{Rat: big.NewRat(0, 1)} unminedNoFeeTxDetail.Value = unminedNoFeeDetails.Credits[0].Amount + unminedNoFeeDetails.Credits[1].Amount unminedNoFeeTxDetail.PrevOuts[0].IsOurs = false @@ -271,20 +272,21 @@ func createUnminedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) { } // Define the expected TxDetail for the unmined case. + weight := unit.WeightUnit(blockchain.GetTransactionWeight( + btcutil.NewTx(&rec.MsgTx), + )) unminedTxDetail := &TxDetail{ Hash: *TstTxHash, RawTx: TstSerializedTx, Label: testLabel, Value: totalCredits - debitAmt, Fee: fee, - FeeRate: unit.SatPerVByte{Amount: 4}, + FeeRate: unit.NewSatPerVByte(fee, weight.ToVB()), Confirmations: 0, - Weight: unit.WeightUnit(blockchain.GetTransactionWeight( - btcutil.NewTx(&rec.MsgTx), - )), - ReceivedTime: txTime, - Outputs: expectedOutputs, - PrevOuts: expectedPrevOuts, + Weight: weight, + ReceivedTime: txTime, + Outputs: expectedOutputs, + PrevOuts: expectedPrevOuts, } return unminedDetails, unminedTxDetail From 120e6e58845e0a26b9633965ced25eb0c4d00dc2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 10 Nov 2025 19:54:46 +0800 Subject: [PATCH 129/691] unit: improve test coverage from 65.1% to 100% --- pkg/unit/rates_test.go | 154 ++++++++++++++++++++++++++++++++++++++++ pkg/unit/txsize_test.go | 13 ++++ 2 files changed, 167 insertions(+) diff --git a/pkg/unit/rates_test.go b/pkg/unit/rates_test.go index 2ef57393d4..92242d3b8d 100644 --- a/pkg/unit/rates_test.go +++ b/pkg/unit/rates_test.go @@ -1,6 +1,7 @@ package unit import ( + "math" "math/big" "testing" @@ -182,3 +183,156 @@ func TestNewFeeRateConstructors(t *testing.T) { t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat), ) } + +// TestStringer tests the stringer methods of the fee rate types. +func TestStringer(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to test. + r1 := SatPerVByte{big.NewRat(1, 1)} + r2 := SatPerKVByte{big.NewRat(1000, 1)} + r3 := SatPerKWeight{big.NewRat(250, 1)} + + // Test String. + require.Equal(t, "1.00 sat/vb", r1.String()) + require.Equal(t, "1000.00 sat/kvb", r2.String()) + require.Equal(t, "250.00 sat/kw", r3.String()) +} + +// TestFeeRateComparisonsKVB tests the comparison methods of the SatPerKVByte +// type. +func TestFeeRateComparisonsKVB(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to compare. + r1 := SatPerKVByte{big.NewRat(1, 1)} + r2 := SatPerKVByte{big.NewRat(2, 1)} + r3 := SatPerKVByte{big.NewRat(1, 1)} + + // Test Equal. + require.True(t, r1.Equal(r3)) + require.False(t, r1.Equal(r2)) + + // Test GreaterThan. + require.True(t, r2.GreaterThan(r1)) + require.False(t, r1.GreaterThan(r2)) + require.False(t, r1.GreaterThan(r3)) + + // Test LessThan. + require.True(t, r1.LessThan(r2)) + require.False(t, r2.LessThan(r1)) + require.False(t, r1.LessThan(r3)) + + // Test GreaterThanOrEqual. + require.True(t, r2.GreaterThanOrEqual(r1)) + require.True(t, r1.GreaterThanOrEqual(r3)) + require.False(t, r1.GreaterThanOrEqual(r2)) + + // Test LessThanOrEqual. + require.True(t, r1.LessThanOrEqual(r2)) + require.True(t, r1.LessThanOrEqual(r3)) + require.False(t, r2.LessThanOrEqual(r1)) +} + +// TestFeeRateComparisonsKW tests the comparison methods of the SatPerKWeight +// type. +func TestFeeRateComparisonsKW(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to compare. + r1 := SatPerKWeight{big.NewRat(1, 1)} + r2 := SatPerKWeight{big.NewRat(2, 1)} + r3 := SatPerKWeight{big.NewRat(1, 1)} + + // Test Equal. + require.True(t, r1.Equal(r3)) + require.False(t, r1.Equal(r2)) + + // Test GreaterThan. + require.True(t, r2.GreaterThan(r1)) + require.False(t, r1.GreaterThan(r2)) + require.False(t, r1.GreaterThan(r3)) + + // Test LessThan. + require.True(t, r1.LessThan(r2)) + require.False(t, r2.LessThan(r1)) + require.False(t, r1.LessThan(r3)) + + // Test GreaterThanOrEqual. + require.True(t, r2.GreaterThanOrEqual(r1)) + require.True(t, r1.GreaterThanOrEqual(r3)) + require.False(t, r1.GreaterThanOrEqual(r2)) + + // Test LessThanOrEqual. + require.True(t, r1.LessThanOrEqual(r2)) + require.True(t, r1.LessThanOrEqual(r3)) + require.False(t, r2.LessThanOrEqual(r1)) +} + +// TestFeeForSize tests the FeeForVSize and FeeForVByte methods. +func TestFeeForSize(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to test. + r1 := SatPerKVByte{big.NewRat(1000, 1)} + r2 := SatPerKWeight{big.NewRat(250, 1)} + + // Test FeeForVSize. + require.Equal(t, btcutil.Amount(250), r1.FeeForVSize(250)) + + // Test FeeForVByte. + require.Equal(t, btcutil.Amount(250), r2.FeeForVByte(250)) +} + +// TestNewFeeRateConstructorsZero tests the New* fee rate constructors with +// zero values. +func TestNewFeeRateConstructorsZero(t *testing.T) { + t.Parallel() + + // Test NewSatPerKWeight with zero weight. + fee := btcutil.Amount(1000) + wu := WeightUnit(0) + expectedRate := SatPerKWeight{big.NewRat(0, 1)} + require.Zero( + t, expectedRate.Cmp(NewSatPerKWeight(fee, wu).Rat), + ) + + // Test NewSatPerVByte with zero vbytes. + vb := VByte(0) + expectedRateVB := SatPerVByte{big.NewRat(0, 1)} + require.Zero( + t, expectedRateVB.Cmp(NewSatPerVByte(fee, vb).Rat), + ) + + // Test NewSatPerKVByte with zero kvbytes. + kvb := VByte(0) + expectedRateKVB := SatPerKVByte{big.NewRat(0, 1)} + require.Zero( + t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat), + ) +} + +// TestSafeUint64ToInt64Overflow tests the overflow condition in +// safeUint64ToInt64 through the New* constructors. +func TestSafeUint64ToInt64Overflow(t *testing.T) { + t.Parallel() + + fee := btcutil.Amount(1) + overflowVByte := VByte(math.MaxInt64 + 1) + + // Test NewSatPerVByte with an overflowing vbyte value. + // The denominator should be capped at math.MaxInt64. + rateVB := NewSatPerVByte(fee, overflowVByte) + expectedDenom := big.NewInt(math.MaxInt64) + require.Zero(t, expectedDenom.Cmp(rateVB.Denom())) + + // Test NewSatPerKVByte with an overflowing kvb value. + // The denominator should be capped at math.MaxInt64. + rateKVB := NewSatPerKVByte(fee, overflowVByte) + require.Zero(t, expectedDenom.Cmp(rateKVB.Denom())) + + // Test NewSatPerKWeight with an overflowing weight unit value. + overflowWU := WeightUnit(math.MaxInt64 + 1) + rateKW := NewSatPerKWeight(fee, overflowWU) + require.Zero(t, expectedDenom.Cmp(rateKW.Denom())) +} diff --git a/pkg/unit/txsize_test.go b/pkg/unit/txsize_test.go index 83e16b4d94..9c2486062c 100644 --- a/pkg/unit/txsize_test.go +++ b/pkg/unit/txsize_test.go @@ -20,3 +20,16 @@ func TestTxSizeConversion(t *testing.T) { // 250 vb should be equal to 1000 wu. require.Equal(t, wu, VByte(250).ToWU()) } + +// TestTxSizeStringer tests the stringer methods of the tx size types. +func TestTxSizeStringer(t *testing.T) { + t.Parallel() + + // Create a test weight of 1000 wu. + wu := WeightUnit(1000) + vb := VByte(250) + + // Test String. + require.Equal(t, "1000 wu", wu.String()) + require.Equal(t, "250 vb", vb.String()) +} From 13b25f9d8e1654323348dc935f22ff81da129807 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 10 Nov 2025 20:11:01 +0800 Subject: [PATCH 130/691] wallet+pkg: turn `VByte` and `WeightUnit` into structs for type safety --- pkg/unit/rates.go | 18 +++++++++--------- pkg/unit/rates_test.go | 22 +++++++++++----------- pkg/unit/txsize.go | 27 +++++++++++++++++++++------ pkg/unit/txsize_test.go | 10 +++++----- wallet/tx_reader.go | 4 ++-- wallet/tx_reader_test.go | 4 ++-- 6 files changed, 50 insertions(+), 35 deletions(-) diff --git a/pkg/unit/rates.go b/pkg/unit/rates.go index 650aa329a1..716178d1f2 100644 --- a/pkg/unit/rates.go +++ b/pkg/unit/rates.go @@ -32,12 +32,12 @@ type SatPerVByte struct { // NewSatPerVByte creates a new fee rate in sat/vb. The given fee and vbytes // are used to calculate the fee rate. func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { - if vb == 0 { + if vb.val == 0 { return SatPerVByte{big.NewRat(0, 1)} } return SatPerVByte{ - big.NewRat(int64(fee), safeUint64ToInt64(uint64(vb))), + big.NewRat(int64(fee), safeUint64ToInt64(vb.val)), } } @@ -98,14 +98,14 @@ type SatPerKVByte struct { // NewSatPerKVByte creates a new fee rate in sat/kvb. The given fee and kvbytes // are used to calculate the fee rate. func NewSatPerKVByte(fee btcutil.Amount, kvb VByte) SatPerKVByte { - if kvb == 0 { + if kvb.val == 0 { return SatPerKVByte{big.NewRat(0, 1)} } return SatPerKVByte{ big.NewRat( int64(fee)*SatsPerKilo, - safeUint64ToInt64(uint64(kvb)), + safeUint64ToInt64(kvb.val), ), } } @@ -115,7 +115,7 @@ func NewSatPerKVByte(fee btcutil.Amount, kvb VByte) SatPerKVByte { func (s SatPerKVByte) FeeForVSize(vbytes VByte) btcutil.Amount { fee := new(big.Rat).Mul( s.Rat, - big.NewRat(safeUint64ToInt64(uint64(vbytes)), SatsPerKilo), + big.NewRat(safeUint64ToInt64(vbytes.val), SatsPerKilo), ) return roundToAmount(fee) @@ -170,14 +170,14 @@ type SatPerKWeight struct { // NewSatPerKWeight creates a new fee rate in sat/kw. The given fee and weight // are used to calculate the fee rate. func NewSatPerKWeight(fee btcutil.Amount, wu WeightUnit) SatPerKWeight { - if wu == 0 { + if wu.val == 0 { return SatPerKWeight{big.NewRat(0, 1)} } return SatPerKWeight{ big.NewRat( int64(fee)*SatsPerKilo, - safeUint64ToInt64(uint64(wu)), + safeUint64ToInt64(wu.val), ), } } @@ -187,7 +187,7 @@ func NewSatPerKWeight(fee btcutil.Amount, wu WeightUnit) SatPerKWeight { func (s SatPerKWeight) FeeForWeight(wu WeightUnit) btcutil.Amount { // The resulting fee is rounded down, as specified in BOLT#03. fee := new(big.Rat).Mul( - s.Rat, big.NewRat(safeUint64ToInt64(uint64(wu)), SatsPerKilo), + s.Rat, big.NewRat(safeUint64ToInt64(wu.val), SatsPerKilo), ) return btcutil.Amount(new(big.Int).Div(fee.Num(), fee.Denom()).Int64()) @@ -204,7 +204,7 @@ func (s SatPerKWeight) FeeForWeightRoundUp( // This ensures that any fractional part of the fee is rounded up to // the next whole satoshi. feeRat := new(big.Rat).Mul( - s.Rat, big.NewRat(safeUint64ToInt64(uint64(wu)), SatsPerKilo), + s.Rat, big.NewRat(safeUint64ToInt64(wu.val), SatsPerKilo), ) num := feeRat.Num() diff --git a/pkg/unit/rates_test.go b/pkg/unit/rates_test.go index 92242d3b8d..0a5e9a3a1f 100644 --- a/pkg/unit/rates_test.go +++ b/pkg/unit/rates_test.go @@ -150,7 +150,7 @@ func TestFeeForWeightRoundUp(t *testing.T) { t.Parallel() feeRate := SatPerVByte{big.NewRat(1, 1)}.FeePerKWeight() - txWeight := WeightUnit(674) // 674 weight units is 168.5 vb. + txWeight := NewWeightUnit(674) // 674 weight units is 168.5 vb. require.EqualValues(t, 168, feeRate.FeeForWeight(txWeight)) require.EqualValues(t, 169, feeRate.FeeForWeightRoundUp(txWeight)) @@ -163,21 +163,21 @@ func TestNewFeeRateConstructors(t *testing.T) { // Test NewSatPerKWeight. fee := btcutil.Amount(1000) - wu := WeightUnit(1000) + wu := NewWeightUnit(1000) expectedRate := SatPerKWeight{big.NewRat(1000, 1)} require.Zero( t, expectedRate.Cmp(NewSatPerKWeight(fee, wu).Rat), ) // Test NewSatPerVByte. - vb := VByte(250) + vb := NewVByte(250) expectedRateVB := SatPerVByte{big.NewRat(4, 1)} require.Zero( t, expectedRateVB.Cmp(NewSatPerVByte(fee, vb).Rat), ) // Test NewSatPerKVByte. - kvb := VByte(1) + kvb := NewVByte(1) expectedRateKVB := SatPerKVByte{big.NewRat(1000000, 1)} require.Zero( t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat), @@ -278,10 +278,10 @@ func TestFeeForSize(t *testing.T) { r2 := SatPerKWeight{big.NewRat(250, 1)} // Test FeeForVSize. - require.Equal(t, btcutil.Amount(250), r1.FeeForVSize(250)) + require.Equal(t, btcutil.Amount(250), r1.FeeForVSize(NewVByte(250))) // Test FeeForVByte. - require.Equal(t, btcutil.Amount(250), r2.FeeForVByte(250)) + require.Equal(t, btcutil.Amount(250), r2.FeeForVByte(NewVByte(250))) } // TestNewFeeRateConstructorsZero tests the New* fee rate constructors with @@ -291,21 +291,21 @@ func TestNewFeeRateConstructorsZero(t *testing.T) { // Test NewSatPerKWeight with zero weight. fee := btcutil.Amount(1000) - wu := WeightUnit(0) + wu := NewWeightUnit(0) expectedRate := SatPerKWeight{big.NewRat(0, 1)} require.Zero( t, expectedRate.Cmp(NewSatPerKWeight(fee, wu).Rat), ) // Test NewSatPerVByte with zero vbytes. - vb := VByte(0) + vb := NewVByte(0) expectedRateVB := SatPerVByte{big.NewRat(0, 1)} require.Zero( t, expectedRateVB.Cmp(NewSatPerVByte(fee, vb).Rat), ) // Test NewSatPerKVByte with zero kvbytes. - kvb := VByte(0) + kvb := NewVByte(0) expectedRateKVB := SatPerKVByte{big.NewRat(0, 1)} require.Zero( t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat), @@ -318,7 +318,7 @@ func TestSafeUint64ToInt64Overflow(t *testing.T) { t.Parallel() fee := btcutil.Amount(1) - overflowVByte := VByte(math.MaxInt64 + 1) + overflowVByte := NewVByte(math.MaxInt64 + 1) // Test NewSatPerVByte with an overflowing vbyte value. // The denominator should be capped at math.MaxInt64. @@ -332,7 +332,7 @@ func TestSafeUint64ToInt64Overflow(t *testing.T) { require.Zero(t, expectedDenom.Cmp(rateKVB.Denom())) // Test NewSatPerKWeight with an overflowing weight unit value. - overflowWU := WeightUnit(math.MaxInt64 + 1) + overflowWU := NewWeightUnit(math.MaxInt64 + 1) rateKW := NewSatPerKWeight(fee, overflowWU) require.Zero(t, expectedDenom.Cmp(rateKW.Denom())) } diff --git a/pkg/unit/txsize.go b/pkg/unit/txsize.go index 0db3834d0e..19bd810f32 100644 --- a/pkg/unit/txsize.go +++ b/pkg/unit/txsize.go @@ -14,31 +14,46 @@ import ( // data. // - Total tx size is the transaction size in bytes serialized according // #BIP144. -type WeightUnit uint64 +type WeightUnit struct { + val uint64 +} + +// NewWeightUnit creates a new WeightUnit from a uint64. +func NewWeightUnit(val uint64) WeightUnit { + return WeightUnit{val: val} +} // ToVB converts a value expressed in weight units to virtual bytes. func (wu WeightUnit) ToVB() VByte { // According to BIP141: Virtual transaction size is defined as // Transaction weight / 4 (rounded up to the next integer). - return VByte(math.Ceil(float64(wu) / blockchain.WitnessScaleFactor)) + vbytes := math.Ceil(float64(wu.val) / blockchain.WitnessScaleFactor) + return VByte{val: uint64(vbytes)} } // String returns the string representation of the weight unit. func (wu WeightUnit) String() string { - return fmt.Sprintf("%d wu", wu) + return fmt.Sprintf("%d wu", wu.val) } // VByte defines a unit to express the transaction size. One virtual byte is // 1/4th of a weight unit. The tx virtual bytes is calculated using `TxWeight / // 4`. -type VByte uint64 +type VByte struct { + val uint64 +} + +// NewVByte creates a new VByte from a uint64. +func NewVByte(val uint64) VByte { + return VByte{val: val} +} // ToWU converts a value expressed in virtual bytes to weight units. func (vb VByte) ToWU() WeightUnit { - return WeightUnit(vb * blockchain.WitnessScaleFactor) + return WeightUnit{val: vb.val * blockchain.WitnessScaleFactor} } // String returns the string representation of the virtual byte. func (vb VByte) String() string { - return fmt.Sprintf("%d vb", vb) + return fmt.Sprintf("%d vb", vb.val) } diff --git a/pkg/unit/txsize_test.go b/pkg/unit/txsize_test.go index 9c2486062c..a1cf8fcc73 100644 --- a/pkg/unit/txsize_test.go +++ b/pkg/unit/txsize_test.go @@ -12,13 +12,13 @@ func TestTxSizeConversion(t *testing.T) { t.Parallel() // Create a test weight of 1000 wu. - wu := WeightUnit(1000) + wu := NewWeightUnit(1000) // 1000 wu should be equal to 250 vb. - require.Equal(t, VByte(250), wu.ToVB()) + require.Equal(t, NewVByte(250), wu.ToVB()) // 250 vb should be equal to 1000 wu. - require.Equal(t, wu, VByte(250).ToWU()) + require.Equal(t, wu, NewVByte(250).ToWU()) } // TestTxSizeStringer tests the stringer methods of the tx size types. @@ -26,8 +26,8 @@ func TestTxSizeStringer(t *testing.T) { t.Parallel() // Create a test weight of 1000 wu. - wu := WeightUnit(1000) - vb := VByte(250) + wu := NewWeightUnit(1000) + vb := NewVByte(250) // Test String. require.Equal(t, "1000 wu", wu.String()) diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go index 268cf75cdd..d2f29c0bbf 100644 --- a/wallet/tx_reader.go +++ b/wallet/tx_reader.go @@ -415,10 +415,10 @@ func (w *Wallet) populatePrevOuts(details *TxDetail, // value is non-negative. func safeInt64ToWeightUnit(w int64) unit.WeightUnit { if w < 0 { - return 0 + return unit.NewWeightUnit(0) } - return unit.WeightUnit(w) + return unit.NewWeightUnit(uint64(w)) } // safeIntToUint32 converts an int to a uint32, returning false if the diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go index 0a154b88ec..9ae7a9a08a 100644 --- a/wallet/tx_reader_test.go +++ b/wallet/tx_reader_test.go @@ -272,9 +272,9 @@ func createUnminedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) { } // Define the expected TxDetail for the unmined case. - weight := unit.WeightUnit(blockchain.GetTransactionWeight( + weight := unit.NewWeightUnit(uint64(blockchain.GetTransactionWeight( btcutil.NewTx(&rec.MsgTx), - )) + ))) unminedTxDetail := &TxDetail{ Hash: *TstTxHash, RawTx: TstSerializedTx, From 10065127bc57c1730141fb18a627278704e6953c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 12 Nov 2025 20:05:33 +0800 Subject: [PATCH 131/691] refactor: rename unit package to btcunit --- pkg/{unit => btcunit}/README.md | 2 +- pkg/{unit => btcunit}/rates.go | 4 ++-- pkg/{unit => btcunit}/rates_test.go | 2 +- pkg/{unit => btcunit}/txsize.go | 2 +- pkg/{unit => btcunit}/txsize_test.go | 2 +- wallet/tx_reader.go | 16 ++++++++-------- wallet/tx_reader_test.go | 10 ++++++---- 7 files changed, 20 insertions(+), 18 deletions(-) rename pkg/{unit => btcunit}/README.md (98%) rename pkg/{unit => btcunit}/rates.go (98%) rename pkg/{unit => btcunit}/rates_test.go (99%) rename pkg/{unit => btcunit}/txsize.go (99%) rename pkg/{unit => btcunit}/txsize_test.go (97%) diff --git a/pkg/unit/README.md b/pkg/btcunit/README.md similarity index 98% rename from pkg/unit/README.md rename to pkg/btcunit/README.md index 3c35f4b585..ed4abef300 100644 --- a/pkg/unit/README.md +++ b/pkg/btcunit/README.md @@ -1,4 +1,4 @@ -# btcwallet/unit +# btcwallet/btcunit This package provides a set of idiomatic, type-safe units for handling common Bitcoin quantities like transaction sizes, weights, and fee rates. diff --git a/pkg/unit/rates.go b/pkg/btcunit/rates.go similarity index 98% rename from pkg/unit/rates.go rename to pkg/btcunit/rates.go index 716178d1f2..97f886a22e 100644 --- a/pkg/unit/rates.go +++ b/pkg/btcunit/rates.go @@ -2,8 +2,8 @@ // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. -// Package unit provides a set of types for dealing with bitcoin units. -package unit +// Package btcunit provides a set of types for dealing with bitcoin units. +package btcunit import ( "log/slog" diff --git a/pkg/unit/rates_test.go b/pkg/btcunit/rates_test.go similarity index 99% rename from pkg/unit/rates_test.go rename to pkg/btcunit/rates_test.go index 0a5e9a3a1f..29cbc967a1 100644 --- a/pkg/unit/rates_test.go +++ b/pkg/btcunit/rates_test.go @@ -1,4 +1,4 @@ -package unit +package btcunit import ( "math" diff --git a/pkg/unit/txsize.go b/pkg/btcunit/txsize.go similarity index 99% rename from pkg/unit/txsize.go rename to pkg/btcunit/txsize.go index 19bd810f32..6910ca4877 100644 --- a/pkg/unit/txsize.go +++ b/pkg/btcunit/txsize.go @@ -1,4 +1,4 @@ -package unit +package btcunit import ( "fmt" diff --git a/pkg/unit/txsize_test.go b/pkg/btcunit/txsize_test.go similarity index 97% rename from pkg/unit/txsize_test.go rename to pkg/btcunit/txsize_test.go index a1cf8fcc73..d86936f594 100644 --- a/pkg/unit/txsize_test.go +++ b/pkg/btcunit/txsize_test.go @@ -1,4 +1,4 @@ -package unit +package btcunit import ( "testing" diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go index d2f29c0bbf..6f394359a5 100644 --- a/wallet/tx_reader.go +++ b/wallet/tx_reader.go @@ -17,7 +17,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/pkg/unit" + "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -114,10 +114,10 @@ type TxDetail struct { // // NOTE: This is only calculated if all inputs are known to the wallet. // Otherwise, it will be zero. - FeeRate unit.SatPerVByte + FeeRate btcunit.SatPerVByte // Weight is the tx's weight. - Weight unit.WeightUnit + Weight btcunit.WeightUnit // Confirmations is the number of confirmations this tx has. // This will be 0 for unconfirmed txns. @@ -283,7 +283,7 @@ func (w *Wallet) buildBasicTxDetail(txDetails *wtxmgr.TxDetails) *TxDetail { Label: txDetails.Label, ReceivedTime: txDetails.Received, Weight: safeInt64ToWeightUnit(txWeight), - FeeRate: unit.SatPerVByte{Rat: big.NewRat(0, 1)}, + FeeRate: btcunit.SatPerVByte{Rat: big.NewRat(0, 1)}, } } @@ -340,7 +340,7 @@ func (w *Wallet) calculateValueAndFee(details *TxDetail, } details.Fee = totalInput - totalOutput - details.FeeRate = unit.NewSatPerVByte( + details.FeeRate = btcunit.NewSatPerVByte( details.Fee, details.Weight.ToVB(), ) } @@ -413,12 +413,12 @@ func (w *Wallet) populatePrevOuts(details *TxDetail, // safeInt64ToWeightUnit converts an int64 to a unit.WeightUnit, ensuring the // value is non-negative. -func safeInt64ToWeightUnit(w int64) unit.WeightUnit { +func safeInt64ToWeightUnit(w int64) btcunit.WeightUnit { if w < 0 { - return unit.NewWeightUnit(0) + return btcunit.NewWeightUnit(0) } - return unit.NewWeightUnit(uint64(w)) + return btcunit.NewWeightUnit(uint64(w)) } // safeIntToUint32 converts an int to a uint32, returning false if the diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go index 9ae7a9a08a..bef5c3d5ed 100644 --- a/wallet/tx_reader_test.go +++ b/wallet/tx_reader_test.go @@ -13,7 +13,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcwallet/pkg/unit" + "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/mock" @@ -30,7 +30,9 @@ func TestBuildTxDetail(t *testing.T) { unminedNoFeeDetails, unminedNoFeeTxDetail := createUnminedTxDetail(t) unminedNoFeeDetails.Debits = nil unminedNoFeeTxDetail.Fee = 0 - unminedNoFeeTxDetail.FeeRate = unit.SatPerVByte{Rat: big.NewRat(0, 1)} + unminedNoFeeTxDetail.FeeRate = btcunit.SatPerVByte{ + Rat: big.NewRat(0, 1), + } unminedNoFeeTxDetail.Value = unminedNoFeeDetails.Credits[0].Amount + unminedNoFeeDetails.Credits[1].Amount unminedNoFeeTxDetail.PrevOuts[0].IsOurs = false @@ -272,7 +274,7 @@ func createUnminedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) { } // Define the expected TxDetail for the unmined case. - weight := unit.NewWeightUnit(uint64(blockchain.GetTransactionWeight( + weight := btcunit.NewWeightUnit(uint64(blockchain.GetTransactionWeight( btcutil.NewTx(&rec.MsgTx), ))) unminedTxDetail := &TxDetail{ @@ -281,7 +283,7 @@ func createUnminedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) { Label: testLabel, Value: totalCredits - debitAmt, Fee: fee, - FeeRate: unit.NewSatPerVByte(fee, weight.ToVB()), + FeeRate: btcunit.NewSatPerVByte(fee, weight.ToVB()), Confirmations: 0, Weight: weight, ReceivedTime: txTime, From 560acade3302d0b0e175b66d91516800a606463f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 12 Nov 2025 20:43:17 +0800 Subject: [PATCH 132/691] wtxmgr: cache `SerializedTx` in `readRawTxRecord` --- wtxmgr/db.go | 4 ++++ wtxmgr/query_test.go | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/wtxmgr/db.go b/wtxmgr/db.go index c7e3619162..6a7533d289 100644 --- a/wtxmgr/db.go +++ b/wtxmgr/db.go @@ -434,6 +434,10 @@ func readRawTxRecord(txHash *chainhash.Hash, v []byte, rec *TxRecord) error { bucketTxRecords, txHash) return storeError(ErrData, str, err) } + + // Cache the raw bytes when the above deserialization succeeded. + rec.SerializedTx = v[8:] + return nil } diff --git a/wtxmgr/query_test.go b/wtxmgr/query_test.go index 367d57d423..fc888cceb0 100644 --- a/wtxmgr/query_test.go +++ b/wtxmgr/query_test.go @@ -277,11 +277,12 @@ func TestStoreQueries(t *testing.T) { newState.blocks = [][]TxDetails{ { { - TxRecord: *stripSerializedTx(recA), + TxRecord: *recA, Block: BlockMeta{Block: Block{Height: -1}}, }, }, } + newState.txDetails[recA.Hash] = []TxDetails{ newState.blocks[0][0], } @@ -324,7 +325,7 @@ func TestStoreQueries(t *testing.T) { newState = lastState.deepCopy() newState.blocks[0][0].Credits[0].Spent = true newState.blocks[0] = append(newState.blocks[0], TxDetails{ - TxRecord: *stripSerializedTx(recB), + TxRecord: *recB, Block: BlockMeta{Block: Block{Height: -1}}, Debits: []DebitRecord{ { From 7302e83f281c714b932224898bd651de3b620928 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 18 Nov 2025 02:42:28 +0800 Subject: [PATCH 133/691] waddrmgr: fix data race when accessing the ScopedKeyManager --- waddrmgr/manager.go | 9 ++++----- waddrmgr/scoped_manager.go | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/waddrmgr/manager.go b/waddrmgr/manager.go index b5468647d5..1343fb8d2f 100644 --- a/waddrmgr/manager.go +++ b/waddrmgr/manager.go @@ -431,17 +431,16 @@ func (m *Manager) IsWatchOnlyAccount(ns walletdb.ReadBucket, keyScope KeyScope, func (m *Manager) lock() { for _, manager := range m.scopedManagers { // Clear all of the account private keys. - for _, acctInfo := range manager.acctInfo { + for _, acctInfo := range manager.accountInfo() { if acctInfo.acctKeyPriv != nil { acctInfo.acctKeyPriv.Zero() } acctInfo.acctKeyPriv = nil } - } - // Remove clear text private keys and scripts from all address entries. - for _, manager := range m.scopedManagers { - for _, ma := range manager.addrs { + // Remove clear text private keys and scripts from all address + // entries. + for _, ma := range manager.addresses() { switch addr := ma.(type) { case *managedAddress: addr.lock() diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 152e95cc42..ed3fd69740 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/binary" "fmt" + "maps" "sync" "github.com/btcsuite/btcd/btcec/v2" @@ -2654,3 +2655,27 @@ func (s *ScopedKeyManager) NewAddress(addrmgrNs walletdb.ReadWriteBucket, return addr, nil } + +// accountInfo returns a copy of the account info map. +func (s *ScopedKeyManager) accountInfo() map[uint32]*accountInfo { + s.mtx.RLock() + defer s.mtx.RUnlock() + + acctInfoCopy := make(map[uint32]*accountInfo, len(s.acctInfo)) + maps.Copy(acctInfoCopy, s.acctInfo) + + return acctInfoCopy +} + +// addresses returns a slice of all managed addresses. +func (s *ScopedKeyManager) addresses() []ManagedAddress { + s.mtx.RLock() + defer s.mtx.RUnlock() + + addrs := make([]ManagedAddress, 0, len(s.addrs)) + for _, ma := range s.addrs { + addrs = append(addrs, ma) + } + + return addrs +} From 7067cb3ebff9e3428bc0d74ddc050340e63c004f Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 17 Nov 2025 06:13:32 +0000 Subject: [PATCH 134/691] wallet: include totalizer for confirmed and unconfirmed txs in benchmarking --- wallet/benchmark_helpers_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 2c01b447f7..ab75c5d89d 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -135,6 +135,10 @@ type benchmarkWallet struct { // during benchmark setup. These are spending transactions with both // debits (inputs) and credits (outputs) that are in the mempool. unconfirmedTxs []*wire.MsgTx + + // allTxs contains all wallet transactions (both confirmed and + // unconfirmed) combined. + allTxs []*wire.MsgTx } // setupBenchmarkWallet creates a wallet with test data based on the provided @@ -171,10 +175,14 @@ func setupBenchmarkWallet(tb testing.TB, } } + allTxs := append([]*wire.MsgTx{}, txsResult.confirmed...) + allTxs = append(allTxs, txsResult.unconfirmed...) + return &benchmarkWallet{ Wallet: w, confirmedTxs: txsResult.confirmed, unconfirmedTxs: txsResult.unconfirmed, + allTxs: allTxs, } } From b3bd2aa734faa58ebf21115c8a01b69e0226b239 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 17 Nov 2025 07:41:12 +0000 Subject: [PATCH 135/691] wallet: introduce tx patterns to benchmarking wallet --- wallet/benchmark_helpers_test.go | 150 ++++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 2 deletions(-) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index ab75c5d89d..bb7d91c4e5 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -3,6 +3,7 @@ package wallet import ( "errors" "fmt" + "math/rand" "strconv" "testing" "time" @@ -80,6 +81,140 @@ func exponentialGrowth(i int) int { return 1 << i } +// InterleavePattern defines how to interleave two slices of any type. +type InterleavePattern int + +const ( + + // Alternating pattern interleaves elements one-by-one from each slice. + // + // Use when: Testing iteration logic that needs to handle mixed element + // types or simulating varied element ordering. + // + // Pattern: A B A B A B ... + // + // Example: Testing APIs that filter or group elements by type during + // iteration. + Alternating InterleavePattern = iota + + // Sequential pattern concatenates all elements from first slice + // followed by all elements from second slice. + // + // Use when: Testing best/worst case scenarios where elements are + // perfectly sorted by type, or when element order doesn't affect the + // algorithm being tested. + // + // Pattern: A ... B ... + Sequential + + // Grouped pattern interleaves elements in batches of specified size. + // + // Use when: Testing batch processing scenarios or simulating elements + // that arrive in groups. + // + // Pattern (groupSize=2): A B A B ... + // + // Example: Testing batch transaction processing or cache locality + // effects. + Grouped + + // Random pattern shuffles elements from both slices randomly. + // + // Use when: Testing performance with unpredictable ordering or + // verifying that algorithms handle arbitrary element arrangements. + // + // Pattern: B A B A ... + // + // Note: Uses simple pseudo-random shuffle, not cryptographically + // secure. + Random +) + +// Interleave combines two slices according to the specified pattern. +func Interleave[T any](pattern InterleavePattern, groupSize int, a, b []T) []T { + switch pattern { + case Sequential: + return sequentialInterleave(a, b) + case Alternating: + return alternatingInterleave(a, b) + case Grouped: + return groupedInterleave(groupSize, a, b) + case Random: + return randomInterleave(a, b) + default: + return alternatingInterleave(a, b) + } +} + +// sequentialInterleave concatenates all elements from a followed by all +// elements from b. +func sequentialInterleave[T any](a, b []T) []T { + result := make([]T, 0, len(a)+len(b)) + result = append(result, a...) + result = append(result, b...) + + return result +} + +// alternatingInterleave interleaves elements one-by-one from a and b. +func alternatingInterleave[T any](a, b []T) []T { + result := make([]T, 0, len(a)+len(b)) + aIdx, bIdx := 0, 0 + + for aIdx < len(a) || bIdx < len(b) { + if aIdx < len(a) { + result = append(result, a[aIdx]) + aIdx++ + } + + if bIdx < len(b) { + result = append(result, b[bIdx]) + bIdx++ + } + } + + return result +} + +// groupedInterleave interleaves elements in batches of groupSize from a and b. +func groupedInterleave[T any](groupSize int, a, b []T) []T { + if groupSize <= 0 { + groupSize = 1 + } + + result := make([]T, 0, len(a)+len(b)) + aIdx, bIdx := 0, 0 + + for aIdx < len(a) || bIdx < len(b) { + // Add batch from a. + for i := 0; i < groupSize && aIdx < len(a); i++ { + result = append(result, a[aIdx]) + aIdx++ + } + + // Add batch from b. + for i := 0; i < groupSize && bIdx < len(b); i++ { + result = append(result, b[bIdx]) + bIdx++ + } + } + + return result +} + +// randomInterleave combines elements from a and b in pseudo-random order. +func randomInterleave[T any](a, b []T) []T { + result := make([]T, 0, len(a)+len(b)) + result = append(result, a...) + result = append(result, b...) + + rand.Shuffle(len(result), func(i, j int) { + result[i], result[j] = result[j], result[i] + }) + + return result +} + // mapRange maps fn over indices [start..end] (inclusive) and returns the // results. This provides functional-style array generation for benchmarks. // @@ -120,6 +255,10 @@ type benchmarkWalletConfig struct { // numTxOutputs is the number of outputs per transaction. If 0, // defaults to 1 output per transaction. numTxOutputs int + + // txInterleavePattern specifies the interleaving pattern for organizing + // transactions with different states (confirmed vs unconfirmed). + txInterleavePattern InterleavePattern } // benchmarkWallet holds a wallet and its created wallet transactions. @@ -175,8 +314,15 @@ func setupBenchmarkWallet(tb testing.TB, } } - allTxs := append([]*wire.MsgTx{}, txsResult.confirmed...) - allTxs = append(allTxs, txsResult.unconfirmed...) + // Combine confirmed and unconfirmed transactions using the specified + // pattern. This ensures diverse transaction states in allTxs even with + // small dataset sizes (e.g., when using constantGrowth which defaults + // to 5 elements), allowing benchmarks to test iteration logic that + // handles mixed confirmation states. + allTxs := Interleave( + cfg.txInterleavePattern, 0, txsResult.confirmed, + txsResult.unconfirmed, + ) return &benchmarkWallet{ Wallet: w, From e7596d8a8693aac303a22228db9cf0492bc757ea Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 17 Nov 2025 06:38:19 +0000 Subject: [PATCH 136/691] wallet: benchmark `GetTx` API --- wallet/tx_reader_benchmark_test.go | 193 +++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 wallet/tx_reader_benchmark_test.go diff --git a/wallet/tx_reader_benchmark_test.go b/wallet/tx_reader_benchmark_test.go new file mode 100644 index 0000000000..286339bc5c --- /dev/null +++ b/wallet/tx_reader_benchmark_test.go @@ -0,0 +1,193 @@ +package wallet + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// BenchmarkGetTxAPI benchmarks GetTx API and its deprecated variant +// GetTransaction using identical test data across transactions with varying +// complexity (input/output counts). Test names start with transaction +// complexity to group API comparisons for benchstat analysis. +// +// Time Complexity Analysis: +// GetTx has no amortization - it's a read operation with consistent upper/tight +// bound cost every time. The time complexity is O(log n + I + O) where: +// - n: number of transactions in the database (B-tree lookup) +// - I: number of inputs in the transaction +// - O: number of outputs in the transaction +func BenchmarkGetTxAPI(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // endGrowthIteration is the maximum iteration index for the + // growth sequence. + endGrowthIteration = 5 + ) + + var ( + // accountGrowth uses constantGrowth since account count doesn't + // affect the API's time complexity. + accountGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // affect the API's time complexity. + addressGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // txPoolGrowth uses linearGrowth to test O(log n) B-tree lookup + // scaling. As database size grows linearly, lookup time should + // grow logarithmically, demonstrating sublinear scaling. + txPoolGrowth = mapRange( + startGrowthIteration, endGrowthIteration, linearGrowth, + ) + + // txIOGrowth uses symmetric linearGrowth for both inputs + // and outputs to stress test the O(I + O) processing cost with + // rapidly growing transaction complexity, exposing potential + // performance bottlenecks in input/output iteration and address + // extraction. + txIOGrowth = mapRange( + startGrowthIteration, endGrowthIteration, linearGrowth, + ) + + txPoolGrowthPadding = decimalWidth( + txPoolGrowth[len(txPoolGrowth)-1], + ) + + txIOGrowthPadding = decimalWidth( + txIOGrowth[len(txIOGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= endGrowthIteration; i++ { + name := fmt.Sprintf("TxPool-%0*d-Ins-%0*d-Outs-%0*d", + txPoolGrowthPadding, txPoolGrowth[i], txIOGrowthPadding, + txIOGrowth[i], txIOGrowthPadding, txIOGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: txPoolGrowth[i], + numTxInputs: txIOGrowth[i], + numTxOutputs: txIOGrowth[i], + }, + ) + + // Get a transaction hash from the middle of the dataset + // for representative benchmarking. + medianIndex := len(bw.allTxs) / 2 + testTxHash := bw.allTxs[medianIndex].TxHash() + + var ( + beforeResult *GetTransactionResult + afterResult *TxDetail + ) + + b.Run("0-Before", func(b *testing.B) { + var ( + result *GetTransactionResult + baselineResult *GetTransactionResult + err error + ) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; b.Loop(); i++ { + result, err = bw.GetTransaction( + testTxHash, + ) + require.NoError(b, err) + + // Capture first result only. + if i == 0 { + baselineResult = result + } + } + + require.Equal( + b, baselineResult, result, + "GetTransaction API should be "+ + "idempotent", + ) + + beforeResult = result + }) + + b.Run("1-After", func(b *testing.B) { + var ( + result *TxDetail + baselineResult *TxDetail + err error + ) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; b.Loop(); i++ { + result, err = bw.GetTx( + b.Context(), testTxHash, + ) + require.NoError(b, err) + + // Capture first baseline result only. + if i == 0 { + baselineResult = result + } + } + + require.Equal( + b, baselineResult, result, + "GetTx API should be idempotent", + ) + + afterResult = result + }) + + // Verify API equivalence after benchmarks complete. + // This ensures: + // - Both APIs return consistent results for the same + // transaction + // - The new API maintains compatibility with the + // legacy API + // - Regression prevention for future changes + assertGetTxAPIsEquivalent( + b, bw.Wallet, beforeResult, afterResult, + ) + }) + } +} + +// assertGetTxAPIsEquivalent verifies that GetTransaction (legacy) and GetTx +// (new) return equivalent data for the same transaction. +func assertGetTxAPIsEquivalent(b *testing.B, w *Wallet, + before *GetTransactionResult, after *TxDetail) { + + b.Helper() + + require.NotNil(b, before) + require.NotNil(b, after) + + afterConverted, err := w.GetTransaction(after.Hash) + require.NoError(b, err) + + require.GreaterOrEqual(b, afterConverted.Confirmations, int32(0)) + + require.Equal(b, before, afterConverted) +} From 99d2fabe6b3ebcb3389fb9b4aaae69ffb321626c Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 17 Nov 2025 06:54:45 +0000 Subject: [PATCH 137/691] wallet: benchmark `GetTx` API concurrently --- wallet/tx_reader_benchmark_test.go | 133 +++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/wallet/tx_reader_benchmark_test.go b/wallet/tx_reader_benchmark_test.go index 286339bc5c..7db865950c 100644 --- a/wallet/tx_reader_benchmark_test.go +++ b/wallet/tx_reader_benchmark_test.go @@ -174,6 +174,139 @@ func BenchmarkGetTxAPI(b *testing.B) { } } +// BenchmarkGetTxAPIConcurrently benchmarks GetTx API and its deprecated +// variant GetTransaction using identical test data under concurrent load. +// Test names start with transaction pool size to group API comparisons for +// benchstat analysis. +// +// Time Complexity Analysis: +// Under concurrent load, the API maintains the same per-transaction complexity +// of O(log n + I + O) as the sequential benchmark, where: +// - n: number of transactions in the database (B-tree lookup) +// - I: number of inputs in the transaction +// - O: number of outputs in the transaction +// +// This benchmark stresses the lock contention characteristics during database +// reads, demonstrating scalability under concurrent read operations. +func BenchmarkGetTxAPIConcurrently(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // endGrowthIteration is the maximum iteration index for the + // growth sequence. + endGrowthIteration = 5 + ) + + var ( + // accountGrowth uses constantGrowth since account count doesn't + // affect the API's time complexity. + accountGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // affect the API's time complexity. + addressGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // txPoolGrowth uses linearGrowth to test O(log n) B-tree lookup + // scaling. As database size grows linearly, lookup time should + // grow logarithmically, demonstrating sublinear scaling. + txPoolGrowth = mapRange( + startGrowthIteration, endGrowthIteration, linearGrowth, + ) + + // txIOGrowth uses symmetric linearGrowth for both inputs + // and outputs to stress test the O(I + O) processing cost with + // rapidly growing transaction complexity, exposing potential + // performance bottlenecks in input/output iteration and address + // extraction. + txIOGrowth = mapRange( + startGrowthIteration, endGrowthIteration, linearGrowth, + ) + + txPoolGrowthPadding = decimalWidth( + txPoolGrowth[len(txPoolGrowth)-1], + ) + + txIOGrowthPadding = decimalWidth( + txIOGrowth[len(txIOGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= endGrowthIteration; i++ { + name := fmt.Sprintf("TxPool-%0*d-Ins-%0*d-Outs-%0*d", + txPoolGrowthPadding, txPoolGrowth[i], txIOGrowthPadding, + txIOGrowth[i], txIOGrowthPadding, txIOGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: txPoolGrowth[i], + numTxInputs: txIOGrowth[i], + numTxOutputs: txIOGrowth[i], + }, + ) + + // Get a transaction hash from the middle of the dataset + // for representative benchmarking. + medianIndex := len(bw.allTxs) / 2 + testTxHash := bw.allTxs[medianIndex].TxHash() + + var ( + before *GetTransactionResult + after *TxDetail + ) + + b.Run("0-Before", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + res, err := bw.GetTransaction( + testTxHash, + ) + before = res + + require.NoError(b, err) + require.NotNil(b, before) + } + }) + }) + + b.Run("1-After", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + res, err := bw.GetTx( + b.Context(), testTxHash, + ) + after = res + + require.NoError(b, err) + require.NotNil(b, after) + } + }) + }) + + assertGetTxAPIsEquivalent(b, bw.Wallet, before, after) + }) + } +} + // assertGetTxAPIsEquivalent verifies that GetTransaction (legacy) and GetTx // (new) return equivalent data for the same transaction. func assertGetTxAPIsEquivalent(b *testing.B, w *Wallet, From 7371a35fc7dbec4e6720e6b32f20bfdb913adc86 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 17 Nov 2025 08:10:09 +0000 Subject: [PATCH 138/691] wallet: benchmark `ListTxns` API --- wallet/tx_reader_benchmark_test.go | 214 +++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/wallet/tx_reader_benchmark_test.go b/wallet/tx_reader_benchmark_test.go index 7db865950c..0f50d64083 100644 --- a/wallet/tx_reader_benchmark_test.go +++ b/wallet/tx_reader_benchmark_test.go @@ -307,6 +307,181 @@ func BenchmarkGetTxAPIConcurrently(b *testing.B) { } } +// BenchmarkListTxnsAPI benchmarks ListTxns API and its deprecated variant +// GetTransactions using identical test data across varying block ranges and +// transaction densities. Test names start with complexity metrics to group API +// comparisons for benchstat analysis. +// +// Time Complexity Analysis: +// ListTxns has no amortization - it's a read operation with consistent +// upper/tight bound cost. The time complexity is O(B * T * (I + O)) where: +// - B: number of blocks in the range [startHeight, endHeight] +// - T: average transactions per block +// - I: average inputs per transaction +// - O: average outputs per transaction +// +// This simplifies to O(N) where N = total inputs + outputs across all +// transactions in the block range. +func BenchmarkListTxnsAPI(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // endGrowthIteration is the maximum iteration index for the + // growth sequence. + endGrowthIteration = 10 + ) + + var ( + // accountGrowth uses constantGrowth since account count doesn't + // affect the API's time complexity. + accountGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // affect the API's time complexity. + addressGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // txPoolGrowth uses exponentialGrowth to stress test the + // O(B * T) component - total transactions across blocks. + txPoolGrowth = mapRange( + startGrowthIteration, endGrowthIteration, linearGrowth, + ) + + // txIOGrowth uses exponentialGrowth for both inputs and outputs + // to stress test the O(I + O) per-transaction processing cost + // with rapidly growing transaction complexity. + txIOGrowth = mapRange( + startGrowthIteration, endGrowthIteration, linearGrowth, + ) + + txPoolGrowthPadding = decimalWidth( + txPoolGrowth[len(txPoolGrowth)-1], + ) + + txIOGrowthPadding = decimalWidth( + txIOGrowth[len(txIOGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= endGrowthIteration; i++ { + name := fmt.Sprintf("TxPool-%0*d-Ins-%0*d-Outs-%0*d", + txPoolGrowthPadding, txPoolGrowth[i], txIOGrowthPadding, + txIOGrowth[i], txIOGrowthPadding, txIOGrowth[i]) + + b.Run(name, func(b *testing.B) { + // Setup wallet once for both API benchmarks. + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: txPoolGrowth[i], + numTxInputs: txIOGrowth[i], + numTxOutputs: txIOGrowth[i], + }, + ) + + // List all transactions (no height filter). + // For GetTransactions (old): nil, nil means all blocks + // For ListTxns (new): 0, -1 means all blocks + // (0=genesis, -1=unlimited). + var ( + startBlock *BlockIdentifier + endBlock *BlockIdentifier + startHeight int32 = 0 + endHeight int32 = -1 + ) + + var ( + beforeResult *GetTransactionsResult + afterResult []*TxDetail + ) + + b.Run("0-Before", func(b *testing.B) { + var ( + result *GetTransactionsResult + firstResult *GetTransactionsResult + err error + ) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; b.Loop(); i++ { + result, err = bw.GetTransactions( + startBlock, endBlock, "", nil, + ) + require.NoError(b, err) + + // Capture first result only. + if i == 0 { + firstResult = result + } + } + + require.Equal( + b, firstResult, result, + "GetTransactions API should be "+ + "idempotent", + ) + + beforeResult = result + }) + + b.Run("1-After", func(b *testing.B) { + var ( + result []*TxDetail + firstResult []*TxDetail + err error + ) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; b.Loop(); i++ { + result, err = bw.ListTxns( + b.Context(), startHeight, + endHeight, + ) + require.NoError(b, err) + + // Capture first result only. + if i == 0 { + firstResult = result + } + } + + require.Equal( + b, firstResult, result, + "ListTxns API should be idempotent ", + ) + + afterResult = result + }) + + // Verify API equivalence after benchmarks complete. + // This ensures: + // - Both APIs return consistent results for the same + // block range + // - The new API maintains compatibility with the + // legacy API + // - Regression prevention for future changes + assertListTxnsAPIsEquivalent( + b, bw.Wallet, beforeResult, afterResult, + ) + }) + } +} + // assertGetTxAPIsEquivalent verifies that GetTransaction (legacy) and GetTx // (new) return equivalent data for the same transaction. func assertGetTxAPIsEquivalent(b *testing.B, w *Wallet, @@ -324,3 +499,42 @@ func assertGetTxAPIsEquivalent(b *testing.B, w *Wallet, require.Equal(b, before, afterConverted) } + +// assertListTxnsAPIsEquivalent verifies that GetTransactions (legacy) and +// ListTxns (new) return equivalent data for the same block range. +func assertListTxnsAPIsEquivalent(b *testing.B, w *Wallet, + before *GetTransactionsResult, after []*TxDetail) { + + b.Helper() + + require.NotNil(b, before) + require.NotNil(b, after) + + // Use GetTransactions API to fetch all transactions (both confirmed + // and unconfirmed) for comparison. Parameters match the benchmark's + // "before" case: + // - startBlock: nil (from genesis) + // - endBlock: nil (to current tip) + // - accountName: "" (all accounts) + // - cancel: nil (no cancellation) + var ( + startBlock *BlockIdentifier + endBlock *BlockIdentifier + accountName string + cancel <-chan struct{} + ) + + afterConverted, err := w.GetTransactions( + startBlock, endBlock, accountName, cancel, + ) + require.NoError(b, err) + + require.NotEmpty(b, before.MinedTransactions) + require.NotEmpty(b, before.UnminedTransactions) + + require.Equal( + b, before, afterConverted, + "GetTransactions and ListTxns APIs should return equivalent "+ + "data", + ) +} From 2cab8ec2e8c6110347c492babb698fc0ecc38d85 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 17 Nov 2025 08:14:32 +0000 Subject: [PATCH 139/691] wallet: benchmark `ListTxns` API concurrently --- wallet/tx_reader_benchmark_test.go | 137 +++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/wallet/tx_reader_benchmark_test.go b/wallet/tx_reader_benchmark_test.go index 0f50d64083..461fea0655 100644 --- a/wallet/tx_reader_benchmark_test.go +++ b/wallet/tx_reader_benchmark_test.go @@ -482,6 +482,143 @@ func BenchmarkListTxnsAPI(b *testing.B) { } } +// BenchmarkListTxnsAPIConcurrently benchmarks ListTxns API and its deprecated +// variant GetTransactions using identical test data under concurrent load. +// Test names start with complexity metrics to group API comparisons for +// benchstat analysis. +// +// Time Complexity Analysis: +// Under concurrent load, the API maintains the same per-request complexity +// of O(B * T * (I + O)) as the sequential benchmark, where: +// - B: number of blocks in the range [startHeight, endHeight] +// - T: average transactions per block +// - I: average inputs per transaction +// - O: average outputs per transaction +// +// This benchmark stresses lock contention characteristics during database +// reads, demonstrating scalability under concurrent read operations. +func BenchmarkListTxnsAPIConcurrently(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // endGrowthIteration is the maximum iteration index for the + // growth sequence. + endGrowthIteration = 5 + ) + + var ( + // accountGrowth uses constantGrowth since account count doesn't + // affect the API's time complexity. + accountGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // affect the API's time complexity. + addressGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // txPoolGrowth uses linearGrowth for CI-friendly execution + // while still testing scaling behavior. + txPoolGrowth = mapRange( + startGrowthIteration, endGrowthIteration, linearGrowth, + ) + + // txIOGrowth uses linearGrowth for CI-friendly execution + // while still testing scaling behavior. + txIOGrowth = mapRange( + startGrowthIteration, endGrowthIteration, linearGrowth, + ) + + txPoolGrowthPadding = decimalWidth( + txPoolGrowth[len(txPoolGrowth)-1], + ) + + txIOGrowthPadding = decimalWidth( + txIOGrowth[len(txIOGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= endGrowthIteration; i++ { + name := fmt.Sprintf("TxPool-%0*d-Ins-%0*d-Outs-%0*d", + txPoolGrowthPadding, txPoolGrowth[i], txIOGrowthPadding, + txIOGrowth[i], txIOGrowthPadding, txIOGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: txPoolGrowth[i], + numTxInputs: txIOGrowth[i], + numTxOutputs: txIOGrowth[i], + }, + ) + + // List all transactions (no height filter). + var ( + startBlock *BlockIdentifier + endBlock *BlockIdentifier + startHeight int32 = 0 + endHeight int32 = -1 + ) + + var ( + beforeResult *GetTransactionsResult + afterResult []*TxDetail + ) + + b.Run("0-Before", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + res, err := bw.GetTransactions( + startBlock, endBlock, + "", nil, + ) + beforeResult = res + + require.NoError(b, err) + require.NotNil(b, res) + } + }) + }) + + b.Run("1-After", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + res, err := bw.ListTxns( + b.Context(), + startHeight, endHeight, + ) + afterResult = res + + require.NoError(b, err) + require.NotNil(b, res) + } + }) + }) + + assertListTxnsAPIsEquivalent( + b, bw.Wallet, beforeResult, afterResult, + ) + }) + } +} + // assertGetTxAPIsEquivalent verifies that GetTransaction (legacy) and GetTx // (new) return equivalent data for the same transaction. func assertGetTxAPIsEquivalent(b *testing.B, w *Wallet, From b433ae563d5b4262100fe86409667d9bda65c09a Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 17 Nov 2025 09:54:02 +0000 Subject: [PATCH 140/691] wallet: benchmark `LabelTx` API --- wallet/tx_writer_benchmark_test.go | 166 +++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 wallet/tx_writer_benchmark_test.go diff --git a/wallet/tx_writer_benchmark_test.go b/wallet/tx_writer_benchmark_test.go new file mode 100644 index 0000000000..6cd8a1c5cf --- /dev/null +++ b/wallet/tx_writer_benchmark_test.go @@ -0,0 +1,166 @@ +package wallet + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/stretchr/testify/require" +) + +// BenchmarkLabelTxAPI benchmarks LabelTx API against its deprecated variant +// LabelTransaction (when overwrite is true) using identical test data. +// Test names use the wallet size metric to group API comparisons for benchstat +// analysis. +// +// Time Complexity Analysis: +// Both APIs are dominated by a single key-value write (PutTxLabel) operation, +// which is typically O(log n) on a B-tree where n is the number of transactions +// (keys). Since the new API eliminates an initial read operation +// (FetchTxLabel), it should show better performance. +func BenchmarkLabelTxAPI(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // endGrowthIteration is the maximum iteration index for the + // growth sequence. + endGrowthIteration = 5 + ) + + var ( + // accountGrowth and addressGrowth use constantGrowth since + // ccount/address count doesn't directly affect the API's time + // complexity for a single tx label. + accountGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // txPoolGrowth uses linearGrowth to test O(log n) B-tree write + // scaling. As database size grows linearly, write time should + // grow logarithmically. + txPoolGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + linearGrowth, + ) + + // txIOGrowth uses constantGrowth since I/O count doesn't affect + // the LabelTx API's time complexity. + txIOGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + txPoolGrowthPadding = decimalWidth( + txPoolGrowth[len(txPoolGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + // testLabel is the string used for labeling the transaction. + testLabel = "bench_label" + ) + + for i := 0; i <= endGrowthIteration; i++ { + name := fmt.Sprintf("TxPool-%0*d", txPoolGrowthPadding, + txPoolGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: txPoolGrowth[i], + numTxInputs: txIOGrowth[i], + numTxOutputs: txIOGrowth[i], + }, + ) + + // Get a transaction hash from the middle of the dataset + // for representative benchmarking. + medianIndex := len(bw.unconfirmedTxs) / 2 + testTxHash := bw.unconfirmedTxs[medianIndex].TxHash() + + // Initial write of the label to ensure both APIs are + // testing the _overwrite_ case, which aligns the + // functional behavior of LabelTransaction + // (overwrite=true) with LabelTx. + err := bw.LabelTx(b.Context(), testTxHash, testLabel) + require.NoError(b, err) + + b.Run("0-Before", func(b *testing.B) { + const overwrite = true + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; b.Loop(); i++ { + err = bw.LabelTransaction( + testTxHash, testLabel, + overwrite, + ) + require.NoError(b, err) + } + }) + + b.Run("1-After", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + for i := 0; b.Loop(); i++ { + err = bw.LabelTx( + b.Context(), testTxHash, + testLabel, + ) + require.NoError(b, err) + } + }) + + // Verification: Ensure the label was successfully + // written and is identical after both benchmarks. Since + // we are testing the overwrite case repeatedly, we only + // need to check the final state. That way we are sure + // that we are benchmarking the thing right. + assertLabelTxAPIsEquivalent( + b, bw.Wallet, testTxHash, testLabel, + ) + }) + } +} + +// assertLabelTxAPIsEquivalent verifies that the transaction label is correctly +// set after the benchmark run. +func assertLabelTxAPIsEquivalent(b *testing.B, w *Wallet, hash chainhash.Hash, + expectedLabel string) { + + b.Helper() + + var actualLabel string + + err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + var err error + + actualLabel, err = w.txStore.FetchTxLabel(txmgrNs, hash) + + return err + }) + + require.NoError(b, err) + require.Equal( + b, expectedLabel, actualLabel, + "LabelTx and LabelTransaction should result in the same label "+ + "value", + ) +} From 39065a7e6df8f20354302fee38ec951f88154b3d Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 17 Nov 2025 10:09:58 +0000 Subject: [PATCH 141/691] wallet: benchmark `LabelTx` API concurrently --- wallet/tx_writer_benchmark_test.go | 136 +++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/wallet/tx_writer_benchmark_test.go b/wallet/tx_writer_benchmark_test.go index 6cd8a1c5cf..7fb3f83c1c 100644 --- a/wallet/tx_writer_benchmark_test.go +++ b/wallet/tx_writer_benchmark_test.go @@ -138,6 +138,142 @@ func BenchmarkLabelTxAPI(b *testing.B) { } } +// BenchmarkLabelTxAPIConcurrently benchmarks LabelTx API and its deprecated +// variant LabelTransaction (when overwrite is true) using identical test data +// under concurrent load. Test names use the wallet size metric to group API +// comparisons for benchstat analysis. +// +// Time Complexity Analysis: +// Under concurrent load, the API maintains the same per-transaction complexity +// of O(log n) as the sequential benchmark, where n is the number of +// transactions in the database (B-tree write operation). Since the new API +// eliminates an initial read operation (FetchTxLabel), it should show better +// performance even under concurrent load. +// +// This benchmark stresses the lock contention characteristics during database +// writes, demonstrating scalability under concurrent write operations. +func BenchmarkLabelTxAPIConcurrently(b *testing.B) { + const ( + // startGrowthIteration is the starting iteration index for the + // growth sequence. + startGrowthIteration = 0 + + // endGrowthIteration is the maximum iteration index for the + // growth sequence. + endGrowthIteration = 5 + ) + + var ( + // accountGrowth and addressGrowth use constantGrowth since + // account/address count doesn't directly affect the API's time + // complexity for a single tx label. + accountGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + // txPoolGrowth uses linearGrowth to test O(log n) B-tree write + // scaling. As database size grows linearly, write time should + // grow logarithmically. + txPoolGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + linearGrowth, + ) + + // txIOGrowth uses constantGrowth since I/O count doesn't affect + // the LabelTx API's time complexity. + txIOGrowth = mapRange( + startGrowthIteration, endGrowthIteration, + constantGrowth, + ) + + txPoolGrowthPadding = decimalWidth( + txPoolGrowth[len(txPoolGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + + // testLabel is the string used for labeling the transaction. + testLabel = "bench_label" + ) + + for i := 0; i <= endGrowthIteration; i++ { + name := fmt.Sprintf("TxPool-%0*d", txPoolGrowthPadding, + txPoolGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: txPoolGrowth[i], + numTxInputs: txIOGrowth[i], + numTxOutputs: txIOGrowth[i], + }, + ) + + // Get a transaction hash from the middle of the dataset + // for representative benchmarking. + medianIndex := len(bw.unconfirmedTxs) / 2 + testTxHash := bw.unconfirmedTxs[medianIndex].TxHash() + + // Initial write of the label to ensure both APIs are + // testing the _overwrite_ case, which aligns the + // functional behavior of LabelTransaction + // (overwrite=true) with LabelTx. + err := bw.LabelTx(b.Context(), testTxHash, testLabel) + require.NoError(b, err) + + b.Run("0-Before", func(b *testing.B) { + const overwrite = true + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + err := bw.LabelTransaction( + testTxHash, testLabel, + overwrite, + ) + require.NoError(b, err) + } + }) + }) + + b.Run("1-After", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + err := bw.LabelTx( + b.Context(), testTxHash, + testLabel, + ) + require.NoError(b, err) + } + }) + }) + + // Verification: Ensure the label was successfully + // written and is identical after both benchmarks. Since + // we are testing the overwrite case repeatedly, we only + // need to check the final state. That way we are sure + // that we are benchmarking the thing right. + assertLabelTxAPIsEquivalent( + b, bw.Wallet, testTxHash, testLabel, + ) + }) + } +} + // assertLabelTxAPIsEquivalent verifies that the transaction label is correctly // set after the benchmark run. func assertLabelTxAPIsEquivalent(b *testing.B, w *Wallet, hash chainhash.Hash, From e5c0f5b275cb23452645e7270e836ef77f1cfca1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Sep 2025 19:27:38 +0800 Subject: [PATCH 142/691] wallet: move existing signer methods to `deprecated.go` --- wallet/deprecated.go | 82 +++++++++++++++++++++++++++++++++++++++++ wallet/signer.go | 87 -------------------------------------------- 2 files changed, 82 insertions(+), 87 deletions(-) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index e94d36b2ee..db8ba570c3 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -2,9 +2,13 @@ package wallet import ( + "context" + + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" ) @@ -150,3 +154,81 @@ func (w *Wallet) RenameAccountDeprecated(scope waddrmgr.KeyScope, return err } + +// ScriptForOutputDeprecated returns the address, witness program and redeem +// script for a given UTXO. An error is returned if the UTXO does not +// belong to our wallet or it is not a managed pubKey address. +// +// Deprecated: Use AddressManager.ScriptForOutput instead. +func (w *Wallet) ScriptForOutputDeprecated(output *wire.TxOut) ( + waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) { + + script, err := w.ScriptForOutput(context.Background(), *output) + if err != nil { + return nil, nil, nil, err + } + + return script.Addr, script.WitnessProgram, script.RedeemScript, nil +} + +// PrivKeyTweaker is a function type that can be used to pass in a callback for +// tweaking a private key before it's used to sign an input. +type PrivKeyTweaker func(*btcec.PrivateKey) (*btcec.PrivateKey, error) + +// ComputeInputScript generates a complete InputScript for the passed +// transaction with the signature as defined within the passed +// SignDescriptor. This method is capable of generating the proper input +// script for both regular p2wkh output and p2wkh outputs nested within a +// regular p2sh output. +func (w *Wallet) ComputeInputScript(tx *wire.MsgTx, output *wire.TxOut, + inputIndex int, sigHashes *txscript.TxSigHashes, + hashType txscript.SigHashType, tweaker PrivKeyTweaker) (wire.TxWitness, + []byte, error) { + + walletAddr, witnessProgram, sigScript, err := + w.ScriptForOutputDeprecated( + output, + ) + if err != nil { + return nil, nil, err + } + + privKey, err := walletAddr.PrivKey() + if err != nil { + return nil, nil, err + } + + // If we need to maybe tweak our private key, do it now. + if tweaker != nil { + privKey, err = tweaker(privKey) + if err != nil { + return nil, nil, err + } + } + + // We need to produce a Schnorr signature for p2tr key spend addresses. + if txscript.IsPayToTaproot(output.PkScript) { + // We can now generate a valid witness which will allow us to + // spend this output. + witnessScript, err := txscript.TaprootWitnessSignature( + tx, sigHashes, inputIndex, output.Value, + output.PkScript, hashType, privKey, + ) + if err != nil { + return nil, nil, err + } + + return witnessScript, nil, nil + } + + // Generate a valid witness stack for the input. + witnessScript, err := txscript.WitnessSignature( + tx, sigHashes, inputIndex, output.Value, witnessProgram, + hashType, privKey, true, + ) + if err != nil { + return nil, nil, err + } + + return witnessScript, sigScript, nil +} diff --git a/wallet/signer.go b/wallet/signer.go index 6f40ee0ed4..89493d7676 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -3,90 +3,3 @@ // license that can be found in the LICENSE file. package wallet - -import ( - "context" - - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" -) - -// ScriptForOutputDeprecated returns the address, witness program and redeem -// script for a given UTXO. An error is returned if the UTXO does not -// belong to our wallet or it is not a managed pubKey address. -// -// Deprecated: Use AddressManager.ScriptForOutput instead. -func (w *Wallet) ScriptForOutputDeprecated(output *wire.TxOut) ( - waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) { - - script, err := w.ScriptForOutput(context.Background(), *output) - if err != nil { - return nil, nil, nil, err - } - - return script.Addr, script.WitnessProgram, script.RedeemScript, nil -} - -// PrivKeyTweaker is a function type that can be used to pass in a callback for -// tweaking a private key before it's used to sign an input. -type PrivKeyTweaker func(*btcec.PrivateKey) (*btcec.PrivateKey, error) - -// ComputeInputScript generates a complete InputScript for the passed -// transaction with the signature as defined within the passed -// SignDescriptor. This method is capable of generating the proper input -// script for both regular p2wkh output and p2wkh outputs nested within a -// regular p2sh output. -func (w *Wallet) ComputeInputScript(tx *wire.MsgTx, output *wire.TxOut, - inputIndex int, sigHashes *txscript.TxSigHashes, - hashType txscript.SigHashType, tweaker PrivKeyTweaker) (wire.TxWitness, - []byte, error) { - - walletAddr, witnessProgram, sigScript, err := - w.ScriptForOutputDeprecated( - output, - ) - if err != nil { - return nil, nil, err - } - - privKey, err := walletAddr.PrivKey() - if err != nil { - return nil, nil, err - } - - // If we need to maybe tweak our private key, do it now. - if tweaker != nil { - privKey, err = tweaker(privKey) - if err != nil { - return nil, nil, err - } - } - - // We need to produce a Schnorr signature for p2tr key spend addresses. - if txscript.IsPayToTaproot(output.PkScript) { - // We can now generate a valid witness which will allow us to - // spend this output. - witnessScript, err := txscript.TaprootWitnessSignature( - tx, sigHashes, inputIndex, output.Value, - output.PkScript, hashType, privKey, - ) - if err != nil { - return nil, nil, err - } - - return witnessScript, nil, nil - } - - // Generate a valid witness stack for the input. - witnessScript, err := txscript.WitnessSignature( - tx, sigHashes, inputIndex, output.Value, witnessProgram, - hashType, privKey, true, - ) - if err != nil { - return nil, nil, err - } - - return witnessScript, sigScript, nil -} From b0285ceb3485459c257b4e5f404fbbc3f0312a45 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:54:02 +0800 Subject: [PATCH 143/691] wallet: add more mocks to prepare upcoming tests --- wallet/example_test.go | 33 ++++++++++-- wallet/mock_test.go | 120 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/wallet/example_test.go b/wallet/example_test.go index 9cd637edac..cc0e0c3b51 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -60,9 +60,23 @@ func testWallet(t *testing.T) *Wallet { // mockers is a struct that holds all the mocked interfaces that can be // used to test the wallet. type mockers struct { - chain *mockChain + // chain is the mock blockchain backend. + chain *mockChain + + // addrStore is the mock address store. addrStore *mockAddrStore - txStore *mockTxStore + + // txStore is the mock transaction store. + txStore *mockTxStore + + // addr is the mock managed address. + addr *mockManagedAddress + + // accountManager is the mock account manager. + accountManager *mockAccountStore + + // pubKeyAddr is the mock managed public key address. + pubKeyAddr *mockManagedPubKeyAddr } // testWalletWithMocks creates a test wallet and unlocks it. In contrast to @@ -89,6 +103,9 @@ func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { chain := &mockChain{} txStore := &mockTxStore{} addrStore := &mockAddrStore{} + addr := &mockManagedAddress{} + accountManager := &mockAccountStore{} + pubKeyAddr := &mockManagedPubKeyAddr{} addrStore.On("IsLocked").Return(false) addrStore.On("Unlock", mock.Anything, mock.Anything).Return(nil) @@ -106,9 +123,12 @@ func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { // Create the mockers struct so it can be used by the tests to mock // methods. m := &mockers{ - chain: chain, - txStore: txStore, - addrStore: addrStore, + chain: chain, + txStore: txStore, + addrStore: addrStore, + addr: addr, + accountManager: accountManager, + pubKeyAddr: pubKeyAddr, } // When the test finishes, we need to assert the mocked methods are @@ -117,6 +137,9 @@ func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { chain.AssertExpectations(t) txStore.AssertExpectations(t) addrStore.AssertExpectations(t) + addr.AssertExpectations(t) + accountManager.AssertExpectations(t) + pubKeyAddr.AssertExpectations(t) }) return w, m diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 85fdbe79ee..9646b53625 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -273,6 +273,10 @@ func (m *mockAddrStore) FetchScopedKeyManager( scope waddrmgr.KeyScope) (waddrmgr.AccountStore, error) { args := m.Called(scope) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(waddrmgr.AccountStore), args.Error(1) } @@ -541,6 +545,10 @@ func (m *mockAccountStore) DeriveFromKeyPath(ns walletdb.ReadBucket, path waddrmgr.DerivationPath) (waddrmgr.ManagedAddress, error) { args := m.Called(ns, path) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(waddrmgr.ManagedAddress), args.Error(1) } @@ -926,3 +934,115 @@ func (m *mockChain) MapRPCErr(err error) error { args := m.Called(err) return args.Error(0) } + +// mockManagedPubKeyAddr is a mock implementation of the +// waddrmgr.ManagedPubKeyAddress interface, used for testing. +type mockManagedPubKeyAddr struct { + mock.Mock +} + +// A compile-time check to ensure that mockManagedPubKeyAddr implements the +// ManagedPubKeyAddress interface. +var _ waddrmgr.ManagedPubKeyAddress = (*mockManagedPubKeyAddr)(nil) + +// PubKey implements the waddrmgr.ManagedPubKeyAddress interface. +func (m *mockManagedPubKeyAddr) PubKey() *btcec.PublicKey { + args := m.Called() + if args.Get(0) == nil { + return nil + } + + return args.Get(0).(*btcec.PublicKey) +} + +// ExportPrivKey implements the waddrmgr.ManagedPubKeyAddress interface. +func (m *mockManagedPubKeyAddr) ExportPrivKey() (*btcutil.WIF, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*btcutil.WIF), args.Error(1) +} + +// ExportPubKey implements the waddrmgr.ManagedPubKeyAddress interface. +func (m *mockManagedPubKeyAddr) ExportPubKey() string { + args := m.Called() + return args.String(0) +} + +// PrivKey implements the waddrmgr.ManagedPubKeyAddress interface. +func (m *mockManagedPubKeyAddr) PrivKey() (*btcec.PrivateKey, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*btcec.PrivateKey), args.Error(1) +} + +// Address implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedPubKeyAddr) Address() btcutil.Address { + args := m.Called() + if args.Get(0) == nil { + return nil + } + + return args.Get(0).(btcutil.Address) +} + +// AddrHash implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedPubKeyAddr) AddrHash() []byte { + args := m.Called() + if args.Get(0) == nil { + return nil + } + + return args.Get(0).([]byte) +} + +// Imported implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedPubKeyAddr) Imported() bool { + args := m.Called() + return args.Bool(0) +} + +// Internal implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedPubKeyAddr) Internal() bool { + args := m.Called() + return args.Bool(0) +} + +// Compressed implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedPubKeyAddr) Compressed() bool { + args := m.Called() + return args.Bool(0) +} + +// Used implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedPubKeyAddr) Used(ns walletdb.ReadBucket) bool { + args := m.Called(ns) + return args.Bool(0) +} + +// AddrType implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedPubKeyAddr) AddrType() waddrmgr.AddressType { + args := m.Called() + return args.Get(0).(waddrmgr.AddressType) +} + +// InternalAccount implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedPubKeyAddr) InternalAccount() uint32 { + args := m.Called() + return args.Get(0).(uint32) +} + +// DerivationInfo implements the waddrmgr.ManagedAddress interface. +func (m *mockManagedPubKeyAddr) DerivationInfo() (waddrmgr.KeyScope, + waddrmgr.DerivationPath, bool) { + + args := m.Called() + + return args.Get(0).(waddrmgr.KeyScope), + args.Get(1).(waddrmgr.DerivationPath), args.Bool(2) +} From dbc2a0b0f57ccac807e0c5ac42a9812846131bfe Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 26 Sep 2025 14:24:22 +0800 Subject: [PATCH 144/691] wallet: add `GetDerivationInfo` on `AddressManager` This commit introduces a new method, GetDerivationInfo, to the AddressManager interface. This provides a clean, context-aware way to retrieve BIP-32 derivation information for a given wallet address. The new method improves upon the older FetchDerivationInfo by accepting a btcutil.Address directly, which simplifies the API and delegates script parsing to the caller. The error handling is also more precise, returning a specific ErrDerivationPathNotFound for addresses that are not found, are not public key addresses, or are imported. This is more descriptive than the generic ErrNotMine. The implementation uses the existing AddressInfo method to retrieve the managed address and then calls the DerivationInfo method on it to construct the final psbt.Bip32Derivation object. --- wallet/address_manager.go | 62 +++++++++++++++++ wallet/address_manager_test.go | 120 +++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index dad78eab75..31c65838f7 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -17,6 +17,8 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -24,6 +26,12 @@ import ( ) var ( + // ErrDerivationPathNotFound is returned when the derivation path for a + // given script cannot be found. This may be because the script does + // not belong to the wallet, is imported, or is not a pubkey-based + // script. + ErrDerivationPathNotFound = errors.New("derivation path not found") + // ErrUnknownAddrType is an error returned when a wallet function is // called with an unknown address type. ErrUnknownAddrType = errors.New("unknown address type") @@ -113,6 +121,11 @@ type AddressManager interface { // ScriptForOutput returns the address, witness program, and redeem // script for a given UTXO. ScriptForOutput(ctx context.Context, output wire.TxOut) (Script, error) + + // GetDerivationInfo returns the BIP-32 derivation path for a given + // address. + GetDerivationInfo(ctx context.Context, + addr btcutil.Address) (*psbt.Bip32Derivation, error) } // A compile time check to ensure that Wallet implements the interface. @@ -745,3 +758,52 @@ func (w *Wallet) ScriptForOutput(_ context.Context, output wire.TxOut) ( RedeemScript: sigScript, }, nil } + +// GetDerivationInfo returns the BIP-32 derivation path for a given address. +func (w *Wallet) GetDerivationInfo(ctx context.Context, + addr btcutil.Address) (*psbt.Bip32Derivation, error) { + + // We'll use the address to look up the derivation path. + managedAddr, err := w.AddressInfo(ctx, addr) + if err != nil { + return nil, err + } + + // We only care about pubkey addresses, as they are the only + // ones with derivation paths. + pubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil, fmt.Errorf("%w: addr=%v not found", + ErrDerivationPathNotFound, addr) + } + + // Imported addresses don't have derivation paths. + if pubKeyAddr.Imported() { + return nil, fmt.Errorf("%w: addr=%v is imported", + ErrDerivationPathNotFound, addr) + } + + // Get the derivation info. + keyScope, derivPath, ok := pubKeyAddr.DerivationInfo() + if !ok { + return nil, fmt.Errorf("%w: derivation info not found for %v", + ErrDerivationPathNotFound, addr) + } + + // Get the public key. + pubKey := pubKeyAddr.PubKey() + + derivationInfo := &psbt.Bip32Derivation{ + PubKey: pubKey.SerializeCompressed(), + MasterKeyFingerprint: derivPath.MasterKeyFingerprint, + Bip32Path: []uint32{ + keyScope.Purpose + hdkeychain.HardenedKeyStart, + keyScope.Coin + hdkeychain.HardenedKeyStart, + derivPath.Account, + derivPath.Branch, + derivPath.Index, + }, + } + + return derivationInfo, nil +} diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 5989e736c7..ebde88e9bb 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -294,6 +295,125 @@ func TestAddressInfo(t *testing.T) { require.Equal(t, waddrmgr.WitnessPubKey, intInfo.AddrType()) } +// TestGetDerivationInfoExternalAddressSuccess tests that we can successfully +// get the derivation info for an external address. +func TestGetDerivationInfoExternalAddressSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Create a new test wallet and a new p2wkh address to test + // with. + w := testWallet(t) + addr, err := w.NewAddress( + t.Context(), "default", waddrmgr.WitnessPubKey, false, + ) + require.NoError(t, err) + + // Act: Get the derivation info for the address. + derivationInfo, err := w.GetDerivationInfo(t.Context(), addr) + + // Assert: Check that the correct derivation info is returned. + require.NoError(t, err) + require.NotNil(t, derivationInfo) + + addrInfo, err := w.AddressInfo(t.Context(), addr) + require.NoError(t, err) + + pubKeyAddr, ok := addrInfo.(waddrmgr.ManagedPubKeyAddress) + require.True(t, ok) + + pubKey := pubKeyAddr.PubKey() + keyScope, derivPath, ok := pubKeyAddr.DerivationInfo() + require.True(t, ok) + + expectedPath := []uint32{ + keyScope.Purpose + hdkeychain.HardenedKeyStart, + keyScope.Coin + hdkeychain.HardenedKeyStart, + derivPath.Account, + derivPath.Branch, + derivPath.Index, + } + + require.Equal(t, pubKey.SerializeCompressed(), derivationInfo.PubKey) + require.Equal( + t, derivPath.MasterKeyFingerprint, + derivationInfo.MasterKeyFingerprint, + ) + require.Equal(t, expectedPath, derivationInfo.Bip32Path) +} + +// TestGetDerivationInfoInternalAddressSuccess tests that we can successfully +// get the derivation info for an internal address. +func TestGetDerivationInfoInternalAddressSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Create a new test wallet and a new p2wkh change address to + // test with. + w := testWallet(t) + addr, err := w.NewAddress( + t.Context(), "default", waddrmgr.WitnessPubKey, true, + ) + require.NoError(t, err) + + // Act: Get the derivation info for the address. + derivationInfo, err := w.GetDerivationInfo(t.Context(), addr) + + // Assert: Check that the correct derivation info is returned. + require.NoError(t, err) + require.NotNil(t, derivationInfo) + + addrInfo, err := w.AddressInfo(t.Context(), addr) + require.NoError(t, err) + + pubKeyAddr, ok := addrInfo.(waddrmgr.ManagedPubKeyAddress) + require.True(t, ok) + keyScope, derivPath, ok := pubKeyAddr.DerivationInfo() + require.True(t, ok) + + expectedPath := []uint32{ + keyScope.Purpose + hdkeychain.HardenedKeyStart, + keyScope.Coin + hdkeychain.HardenedKeyStart, + derivPath.Account, + derivPath.Branch, + derivPath.Index, + } + require.Equal(t, expectedPath, derivationInfo.Bip32Path) + require.Equal(t, uint32(1), derivPath.Branch) +} + +// TestGetDerivationInfoNoDerivationInfo tests that we get an error when trying +// to get the derivation info for an address that is not in the wallet or is +// imported. +func TestGetDerivationInfoNoDerivationInfo(t *testing.T) { + t.Parallel() + + // Arrange: Create a new test wallet and a key and address that is not + // in the wallet. + w := testWallet(t) + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + addr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), + w.chainParams, + ) + require.NoError(t, err) + + // Act & Assert: Check that we get an error for an address not in the + // wallet. + _, err = w.GetDerivationInfo(t.Context(), addr) + require.Error(t, err) + + // Arrange: Import the key as a watch-only address. + err = w.ImportPublicKey(t.Context(), pubKey, waddrmgr.WitnessPubKey) + require.NoError(t, err) + + // Act & Assert: Check that we still get an error because it's an + // imported key. + _, err = w.GetDerivationInfo(t.Context(), addr) + require.ErrorIs(t, err, ErrDerivationPathNotFound) +} + // TestListAddresses tests the ListAddresses method to ensure it returns the // correct addresses and balances for a given account. func TestListAddresses(t *testing.T) { From 368793e81d214951e4c0d9e73a25adc721be00a4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 12:52:51 +0800 Subject: [PATCH 145/691] wallet: define `Signer` and `UnsafeSigner` interfaces This commit introduces the initial, high-level interfaces for the wallet's cryptographic signing operations. The `Signer` interface will serve as the primary, safe API for common operations like signing and key derivation. The `UnsafeSigner` interface embeds the `Signer` and is designated for security-sensitive operations that export raw private key material. This creates a clear, compile-time boundary that forces developers to consciously opt-in to using dangerous functions. This is the foundational step in building a clean, well-defined signing service for the wallet. --- wallet/signer.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/wallet/signer.go b/wallet/signer.go index 89493d7676..2d66f3e087 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -1,5 +1,13 @@ -// Copyright (c) 2020 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - package wallet + +// Signer provides an interface for common, safe cryptographic operations, +// including signing and key derivation. +type Signer interface { +} + +// UnsafeSigner provides an interface for security-sensitive cryptographic +// operations that export raw private key material. This interface should be +// used with extreme care and only when absolutely necessary. +type UnsafeSigner interface { + Signer +} From cf2677feabf05457a25b79347321619f8d6a7fa0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 12:56:47 +0800 Subject: [PATCH 146/691] wallet: add `DerivePubKey` and `ECDH` to `Signer` This commit extends the `Signer` interface with two core, non-signing cryptographic methods: `DerivePubKey` and `ECDH`. `DerivePubKey` provides a safe way to derive a public key from a full BIP-32 derivation path. `ECDH` provides a method for performing a scalar multiplication between a derived private key and a remote public key, which is a primitive needed for various cryptographic protocols. The `BIP32Path` struct is also introduced to serve as a clear and unambiguous parameter for identifying the key to be used in these operations. --- wallet/signer.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index 2d66f3e087..16d3ab1ead 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -1,8 +1,26 @@ package wallet +import ( + "context" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcwallet/waddrmgr" +) + // Signer provides an interface for common, safe cryptographic operations, // including signing and key derivation. type Signer interface { + // DerivePubKey derives a public key from a full BIP-32 derivation + // path. + DerivePubKey(ctx context.Context, path BIP32Path) ( + *btcec.PublicKey, error) + + // ECDH performs a scalar multiplication (ECDH-like operation) between + // a key from the wallet and a remote public key. The output returned + // will be the raw 32-byte shared secret (the X-coordinate of the + // result point). + ECDH(ctx context.Context, path BIP32Path, pub *btcec.PublicKey) ( + [32]byte, error) } // UnsafeSigner provides an interface for security-sensitive cryptographic @@ -11,3 +29,15 @@ type Signer interface { type UnsafeSigner interface { Signer } + +// BIP32Path contains the full information needed to derive a key from the +// wallet's master seed, as defined by BIP-32. It combines the high-level key +// scope with the specific derivation path. +type BIP32Path struct { + // KeyScope specifies the key scope (e.g., P2WKH, P2TR, or lnd's custom + // scope). + KeyScope waddrmgr.KeyScope + + // DerivationPath specifies the full derivation path within the scope. + DerivationPath waddrmgr.DerivationPath +} From 2cf271d4aefbff8b3052552535ac4e01fff17b44 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:02:52 +0800 Subject: [PATCH 147/691] wallet: add `SignMessage` and related types to `Signer` This commit adds the `SignMessage` method to the `Signer` interface, providing a flexible way to sign arbitrary messages. The `SignMessageIntent` struct is introduced to allow the caller to specify the exact parameters of the signing operation, such as the hash digest to use and whether to produce an ECDSA or Schnorr signature. The returned `Signature` is a marker interface. Detailed documentation and examples are provided to show how the caller can use a type assertion to retrieve the concrete signature type (`ECDSASignature`, `CompactSignature`, or `SchnorrSignature`). --- wallet/signer.go | 111 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index 16d3ab1ead..7f58dcb68b 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -4,6 +4,8 @@ import ( "context" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcwallet/waddrmgr" ) @@ -21,6 +23,12 @@ type Signer interface { // result point). ECDH(ctx context.Context, path BIP32Path, pub *btcec.PublicKey) ( [32]byte, error) + + // SignMessage signs a message based on the provided intent. The + // returned Signature is a marker interface that can be asserted to the + // concrete signature types, ECDSASignature or SchnorrSignature. + SignMessage(ctx context.Context, path BIP32Path, + intent *SignMessageIntent) (Signature, error) } // UnsafeSigner provides an interface for security-sensitive cryptographic @@ -41,3 +49,106 @@ type BIP32Path struct { // DerivationPath specifies the full derivation path within the scope. DerivationPath waddrmgr.DerivationPath } + +// SignMessageIntent represents the user's intent to sign a message. It +// serves as a blueprint for the Signer, bundling all the parameters +// required to produce a signature into a single, coherent structure. +// +// # Usage Examples +// +// ## Standard ECDSA Signature (DER Encoded) +// To produce a standard ECDSA signature, set CompactSig to false and leave the +// Schnorr field nil. +// +// intent := &wallet.SignMessageIntent{ +// Msg: []byte("a message"), +// DoubleHash: true, +// CompactSig: false, +// } +// rawSig, err := signer.SignMessage(ctx, path, intent) +// // Type-assert the result to ECDSASignature. +// ecdsaSig := rawSig.(wallet.ECDSASignature) +// +// ## Compact, Recoverable ECDSA Signature +// To produce a compact, recoverable signature, set CompactSig to true. +// +// intent := &wallet.SignMessageIntent{ +// Msg: []byte("a message"), +// DoubleHash: true, +// CompactSig: true, +// } +// rawSig, err := signer.SignMessage(ctx, path, intent) +// // Type-assert the result to CompactSignature. +// compactSig := rawSig.(wallet.CompactSignature) +// +// ## Schnorr Signature +// To produce a Schnorr signature, populate the Schnorr field. When this field +// is non-nil, all ECDSA-related fields (like CompactSig) are ignored. +// +// intent := &wallet.SignMessageIntent{ +// Msg: []byte("a message"), +// Schnorr: &wallet.SchnorrSignOpts{ +// Tag: []byte("my_protocol_tag"), +// }, +// } +// rawSig, err := signer.SignMessage(ctx, path, intent) +// // Type-assert the result to SchnorrSignature. +// schnorrSig := rawSig.(wallet.SchnorrSignature) +type SignMessageIntent struct { + // Msg is the raw message to be signed. + Msg []byte + + // DoubleHash specifies whether the message should be double-hashed + // (SHA256d) before signing. If false, a single SHA256 hash is used. + DoubleHash bool + + // CompactSig specifies whether the signature should be returned in the + // compact, recoverable format. This is only valid for ECDSA signatures. + CompactSig bool + + // Schnorr specifies the options for a Schnorr signature. If this is + // nil, an ECDSA signature will be produced. + Schnorr *SchnorrSignOpts +} + +// SchnorrSignOpts contains the specific parameters for a Schnorr signature. +type SchnorrSignOpts struct { + // Tweak is an optional private key tweak to be applied before signing. + Tweak []byte + + // Tag is an optional BIP-340 tagged hash to use. If nil, the standard + // SHA256 hash of the message is used. + Tag []byte +} + +// Signature is an interface that represents a cryptographic signature. +// It is a marker interface to allow returning different signature types. +type Signature interface { + // isSignature is a marker method to ensure that only the types defined + // in this package can implement this interface. + isSignature() +} + +// ECDSASignature wraps an ecdsa.Signature to implement the Signature interface. +type ECDSASignature struct { + *ecdsa.Signature +} + +// CompactSignature wraps a compact signature byte slice to implement the +// Signature interface. +type CompactSignature []byte + +// SchnorrSignature wraps a schnorr.Signature to implement the Signature +// interface. +type SchnorrSignature struct { + *schnorr.Signature +} + +// isSignature implements the Signature marker interface. +func (ECDSASignature) isSignature() {} + +// isSignature implements the Signature marker interface. +func (CompactSignature) isSignature() {} + +// isSignature implements the Signature marker interface. +func (SchnorrSignature) isSignature() {} From 1d998a194a97ffca3aa9cea0fedabb84dfa99d56 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:13:26 +0800 Subject: [PATCH 148/691] wallet: add `ComputeUnlockingScript` to `Signer` This commit introduces the `ComputeUnlockingScript` method to the `Signer` interface. This is a high-level convenience function that generates the complete, final script (witness and/or sigScript) required to unlock a UTXO. The method takes an `UnlockingScriptParams` struct which bundles all the necessary parameters for the operation. The returned `UnlockingScript` struct contains the raw script components, ready to be placed on a transaction input. This provides a clear, robust API for the most common signing operations. --- wallet/deprecated.go | 5 ---- wallet/signer.go | 56 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index db8ba570c3..c84f295ca5 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -4,7 +4,6 @@ package wallet import ( "context" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" @@ -171,10 +170,6 @@ func (w *Wallet) ScriptForOutputDeprecated(output *wire.TxOut) ( return script.Addr, script.WitnessProgram, script.RedeemScript, nil } -// PrivKeyTweaker is a function type that can be used to pass in a callback for -// tweaking a private key before it's used to sign an input. -type PrivKeyTweaker func(*btcec.PrivateKey) (*btcec.PrivateKey, error) - // ComputeInputScript generates a complete InputScript for the passed // transaction with the signature as defined within the passed // SignDescriptor. This method is capable of generating the proper input diff --git a/wallet/signer.go b/wallet/signer.go index 7f58dcb68b..f08650089d 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -6,6 +6,8 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" ) @@ -29,6 +31,21 @@ type Signer interface { // concrete signature types, ECDSASignature or SchnorrSignature. SignMessage(ctx context.Context, path BIP32Path, intent *SignMessageIntent) (Signature, error) + + // ComputeUnlockingScript generates the full sigScript and witness + // required to spend a UTXO. The resulting UnlockingScript struct + // contains the raw witness and/or sigScript, which can be used to + // populate the final transaction input. + // + // This method is designed for spending single-signature outputs, which + // are outputs that can be spent with a single signature from a single + // private key. This includes P2PKH, P2WKH, NP2WKH, and P2TR key-path + // spends. For more complex script-based spends, such as P2SH or P2WSH + // multisig, the ComputeRawSig method should be used to generate the raw + // signature, which can then be manually assembled into the final + // witness. + ComputeUnlockingScript(ctx context.Context, + params *UnlockingScriptParams) (*UnlockingScript, error) } // UnsafeSigner provides an interface for security-sensitive cryptographic @@ -152,3 +169,42 @@ func (CompactSignature) isSignature() {} // isSignature implements the Signature marker interface. func (SchnorrSignature) isSignature() {} + +// UnlockingScript is a struct that contains the witness and sigScript for a +// transaction input. +type UnlockingScript struct { + // Witness is the witness stack for the input. For non-SegWit inputs, + // this will be nil. + Witness wire.TxWitness + + // SigScript is the signature script for the input. For native SegWit + // inputs, this will be nil. + SigScript []byte +} + +// PrivKeyTweaker is a function type that can be used to pass in a callback for +// tweaking a private key before it's used to sign an input. +type PrivKeyTweaker func(*btcec.PrivateKey) (*btcec.PrivateKey, error) + +// UnlockingScriptParams provides all the necessary parameters to generate an +// unlocking script (witness and sigScript) for a transaction input. +type UnlockingScriptParams struct { + // Tx is the transaction containing the input to be signed. + Tx *wire.MsgTx + + // InputIndex is the index of the input to be signed. + InputIndex int + + // Output is the previous output that is being spent. + Output *wire.TxOut + + // SigHashes is the sighash cache for the transaction. + SigHashes *txscript.TxSigHashes + + // HashType is the signature hash type to use. + HashType txscript.SigHashType + + // Tweaker is an optional function that can be used to tweak the + // private key before signing. + Tweaker PrivKeyTweaker +} From 16498031fc9cb1c1007ab6cafef3013b9f3b8a28 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:28:43 +0800 Subject: [PATCH 149/691] wallet: add `ComputeRawSig` and related types to `Signer` This commit introduces the `ComputeRawSig` method to the `Signer` interface. This is a low-level specialist function that generates a raw signature for a transaction input, intended for complex, multi-party scenarios like multisig or Lightning. To ensure type safety and clarity, this change introduces a `SpendDetails` sealed interface and a `RawSigParams` struct. This allows the caller to explicitly and safely provide all necessary parameters for the various signing protocols (Legacy, SegWit v0, and Taproot). Compile-time interface checks are included to ensure all concrete `SpendDetails` types correctly implement the interface. --- wallet/signer.go | 209 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index f08650089d..592223cd82 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -2,6 +2,8 @@ package wallet import ( "context" + "errors" + "fmt" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/ecdsa" @@ -11,6 +13,12 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" ) +var ( + // ErrUnknownSignMethod is returned when a transaction is signed with an + // unknown sign method. + ErrUnknownSignMethod = errors.New("unknown sign method") +) + // Signer provides an interface for common, safe cryptographic operations, // including signing and key derivation. type Signer interface { @@ -46,6 +54,19 @@ type Signer interface { // witness. ComputeUnlockingScript(ctx context.Context, params *UnlockingScriptParams) (*UnlockingScript, error) + + // ComputeRawSig generates a raw signature for a single transaction + // input. The caller is responsible for assembling the final witness. + // + // This method is a low-level specialist function that should only be + // used when the caller needs to generate a raw signature for a + // specific key, without the wallet assembling the final witness. This + // is useful for multi-party protocols like multisig or Lightning, + // where signatures may need to be exchanged and combined before the + // final witness is created. For most common, single-signature spends, + // ComputeUnlockingScript should be used instead. + ComputeRawSig(ctx context.Context, params *RawSigParams) ( + RawSignature, error) } // UnsafeSigner provides an interface for security-sensitive cryptographic @@ -208,3 +229,191 @@ type UnlockingScriptParams struct { // private key before signing. Tweaker PrivKeyTweaker } + +// RawSigParams provides all the necessary parameters to generate a raw +// signature for a transaction input. +type RawSigParams struct { + // Tx is the transaction containing the input to be signed. + Tx *wire.MsgTx + + // InputIndex is the index of the input to be signed. + InputIndex int + + // Output is the previous output that is being spent. + Output *wire.TxOut + + // SigHashes is the sighash cache for the transaction. + SigHashes *txscript.TxSigHashes + + // HashType is the signature hash type to use. + HashType txscript.SigHashType + + // Path is the BIP-32 derivation path of the key to be used for + // signing. + Path BIP32Path + + // Tweaker is an optional function that can be used to tweak the + // private key before signing. + Tweaker PrivKeyTweaker + + // Details specifies the version-specific information for signing. + // This field MUST be set to either LegacySpendDetails, + // SegwitV0SpendDetails or TaprootSpendDetails. + Details SpendDetails +} + +// RawSignature is a raw signature. +type RawSignature []byte + +// TaprootSpendPath is an enum that specifies the spending path to be used for a +// Taproot input. +type TaprootSpendPath uint8 + +const ( + // KeyPathSpend indicates that the output should be spent using the key + // path. + KeyPathSpend TaprootSpendPath = iota + + // ScriptPathSpend indicates that the output should be spent using the + // script path. + ScriptPathSpend +) + +// SpendDetails is a sealed interface that provides the version-specific +// details required to generate a raw signature. +type SpendDetails interface { + // isSpendDetails is a marker method to ensure that only the types + // defined in this package can implement this interface. + isSpendDetails() + + // Sign performs the version-specific signing operation. + Sign(params *RawSigParams, privKey *btcec.PrivateKey) ( + RawSignature, error) +} + +// LegacySpendDetails provides the details for signing a legacy P2PKH input. +type LegacySpendDetails struct { + // RedeemScript is the redeem script for P2SH spends. + RedeemScript []byte +} + +// Sign performs the version-specific signing operation for a legacy input. +func (l LegacySpendDetails) Sign(params *RawSigParams, + privKey *btcec.PrivateKey) (RawSignature, error) { + + // For P2SH, the redeem script must be provided. For P2PKH, the pkscript + // of the output is used. + script := l.RedeemScript + if script == nil { + script = params.Output.PkScript + } + + rawSig, err := txscript.RawTxInSignature( + params.Tx, params.InputIndex, script, + params.HashType, privKey, + ) + if err != nil { + return nil, fmt.Errorf("cannot create raw signature: %w", err) + } + + return rawSig, nil +} + +// isSpendDetails implements the sealed interface. +func (l LegacySpendDetails) isSpendDetails() {} + +// SegwitV0SpendDetails provides the details for signing a SegWit v0 input. +type SegwitV0SpendDetails struct { + // WitnessScript is the witness script for P2WSH spends. For P2WKH, + // this should be the P2PKH script of the key. + WitnessScript []byte +} + +// Sign performs the version-specific signing operation for a SegWit v0 input. +func (s SegwitV0SpendDetails) Sign(params *RawSigParams, + privKey *btcec.PrivateKey) (RawSignature, error) { + + sig, err := txscript.RawTxInWitnessSignature( + params.Tx, params.SigHashes, params.InputIndex, + params.Output.Value, s.WitnessScript, + params.HashType, privKey, + ) + if err != nil { + return nil, fmt.Errorf("cannot create witness sig: %w", err) + } + + // Validate the signature by parsing it. This serves as a sanity check + // to ensure the generated signature is valid. + _, err = ecdsa.ParseDERSignature(sig[:len(sig)-1]) + if err != nil { + return nil, fmt.Errorf("generated invalid witness sig: %w", err) + } + + return sig[:len(sig)-1], nil +} + +// isSpendDetails implements the sealed interface. +func (s SegwitV0SpendDetails) isSpendDetails() {} + +// TaprootSpendDetails provides the details for signing a Taproot input. +type TaprootSpendDetails struct { + // SpendPath specifies which spending path to use. + SpendPath TaprootSpendPath + + // Tweak is the tweak to apply to the internal key. For a key-path + // spend, this is typically the merkle root of the script tree. + Tweak []byte + + // WitnessScript is the specific script leaf being spent. This is + // only used for ScriptPathSpend. + WitnessScript []byte +} + +// Sign performs the version-specific signing operation for a Taproot input. +func (t TaprootSpendDetails) Sign(params *RawSigParams, + privKey *btcec.PrivateKey) (RawSignature, error) { + + var ( + rawSig []byte + err error + ) + switch t.SpendPath { + case KeyPathSpend: + rawSig, err = txscript.RawTxInTaprootSignature( + params.Tx, params.SigHashes, + params.InputIndex, params.Output.Value, + params.Output.PkScript, t.Tweak, + params.HashType, privKey, + ) + if err != nil { + return nil, fmt.Errorf("taproot sig error: %w", err) + } + case ScriptPathSpend: + leaf := txscript.TapLeaf{ + LeafVersion: txscript.BaseLeafVersion, + Script: t.WitnessScript, + } + + rawSig, err = txscript.RawTxInTapscriptSignature( + params.Tx, params.SigHashes, + params.InputIndex, params.Output.Value, + params.Output.PkScript, leaf, + params.HashType, privKey, + ) + if err != nil { + return nil, fmt.Errorf("tapscript sig error: %w", err) + } + default: + return nil, fmt.Errorf("%w: %v", ErrUnknownSignMethod, + t.SpendPath) + } + + // Validate the signature by parsing it. This serves as a sanity check + // to ensure the generated signature is valid. + _, err = schnorr.ParseSignature(rawSig[:schnorr.SignatureSize]) + if err != nil { + return nil, fmt.Errorf("generated invalid taproot sig: %w", err) + } + + return rawSig, nil +} From c534354fec4618eb7be211bcb00df256ab682cae Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:31:41 +0800 Subject: [PATCH 150/691] wallet: add unsafe methods to `UnsafeSigner` This commit adds the `DerivePrivKey` and `GetPrivKeyForAddress` methods to the `UnsafeSigner` interface. These methods are explicitly marked as "unsafe" because they export raw private key material from the wallet. By placing them in this separate, embedded interface, we force any component that needs this level of access to consciously and explicitly depend on the `UnsafeSigner` type. This creates a strong, compile-time safety boundary, making it clear when a component is using dangerous, security-sensitive functionality. --- wallet/signer.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index 592223cd82..c143fa65dd 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -74,6 +75,19 @@ type Signer interface { // used with extreme care and only when absolutely necessary. type UnsafeSigner interface { Signer + + // DerivePrivKey derives a private key from a full BIP-32 derivation + // path. + // + // DANGER: This method exports sensitive key material. + DerivePrivKey(ctx context.Context, path BIP32Path) ( + *btcec.PrivateKey, error) + + // GetPrivKeyForAddress returns the private key for a given address. + // + // DANGER: This method exports sensitive key material. + GetPrivKeyForAddress(ctx context.Context, a btcutil.Address) ( + *btcec.PrivateKey, error) } // BIP32Path contains the full information needed to derive a key from the From d7c211cd331a5d6ccbb563d52bc4c395d10a212f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:35:40 +0800 Subject: [PATCH 151/691] wallet: implement `DerivePubKey` and helpers This commit adds the concrete implementation for the `DerivePubKey` method on the `Wallet` struct, satisfying the `Signer` interface. A new private helper, `fetchManagedPubKeyAddress`, is introduced to encapsulate the logic for retrieving a managed address. This helper follows best practices by minimizing the database transaction scope: it only holds a read lock long enough to fetch the raw address data, while all other processing happens outside the transaction. This helper will be reused by all other `Signer` methods. --- wallet/signer.go | 92 +++++++++++++++++++++++++++++ wallet/signer_test.go | 132 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index c143fa65dd..996d913a04 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -12,12 +12,17 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" ) var ( // ErrUnknownSignMethod is returned when a transaction is signed with an // unknown sign method. ErrUnknownSignMethod = errors.New("unknown sign method") + + // ErrUnsupportedAddressType is returned when a transaction is signed + // for an unsupported address type. + ErrUnsupportedAddressType = errors.New("unsupported address type") ) // Signer provides an interface for common, safe cryptographic operations, @@ -431,3 +436,90 @@ func (t TaprootSpendDetails) Sign(params *RawSigParams, return rawSig, nil } + +// isSpendDetails implements the sealed interface. +func (t TaprootSpendDetails) isSpendDetails() {} + +// A compile-time assertion to ensure that all SpendDetails implementations +// adhere to the interface. +var _ SpendDetails = (*LegacySpendDetails)(nil) +var _ SpendDetails = (*SegwitV0SpendDetails)(nil) +var _ SpendDetails = (*TaprootSpendDetails)(nil) + +// DerivePubKey derives a public key from a full BIP-32 derivation path. +func (w *Wallet) DerivePubKey(_ context.Context, path BIP32Path) ( + *btcec.PublicKey, error) { + + managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) + if err != nil { + return nil, err + } + + return managedPubKeyAddr.PubKey(), nil +} + +// fetchManagedPubKeyAddress is a helper function that encapsulates the common +// logic of fetching a scoped key manager, deriving a managed address from a +// BIP32 path, and ensuring it is a public key address. +// +// Time Complexity: +// - Average Case: O(1) - This is the common case where the account +// information is already cached in memory. The function performs a few +// map lookups and constant-time cryptographic operations. +// - Worst Case: O(log N) - This occurs on a cache miss (e.g., the first +// time an account is used). The function must perform a single, indexed +// database lookup to fetch the account's master key. N is the number of +// accounts in the wallet. +// +// Database Actions: +// - This method performs a single read-only database transaction +// (`walletdb.View`). +// - The transaction's only purpose is to call `DeriveFromKeyPath`, which +// performs at most one indexed database lookup for account information if +// that information is not already in the in-memory cache. +func (w *Wallet) fetchManagedPubKeyAddress(path BIP32Path) ( + waddrmgr.ManagedPubKeyAddress, error) { + + // Fetch the scoped key manager for the given key scope. This can be + // done outside of the database transaction as it only deals with + // in-memory state. + manager, err := w.addrStore.FetchScopedKeyManager(path.KeyScope) + if err != nil { + return nil, fmt.Errorf("cannot fetch scoped key manager: %w", + err) + } + + // The derivation of the address is the only part that requires a + // database transaction. + var addr waddrmgr.ManagedAddress + + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + // Derive the managed address from the derivation path. + derivedAddr, err := manager.DeriveFromKeyPath( + addrmgrNs, path.DerivationPath, + ) + if err != nil { + return fmt.Errorf("cannot derive from key path: %w", + err) + } + + addr = derivedAddr + + return nil + }) + if err != nil { + return nil, fmt.Errorf("cannot view wallet database: %w", err) + } + + // The post-processing of the address can be done outside of the + // database transaction as it only deals with the in-memory struct. + managedPubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil, fmt.Errorf("%w: addr %s", ErrNotPubKeyAddress, + addr.Address()) + } + + return managedPubKeyAddr, nil +} diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 88a47e3984..a838387270 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -5,12 +5,24 @@ package wallet import ( + "errors" "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var ( + // errManagerNotFound is returned when a scoped manager cannot be found. + errManagerNotFound = errors.New("manager not found") + + // errDerivationFailed is returned when a key derivation fails. + errDerivationFailed = errors.New("derivation failed") ) // TestComputeInputScript checks that the wallet can create the full @@ -106,3 +118,123 @@ func runTestCase(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, t.Fatalf("error validating tx: %v", err) } } + +// TestDerivePubKeySuccess tests the successful derivation of a public key. +func TestDerivePubKeySuccess(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet with mocks, a test key, and a + // derivation path. + w, mocks := testWalletWithMocks(t) + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + path := BIP32Path{ + KeyScope: waddrmgr.KeyScopeBIP0084, + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: 0, + Branch: 0, + Index: 0, + }, + } + + // Set up the mock account manager and the mock address that will be + // returned by the derivation call. + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, path.DerivationPath, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PubKey").Return(pubKey).Once() + + // Act: Derive the public key. + derivedKey, err := w.DerivePubKey(t.Context(), path) + + // Assert: Check that the correct key is returned without error. + require.NoError(t, err) + require.True(t, pubKey.IsEqual(derivedKey)) +} + +// TestDerivePubKeyFetchManagerFails tests the failure case where the scoped +// key manager cannot be fetched. +func TestDerivePubKeyFetchManagerFails(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet and a test path. Configure the mock + // addrStore to return an error when fetching the key manager. + w, mocks := testWalletWithMocks(t) + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return((*mockAccountStore)(nil), errManagerNotFound).Once() + + // Act: Attempt to derive the public key. + _, err := w.DerivePubKey(t.Context(), path) + + // Assert: Check that the error is propagated correctly. + require.ErrorIs(t, err, errManagerNotFound) + mocks.addrStore.AssertExpectations(t) +} + +// TestDerivePubKeyDeriveFails tests the failure case where the key derivation +// from the path fails. +func TestDerivePubKeyDeriveFails(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, mocks, and a test path. Configure the + // mock account manager to return an error on derivation. + w, mocks := testWalletWithMocks(t) + path := BIP32Path{ + KeyScope: waddrmgr.KeyScopeBIP0084, + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: 0, + Branch: 0, + Index: 0, + }, + } + + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, path.DerivationPath, + ).Return((*mockManagedPubKeyAddr)(nil), errDerivationFailed).Once() + + // Act: Attempt to derive the public key. + _, err := w.DerivePubKey(t.Context(), path) + + // Assert: Check that the error is propagated correctly. + require.ErrorIs(t, err, errDerivationFailed) +} + +// TestDerivePubKeyNotPubKeyAddr tests the failure case where the derived +// address is not a public key address. +func TestDerivePubKeyNotPubKeyAddr(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet and mocks. Configure the mock derivation + // to return a managed address that is NOT a ManagedPubKeyAddress. + w, mocks := testWalletWithMocks(t) + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + + // We need a valid address for the error message. + addr, err := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.chainParams, + ) + require.NoError(t, err) + + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything, + ).Return(mocks.addr, nil).Once() + mocks.addr.On("Address").Return(addr).Once() + + // Act: Attempt to derive the public key. + _, err = w.DerivePubKey(t.Context(), path) + + // Assert: Check that the specific ErrNotPubKeyAddress is returned. + require.ErrorIs(t, err, ErrNotPubKeyAddress) + require.ErrorContains(t, err, "addr "+addr.String()) +} From 0a242e23b6aa8dc85317bf9d7edd13fe6bc4af5a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:36:18 +0800 Subject: [PATCH 152/691] wallet: implement `ECDH` --- wallet/signer.go | 28 +++++++++++++ wallet/signer_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index 996d913a04..ca80a33af2 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -523,3 +523,31 @@ func (w *Wallet) fetchManagedPubKeyAddress(path BIP32Path) ( return managedPubKeyAddr, nil } + +// ECDH performs a scalar multiplication (ECDH-like operation) between a key +// from the wallet and a remote public key. The output returned will be the +// sha256 of the resulting shared point serialized in compressed format. +func (w *Wallet) ECDH(_ context.Context, path BIP32Path, + pub *btcec.PublicKey) ([32]byte, error) { + + managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) + if err != nil { + return [32]byte{}, err + } + + // Get the private key for the derived address. + privKey, err := managedPubKeyAddr.PrivKey() + if err != nil { + return [32]byte{}, fmt.Errorf("cannot get private key: %w", + err) + } + defer privKey.Zero() + + // Perform the scalar multiplication and hash the result. + secret := btcec.GenerateSharedSecret(privKey, pub) + + var sharedSecret [32]byte + copy(sharedSecret[:], secret) + + return sharedSecret, nil +} diff --git a/wallet/signer_test.go b/wallet/signer_test.go index a838387270..5d4855a72e 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -5,6 +5,7 @@ package wallet import ( + "encoding/hex" "errors" "testing" @@ -238,3 +239,96 @@ func TestDerivePubKeyNotPubKeyAddr(t *testing.T) { require.ErrorIs(t, err, ErrNotPubKeyAddress) require.ErrorContains(t, err, "addr "+addr.String()) } + +// TestECDHSuccess tests the successful ECDH key exchange. +func TestECDHSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, mocks, and test keys. + w, mocks := testWalletWithMocks(t) + + // Use a hardcoded private key for deterministic test results. + privKey, _ := deterministicPrivKey(t) + + remoteKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + remotePubKey := remoteKey.PubKey() + + path := BIP32Path{ + KeyScope: waddrmgr.KeyScopeBIP0084, + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: 0, + Branch: 0, + Index: 0, + }, + } + + // Configure the full mock chain to return the test private key. + // + // NOTE: We must use a copy since the ECDH method will zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, path.DerivationPath, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Act: Perform the ECDH operation. + sharedSecret, err := w.ECDH(t.Context(), path, remotePubKey) + + // Assert: Check that the correct shared secret is returned. + require.NoError(t, err) + + // Calculate the expected secret independently to verify. + expectedSecret := btcec.GenerateSharedSecret(privKey, remotePubKey) + + var expectedSecretArray [32]byte + copy(expectedSecretArray[:], expectedSecret) + + require.Equal(t, expectedSecretArray, sharedSecret) + + // Finally, assert that the private key is zeroed out. + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) +} + +// TestECDHFails tests the failure case where the key derivation fails during +// an ECDH operation. +func TestECDHFails(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet and configure the mock addrStore to return + // an error. + w, mocks := testWalletWithMocks(t) + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + + remoteKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + remotePubKey := remoteKey.PubKey() + + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return((*mockAccountStore)(nil), errDerivationFailed).Once() + + // Act: Attempt to perform the ECDH operation. + _, err = w.ECDH(t.Context(), path, remotePubKey) + + // Assert: Check that the error is propagated correctly. + require.ErrorIs(t, err, errDerivationFailed) +} + +// deterministicPrivKey is a helper function that returns a deterministic +// private and public key pair for testing purposes. +func deterministicPrivKey(t *testing.T) (*btcec.PrivateKey, *btcec.PublicKey) { + t.Helper() + + pkBytes, err := hex.DecodeString("22a47fa09a223f2aa079edf85a7c2d4f87" + + "20ee63e502ee2869afab7de234b80c") + require.NoError(t, err) + + privKey, pubKey := btcec.PrivKeyFromBytes(pkBytes) + + return privKey, pubKey +} From 1b61ffa2619ecf8307c77cb98088d4f6fb90b6a5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:39:55 +0800 Subject: [PATCH 153/691] wallet: implement `SignMessage` This commit adds the concrete implementation for the `SignMessage` method on the `Wallet` struct, satisfying the `Signer` interface. The implementation uses the `fetchManagedPubKeyAddress` helper to efficiently and safely retrieve the required private key. The core signing logic is dispatched to new private helpers (`signMessageSchnorr` and `signMessageECDSA`) to handle the different signature schemes. This provides a complete and secure implementation for signing arbitrary messages. A `defer` statement is used to ensure the private key is zeroed from memory after use. --- wallet/signer.go | 88 +++++++++++++++++++++ wallet/signer_test.go | 179 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index ca80a33af2..9b54dc6757 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -551,3 +552,90 @@ func (w *Wallet) ECDH(_ context.Context, path BIP32Path, return sharedSecret, nil } + +// SignMessage signs a message based on the provided intent. +func (w *Wallet) SignMessage(_ context.Context, path BIP32Path, + intent *SignMessageIntent) (Signature, error) { + + managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) + if err != nil { + return nil, err + } + + // Get the private key for the derived address. + privKey, err := managedPubKeyAddr.PrivKey() + if err != nil { + return nil, fmt.Errorf("cannot get private key: %w", err) + } + defer privKey.Zero() + + // Now, sign the message using the derived private key. This is all + // pure computation, so it can be done outside the DB transaction. + return signMessageWithPrivKey(privKey, intent) +} + +// signMessageWithPrivKey performs the actual signing of a message with a given +// private key, based on the options specified in the SignMessageIntent. It +// acts as a dispatcher to the appropriate signing algorithm. +func signMessageWithPrivKey(privKey *btcec.PrivateKey, + intent *SignMessageIntent) (Signature, error) { + + // If Schnorr options are provided, we'll generate a Schnorr signature. + if intent.Schnorr != nil { + return signMessageSchnorr(privKey, intent) + } + + // Otherwise, we'll generate an ECDSA signature. + return signMessageECDSA(privKey, intent) +} + +// signMessageSchnorr performs the actual signing of a message with a given +// private key, using the Schnorr signature algorithm. +func signMessageSchnorr(privKey *btcec.PrivateKey, + intent *SignMessageIntent) (Signature, error) { + + if intent.Schnorr.Tweak != nil { + privKey = txscript.TweakTaprootPrivKey( + *privKey, intent.Schnorr.Tweak, + ) + } + + var digest []byte + if intent.Schnorr.Tag != nil { + taggedHash := chainhash.TaggedHash( + intent.Schnorr.Tag, intent.Msg, + ) + digest = taggedHash[:] + } else { + digest = chainhash.HashB(intent.Msg) + } + + sig, err := schnorr.Sign(privKey, digest) + if err != nil { + return nil, fmt.Errorf("cannot create schnorr sig: %w", err) + } + + return SchnorrSignature{sig}, nil +} + +// signMessageECDSA performs the actual signing of a message with a given +// private key, using the ECDSA signature algorithm. +func signMessageECDSA(privKey *btcec.PrivateKey, + intent *SignMessageIntent) (Signature, error) { + + var digest []byte + if intent.DoubleHash { + digest = chainhash.DoubleHashB(intent.Msg) + } else { + digest = chainhash.HashB(intent.Msg) + } + + if intent.CompactSig { + sig := ecdsa.SignCompact(privKey, digest, true) + return CompactSignature(sig), nil + } + + sig := ecdsa.Sign(privKey, digest) + + return ECDSASignature{sig}, nil +} diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 5d4855a72e..0a67a2ab91 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -10,7 +10,9 @@ import ( "testing" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -332,3 +334,180 @@ func deterministicPrivKey(t *testing.T) (*btcec.PrivateKey, *btcec.PublicKey) { return privKey, pubKey } + +// TestSignMessage tests the signing of a message with different signature +// types. +func TestSignMessage(t *testing.T) { + t.Parallel() + + // We'll use a common set of parameters for all signing test cases to + // ensure the only variable is the signing intent itself. + privKey, pubKey := deterministicPrivKey(t) + path := BIP32Path{ + KeyScope: waddrmgr.KeyScopeBIP0084, + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: 0, + Branch: 0, + Index: 0, + }, + } + msg := []byte("test message") + + testCases := []struct { + // name is the name of the test case. + name string + + // intent is the signing intent to use for the test. + intent *SignMessageIntent + + // verify is a function that verifies the signature produced by + // the signing intent. + verify func(t *testing.T, sig Signature, + pubKey *btcec.PublicKey) + }{ + { + name: "ECDSA success", + intent: &SignMessageIntent{ + Msg: msg, + DoubleHash: false, + CompactSig: false, + }, + verify: func(t *testing.T, sig Signature, + pubKey *btcec.PublicKey) { + + t.Helper() + + ecdsaSig, ok := sig.(ECDSASignature) + require.True(t, ok, "expected ECDSASignature") + msgHash := chainhash.HashB(msg) + require.True( + t, ecdsaSig.Verify(msgHash, pubKey), + "signature invalid", + ) + }, + }, + { + name: "ECDSA compact success", + intent: &SignMessageIntent{ + Msg: msg, + DoubleHash: true, + CompactSig: true, + }, + verify: func(t *testing.T, sig Signature, + pubKey *btcec.PublicKey) { + + t.Helper() + + compactSig, ok := sig.(CompactSignature) + require.True(t, ok, "expected CompactSignature") + msgHash := chainhash.DoubleHashB(msg) + recoveredKey, _, err := ecdsa.RecoverCompact( + compactSig, msgHash, + ) + require.NoError(t, err) + require.True( + t, recoveredKey.IsEqual(pubKey), + "recovered key mismatch", + ) + }, + }, + { + name: "Schnorr success", + intent: &SignMessageIntent{ + Msg: msg, + Schnorr: &SchnorrSignOpts{ + Tag: []byte("test tag"), + }, + }, + verify: func(t *testing.T, sig Signature, + pubKey *btcec.PublicKey) { + + t.Helper() + + schnorrSig, ok := sig.(SchnorrSignature) + require.True(t, ok, "expected SchnorrSignature") + + msgHash := chainhash.TaggedHash( + []byte("test tag"), msg, + ) + require.True(t, + schnorrSig.Verify(msgHash[:], pubKey), + "signature invalid", + ) + }, + }, + { + name: "Schnorr success with tweak", + intent: &SignMessageIntent{ + Msg: msg, + Schnorr: &SchnorrSignOpts{ + Tweak: []byte("test tweak"), + }, + }, + verify: func(t *testing.T, sig Signature, + pubKey *btcec.PublicKey) { + + t.Helper() + + schnorrSig, ok := sig.(SchnorrSignature) + require.True(t, ok, "expected SchnorrSignature") + + // Calculate expected tweaked key and hash + tweak := []byte("test tweak") + tweakedKey := txscript.TweakTaprootPrivKey( + *privKey, tweak, + ) + tweakedPub := tweakedKey.PubKey() + msgHash := chainhash.HashB(msg) + + require.True(t, + schnorrSig.Verify(msgHash, tweakedPub), + "signature invalid for tweaked key", + ) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Arrange: Set up a mock wallet that will return our + // deterministic private key for the specified + // derivation path. This allows us to test the signing + // logic in isolation. + w, mocks := testWalletWithMocks(t) + + // Configure the full mock chain to return the test + // private key. + // + // NOTE: We must use a copy since the ECDH method will + // zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes( + privKey.Serialize(), + ) + + mocks.addrStore.On( + "FetchScopedKeyManager", path.KeyScope, + ).Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, + path.DerivationPath, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return( + privKeyCopy, nil, + ).Once() + + // Act: Attempt to sign the message with the wallet. + sig, err := w.SignMessage(t.Context(), path, tc.intent) + + // Assert: Verify that the signature was created + // successfully and is valid for the given public key. + // We also assert that the private key was cleared from + // memory after the operation. + require.NoError(t, err) + tc.verify(t, sig, pubKey) + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) + }) + } +} From fb4d5bda906b8cadcb1ba6732c4872d591da2a2a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:43:51 +0800 Subject: [PATCH 154/691] wallet: implement `ComputeUnlockingScript` This commit adds the concrete implementation for the `ComputeUnlockingScript` method on the `Wallet` struct, satisfying the `Signer` interface. The implementation uses the `ScriptForOutput` method to retrieve the necessary address and script information. The core signing and script assembly logic is dispatched to a new private helper, `signAndAssembleScript`, which handles all supported single-signature address types (P2PKH, P2WKH, NP2WKH, and P2TR key-path). A `defer` statement is used to ensure the private key is zeroed from memory after use. --- wallet/signer.go | 107 +++++++++++++++ wallet/signer_test.go | 307 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index 9b54dc6757..0164f6e2c0 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -639,3 +639,110 @@ func signMessageECDSA(privKey *btcec.PrivateKey, return ECDSASignature{sig}, nil } + +// ComputeUnlockingScript generates the full sigScript and witness required to +// spend a UTXO. +func (w *Wallet) ComputeUnlockingScript(ctx context.Context, + params *UnlockingScriptParams) (*UnlockingScript, error) { + + // First, we'll fetch the managed address that corresponds to the + // output being spent. This will be used to look up the private key + // required for signing. + scriptInfo, err := w.ScriptForOutput(ctx, *params.Output) + if err != nil { + return nil, err + } + + // The address must be a public key address. + pubKeyAddr, ok := scriptInfo.Addr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil, fmt.Errorf("%w: addr %s", + ErrNotPubKeyAddress, scriptInfo.Addr.Address()) + } + + // Get the private key for the derived address. + privKey, err := pubKeyAddr.PrivKey() + if err != nil { + return nil, fmt.Errorf("cannot get private key: %w", err) + } + defer privKey.Zero() + + // If a tweaker is provided, we'll use it to tweak the private key. + if params.Tweaker != nil { + privKey, err = params.Tweaker(privKey) + if err != nil { + return nil, fmt.Errorf("error tweaking private key: %w", + err) + } + } + + // With the private key retrieved and tweaked, we can now generate the + // unlocking script. + return signAndAssembleScript(params, privKey, &scriptInfo) +} + +// signAndAssembleScript is a helper function that performs the final signing +// and script assembly for a given set of parameters and a private key. +func signAndAssembleScript(params *UnlockingScriptParams, + privKey *btcec.PrivateKey, + scriptInfo *Script) (*UnlockingScript, error) { + + // Dispatch to the correct signing logic based on the address type of + // the output. + switch scriptInfo.Addr.AddrType() { + // For Taproot key-path spends, we produce a Schnorr signature. + case waddrmgr.TaprootPubKey: + witness, err := txscript.TaprootWitnessSignature( + params.Tx, params.SigHashes, params.InputIndex, + params.Output.Value, params.Output.PkScript, + params.HashType, privKey, + ) + if err != nil { + return nil, fmt.Errorf("taproot witness error: %w", err) + } + + return &UnlockingScript{ + Witness: witness, + }, nil + + // For SegWit v0 outputs, we'll generate a standard ECDSA signature. + case waddrmgr.WitnessPubKey, waddrmgr.NestedWitnessPubKey: + witness, err := txscript.WitnessSignature( + params.Tx, params.SigHashes, params.InputIndex, + params.Output.Value, scriptInfo.WitnessProgram, + params.HashType, privKey, true, + ) + if err != nil { + return nil, fmt.Errorf("witness sig error: %w", err) + } + + return &UnlockingScript{ + Witness: witness, + SigScript: scriptInfo.RedeemScript, + }, nil + + // For legacy P2PKH outputs, we'll generate a signature script. + case waddrmgr.PubKeyHash: + sigScript, err := txscript.SignatureScript( + params.Tx, params.InputIndex, params.Output.PkScript, + params.HashType, privKey, true, + ) + if err != nil { + return nil, fmt.Errorf("sig script error: %w", err) + } + + return &UnlockingScript{ + SigScript: sigScript, + }, nil + + // The following address types are not supported by this function. + case waddrmgr.Script, waddrmgr.RawPubKey, waddrmgr.WitnessScript, + waddrmgr.TaprootScript: + return nil, fmt.Errorf("%w: %v", ErrUnsupportedAddressType, + scriptInfo.Addr.AddrType()) + + default: + return nil, fmt.Errorf("%w: %v", ErrUnsupportedAddressType, + scriptInfo.Addr.AddrType()) + } +} diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 0a67a2ab91..266840f7c0 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" @@ -511,3 +512,309 @@ func TestSignMessage(t *testing.T) { }) } } + +// TestComputeUnlockingScriptP2PKH tests that the wallet can generate a valid +// unlocking script for a P2PKH output. +func TestComputeUnlockingScriptP2PKH(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, keys, and a dummy transaction that will + // be used to spend the P2PKH output. + w, mocks := testWalletWithMocks(t) + privKey, pubKey := deterministicPrivKey(t) + + // Create a P2PKH address and the corresponding previous output script. + // This is the output we want to create an unlocking script for. + addr, err := btcutil.NewAddressPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), w.chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + + // The wallet needs to be able to find the private key for the given + // address. We mock the address store to return a mock address that, + // when queried, will provide the private key for signing. This + // simulates a real scenario where the wallet's address manager would + // fetch the key from the database. + mocks.addrStore.On("Address", + mock.Anything, addr, + ).Return(mocks.pubKeyAddr, nil) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash).Twice() + + // Configure the full mock chain to return the test private key. + // + // NOTE: We must use a copy since the ECDH method will zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + + // Act: With the setup complete, we can now ask the wallet to compute + // the unlocking script. + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + params := &UnlockingScriptParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } + script, err := w.ComputeUnlockingScript(t.Context(), params) + require.NoError(t, err) + + // Assert: The computed script should be a valid unlocking script for + // the P2PKH output. We verify this by creating a new script engine + // and executing it with the generated script. A successful execution + // proves the script is correct. + require.NotNil(t, script.SigScript) + require.Nil(t, script.Witness) + tx.TxIn[0].SignatureScript = script.SigScript + + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, 0, txscript.StandardVerifyFlags, nil, + sigHashes, prevOut.Value, fetcher, + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "script execution failed") + + // Finally, we ensure that the private key was not mutated during the + // signing process. + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) +} + +// TestComputeUnlockingScriptP2WKH tests that the wallet can generate a valid +// unlocking script for a P2WKH output. +func TestComputeUnlockingScriptP2WKH(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, keys, and a dummy transaction that will + // be used to spend the P2WKH output. + w, mocks := testWalletWithMocks(t) + privKey, pubKey := deterministicPrivKey(t) + + // Create a P2WKH address and the corresponding previous output script. + addr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), w.chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + + // The wallet needs to be able to find the private key for the given + // address. We mock the address store to return a mock address that, + // when queried, will provide the private key for signing. + mocks.addrStore.On("Address", + mock.Anything, addr, + ).Return(mocks.pubKeyAddr, nil) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Twice() + + // Configure the full mock chain to return the test private key. + // + // NOTE: We must use a copy since the ECDH method will zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + + // Act: With the setup complete, we can now ask the wallet to compute + // the unlocking script. + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + params := &UnlockingScriptParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } + script, err := w.ComputeUnlockingScript(t.Context(), params) + require.NoError(t, err) + + // Assert: The computed script should be a valid unlocking script. For + // P2WKH, this means a nil SigScript and a non-nil Witness. We verify + // this by creating a new script engine and executing it. + require.Nil(t, script.SigScript) + require.NotNil(t, script.Witness) + tx.TxIn[0].Witness = script.Witness + + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, 0, txscript.StandardVerifyFlags, nil, + sigHashes, prevOut.Value, fetcher, + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "script execution failed") + + // Finally, we ensure that the private key was not mutated during the + // signing process. + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) +} + +// TestComputeUnlockingScriptNP2WKH tests that the wallet can generate a valid +// unlocking script for a nested P2WKH output. +func TestComputeUnlockingScriptNP2WKH(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, keys, and a dummy transaction. + w, mocks := testWalletWithMocks(t) + privKey, pubKey := deterministicPrivKey(t) + + // Create a NP2WKH address. This is a P2WKH output nested within a + // P2SH output. This is done by creating the witness program first, + // and then using its hash in a P2SH script. + p2sh, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_0). + AddData(btcutil.Hash160(pubKey.SerializeCompressed())). + Script() + require.NoError(t, err) + addr, err := btcutil.NewAddressScriptHash(p2sh, w.chainParams) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + + // The wallet needs to be able to find the private key for the given + // address. We mock the address store to return a mock address that, + // when queried, will provide the private key for signing. For NP2WKH, + // the wallet also needs the public key to reconstruct the witness + // program, so we mock that as well. + mocks.addrStore.On("Address", + mock.Anything, addr, + ).Return(mocks.pubKeyAddr, nil) + mocks.pubKeyAddr.On("AddrType").Return( + waddrmgr.NestedWitnessPubKey).Twice() + mocks.pubKeyAddr.On("PubKey").Return(pubKey) + + // Configure the full mock chain to return the test private key. + // + // NOTE: We must use a copy since the ECDH method will zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + + // Act: With the setup complete, we can now ask the wallet to compute + // the unlocking script. + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + params := &UnlockingScriptParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } + script, err := w.ComputeUnlockingScript(t.Context(), params) + require.NoError(t, err) + + // Assert: The computed script should be a valid unlocking script. For + // NP2WKH, this means both a non-nil SigScript (containing the redeem + // script) and a non-nil Witness. We verify this by creating a new + // script engine and executing it. + require.NotNil(t, script.SigScript) + require.NotNil(t, script.Witness) + tx.TxIn[0].SignatureScript = script.SigScript + tx.TxIn[0].Witness = script.Witness + + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, 0, txscript.StandardVerifyFlags, nil, + sigHashes, prevOut.Value, fetcher, + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "script execution failed") + + // Finally, we ensure that the private key was not mutated during the + // signing process. + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) +} + +// TestComputeUnlockingScriptP2TR tests that the wallet can generate a valid +// unlocking script for a P2TR key-path spend. +func TestComputeUnlockingScriptP2TR(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, keys, and a dummy transaction. + w, mocks := testWalletWithMocks(t) + privKey, pubKey := deterministicPrivKey(t) + + // Create a P2TR address for a key-path spend. This involves computing + // the taproot output key from the internal public key. + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey( + txscript.ComputeTaprootOutputKey(pubKey, nil), + ), w.chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + + // The wallet needs to be able to find the private key for the given + // address. We mock the address store to return a mock address that, + // when queried, will provide the private key for signing. + mocks.addrStore.On("Address", + mock.Anything, addr, + ).Return(mocks.pubKeyAddr, nil) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.TaprootPubKey).Twice() + + // Configure the full mock chain to return the test private key. + // + // NOTE: We must use a copy since the ECDH method will zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + + // Act: With the setup complete, we can now ask the wallet to compute + // the unlocking script. For Taproot, we must use a multi-output + // fetcher, as the sighash calculation (specifically with + // SigHashDefault) requires access to all previous outputs being spent + // in the transaction. + fetcher := txscript.NewMultiPrevOutFetcher( + map[wire.OutPoint]*wire.TxOut{{Index: 0}: prevOut}, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + params := &UnlockingScriptParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashDefault, + } + script, err := w.ComputeUnlockingScript(t.Context(), params) + require.NoError(t, err) + + // Assert: The computed script should be a valid unlocking script. For a + // P2TR key-path spend, this means a nil SigScript and a non-nil + // Witness containing just the Schnorr signature. We verify this by + // creating a new script engine and executing it. + require.Nil(t, script.SigScript) + require.NotNil(t, script.Witness) + tx.TxIn[0].Witness = script.Witness + + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, 0, txscript.StandardVerifyFlags, nil, + sigHashes, prevOut.Value, fetcher, + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "script execution failed") + + // Finally, we ensure that the private key was not mutated during the + // signing process. + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) +} + +// createDummyTestTx creates a dummy transaction for testing purposes. +func createDummyTestTx(pkScript []byte) (*wire.TxOut, *wire.MsgTx) { + prevOut := wire.NewTxOut(100000, pkScript) + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&wire.OutPoint{Index: 0}, nil, nil)) + tx.AddTxOut(wire.NewTxOut(90000, nil)) + + return prevOut, tx +} From 030f706f260d2106b9f9878622cbe68dae7b003d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:48:13 +0800 Subject: [PATCH 155/691] wallet: implement `ComputeRawSig` This commit adds the concrete implementation for the `ComputeRawSig` method on the `Wallet` struct, satisfying the `Signer` interface. The implementation uses the `fetchManagedPubKeyAddress` helper to efficiently and safely retrieve the required private key. It then delegates the final, version-specific signing logic to the polymorphic `Sign` method on the `SpendDetails` object provided by the caller. A `defer` statement is used to ensure the private key is zeroed from memory after use. --- wallet/signer.go | 38 +++++++ wallet/signer_test.go | 233 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index 0164f6e2c0..6a14ee818f 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -746,3 +746,41 @@ func signAndAssembleScript(params *UnlockingScriptParams, scriptInfo.Addr.AddrType()) } } + +// ComputeRawSig generates a raw signature for a single transaction input. The +// caller is responsible for assembling the final witness. +func (w *Wallet) ComputeRawSig(_ context.Context, params *RawSigParams) ( + RawSignature, error) { + + // Get the managed address for the specified derivation path. This will + // be used to retrieve the private key. + managedAddr, err := w.fetchManagedPubKeyAddress(params.Path) + if err != nil { + return nil, err + } + + // Get the private key for the address. + privKey, err := managedAddr.PrivKey() + if err != nil { + return nil, fmt.Errorf("cannot get private key: %w", err) + } + defer privKey.Zero() + + // If a tweaker is provided, we'll use it to tweak the private key. + if params.Tweaker != nil { + privKey, err = params.Tweaker(privKey) + if err != nil { + return nil, fmt.Errorf("error tweaking private key: %w", + err) + } + } + + // With the private key retrieved and tweaked, we can now delegate the + // actual signing to the version-specific details object. + rawSig, err := params.Details.Sign(params, privKey) + if err != nil { + return nil, fmt.Errorf("cannot sign transaction: %w", err) + } + + return rawSig, nil +} diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 266840f7c0..b6d7b724eb 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -818,3 +818,236 @@ func createDummyTestTx(pkScript []byte) (*wire.TxOut, *wire.MsgTx) { return prevOut, tx } + +// TestComputeRawSigLegacy tests the successful signing of a legacy P2PKH +// input. +func TestComputeRawSigLegacy(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, mocks, and a deterministic private key. + w, mocks := testWalletWithMocks(t) + privKey, pubKey := deterministicPrivKey(t) + + // Create a P2PKH address from the public key. + pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash( + pubKeyHash, w.chainParams, + ) + require.NoError(t, err) + + // Create a previous output and a transaction to spend it. + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + + // Configure the full mock chain to return the test private key. + // + // NOTE: We must use a copy since the ECDH method will zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Create the raw signature parameters. + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + params := &RawSigParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Path: path, + Details: LegacySpendDetails{}, + } + + // Act: Compute the raw signature. + rawSig, err := w.ComputeRawSig(t.Context(), params) + require.NoError(t, err) + + // Assert: Verify that the signature is valid. + sigScript, err := txscript.NewScriptBuilder(). + AddData(rawSig). + AddData(pubKey.SerializeCompressed()). + Script() + require.NoError(t, err) + + tx.TxIn[0].SignatureScript = sigScript + + // The signature is valid if the script engine executes without error. + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, 0, txscript.StandardVerifyFlags, nil, + sigHashes, prevOut.Value, txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ), + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "signature verification failed") + + // Finally, assert that the private key is zeroed out. + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) +} + +// TestComputeRawSigSegwitV0 tests the successful signing of a SegWit v0 P2WKH +// input. +func TestComputeRawSigSegwitV0(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, mocks, and a deterministic private key. + w, mocks := testWalletWithMocks(t) + privKey, pubKey := deterministicPrivKey(t) + + // Create a P2WKH address from the public key. + pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) + addr, err := btcutil.NewAddressWitnessPubKeyHash( + pubKeyHash, w.chainParams, + ) + require.NoError(t, err) + + // Create a previous output and a transaction to spend it. + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + + // Configure the full mock chain to return the test private key. + // + // NOTE: We must use a copy since the ECDH method will zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Create the raw signature parameters. + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + witnessScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + params := &RawSigParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Path: path, + Details: SegwitV0SpendDetails{ + WitnessScript: witnessScript, + }, + } + + // Act: Compute the raw signature. + rawSig, err := w.ComputeRawSig(t.Context(), params) + require.NoError(t, err) + + // Assert: Verify that the signature is valid. + // We need to append the sighash type to the raw signature. + rawSig = append(rawSig, byte(txscript.SigHashAll)) + tx.TxIn[0].Witness = wire.TxWitness{ + rawSig, pubKey.SerializeCompressed(), + } + + // The signature is valid if the script engine executes without error. + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, 0, txscript.StandardVerifyFlags, nil, + sigHashes, prevOut.Value, txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ), + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "signature verification failed") + + // Finally, assert that the private key is zeroed out. + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) +} + +// TestComputeRawSigTaproot tests the successful signing of a Taproot P2TR +// input using the key-path spend. +func TestComputeRawSigTaproot(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, mocks, and a deterministic private key. + w, mocks := testWalletWithMocks(t) + privKey, internalKey := deterministicPrivKey(t) + + // Create a P2TR address from the public key. + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey( + txscript.ComputeTaprootOutputKey(internalKey, nil), + ), w.chainParams, + ) + require.NoError(t, err) + + // Create a previous output and a transaction to spend it. + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + + // Configure the full mock chain to return the test private key. + // + // NOTE: We must use a copy since the ECDH method will zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0086} + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Create the raw signature parameters. + fetcher := txscript.NewMultiPrevOutFetcher( + map[wire.OutPoint]*wire.TxOut{ + {Index: 0}: prevOut, + }, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + params := &RawSigParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashDefault, + Path: path, + Details: TaprootSpendDetails{ + SpendPath: KeyPathSpend, + }, + } + + // Act: Compute the raw signature. + rawSig, err := w.ComputeRawSig(t.Context(), params) + require.NoError(t, err) + + // Assert: Verify that the signature is valid. + tx.TxIn[0].Witness = wire.TxWitness{rawSig} + vm, err := txscript.NewEngine( + pkScript, tx, 0, txscript.StandardVerifyFlags, nil, sigHashes, + prevOut.Value, txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ), + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "signature verification failed") + + // Finally, assert that the private key is zeroed out. + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) +} From 5f7026fe69c5f878166db3255d8682e2beeb7464 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 13:53:51 +0800 Subject: [PATCH 156/691] wallet: implement `UnsafeSigner` methods This commit adds the concrete implementations for the `DerivePrivKey` and `GetPrivKeyForAddress` methods on the `Wallet` struct, satisfying the `UnsafeSigner` interface. `DerivePrivKey` reuses the efficient `fetchManagedPubKeyAddress` helper to get the private key for a given BIP-32 path. `GetPrivKeyForAddress` is also implemented, providing a way to retrieve private keys for imported addresses that do not have a derivation path. Finally, compile-time interface checks are added to ensure that the `Wallet` struct correctly implements both the `Signer` and `UnsafeSigner` interfaces. --- wallet/signer.go | 34 ++++++++++++++++++++++++++ wallet/signer_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index 6a14ee818f..02c64546cf 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -96,6 +96,11 @@ type UnsafeSigner interface { *btcec.PrivateKey, error) } +// A compile-time check to ensure that Wallet implements the Signer and +// UnsafeSigner interfaces. +var _ Signer = (*Wallet)(nil) +var _ UnsafeSigner = (*Wallet)(nil) + // BIP32Path contains the full information needed to derive a key from the // wallet's master seed, as defined by BIP-32. It combines the high-level key // scope with the specific derivation path. @@ -784,3 +789,32 @@ func (w *Wallet) ComputeRawSig(_ context.Context, params *RawSigParams) ( return rawSig, nil } + +// DerivePrivKey derives a private key from a full BIP-32 derivation +// path. +// +// DANGER: This method exports sensitive key material. +func (w *Wallet) DerivePrivKey(_ context.Context, path BIP32Path) ( + *btcec.PrivateKey, error) { + + managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) + if err != nil { + return nil, err + } + + privKey, err := managedPubKeyAddr.PrivKey() + if err != nil { + return nil, fmt.Errorf("cannot get private key: %w", err) + } + + return privKey, nil +} + +// GetPrivKeyForAddress returns the private key for a given address. +// +// DANGER: This method exports sensitive key material. +func (w *Wallet) GetPrivKeyForAddress(_ context.Context, a btcutil.Address) ( + *btcec.PrivateKey, error) { + + return w.PrivKeyForAddress(a) +} diff --git a/wallet/signer_test.go b/wallet/signer_test.go index b6d7b724eb..7aab80c93f 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -1051,3 +1051,60 @@ func TestComputeRawSigTaproot(t *testing.T) { // Finally, assert that the private key is zeroed out. require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) } + +// TestDerivePrivKeySuccess tests the successful derivation of a private key. +func TestDerivePrivKeySuccess(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet with mocks, a test key, and a + // derivation path. + w, mocks := testWalletWithMocks(t) + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + path := BIP32Path{ + KeyScope: waddrmgr.KeyScopeBIP0084, + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: 0, + Branch: 0, + Index: 0, + }, + } + + // Set up the mock account manager and the mock address that will be + // returned by the derivation call. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, path.DerivationPath, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Act: Derive the private key. + derivedKey, err := w.DerivePrivKey(t.Context(), path) + + // Assert: Check that the correct key is returned without error. + require.NoError(t, err) + require.Equal(t, privKey.Serialize(), derivedKey.Serialize()) +} + +// TestDerivePrivKeyFails tests the failure case where the key derivation fails. +func TestDerivePrivKeyFails(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet and a test path. Configure the mock + // addrStore to return an error when fetching the key manager. + w, mocks := testWalletWithMocks(t) + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return((*mockAccountStore)(nil), errManagerNotFound).Once() + + // Act: Attempt to derive the private key. + _, err := w.DerivePrivKey(t.Context(), path) + + // Assert: Check that the error is propagated correctly. + require.ErrorIs(t, err, errManagerNotFound) +} From 8e0c2708295b7bee0fb6a312a696168a05dc3edc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 28 Sep 2025 12:44:12 +0800 Subject: [PATCH 157/691] wallet: Enforce security boundary in `ScriptForOutput` The ScriptForOutput function previously returned a `waddrmgr.ManagedPubKeyAddress`. This interface includes a `PrivKey()` method, which meant that a component designed for managing public address information (the AddressManager) was leaking an object that could be used to access private keys. This is a leaky abstraction and violates the principle of separation of concerns. This commit refactors ScriptForOutput to return the more general `waddrmgr.ManagedAddress` interface instead. This interface does not provide access to private key material. This change enforces a clean architectural boundary between the AddressManager (responsible for public data) and the Signer (responsible for private key operations). The Signer is now the sole component responsible for retrieving and using private keys, which is its intended role. This improves the security posture and clarity of the API. --- wallet/address_manager.go | 34 +++++++++++++++++++++++----------- wallet/deprecated.go | 10 +++++++++- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 31c65838f7..63f6bbd95c 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -47,6 +47,10 @@ var ( "address is not a p2wkh or np2wkh address", ) + // ErrUnableToExtractAddress is returned when an address cannot be + // extracted from a pkscript. + ErrUnableToExtractAddress = errors.New("unable to extract address") + // errStopIteration is a special error used to stop the iteration in // ForEachAccountAddress. errStopIteration = errors.New("stop iteration") @@ -65,7 +69,7 @@ type AddressProperty struct { // Script represents the script information required to spend a UTXO. type Script struct { // Addr is the managed address of the UTXO. - Addr waddrmgr.ManagedPubKeyAddress + Addr waddrmgr.ManagedAddress // WitnessProgram is the witness program of the UTXO. WitnessProgram []byte @@ -691,20 +695,28 @@ func (w *Wallet) ImportTaprootScript(_ context.Context, // - The operation is dominated by the database lookup for the address, which // is typically fast (O(log N) or O(1) with indexing). The script // generation is a constant-time operation. -func (w *Wallet) ScriptForOutput(_ context.Context, output wire.TxOut) ( +func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( Script, error) { - // First make sure we can sign for the input by making sure the script - // in the UTXO belongs to our wallet and we have the private key for it. - walletAddr, err := w.fetchOutputAddr(output.PkScript) + // First, we'll extract the address from the output's pkScript. + addr := extractAddrFromPKScript(output.PkScript, w.chainParams) + if addr == nil { + return Script{}, fmt.Errorf("%w: from pkscript %x", + ErrUnableToExtractAddress, output.PkScript) + } + + // We'll then use the address to look up the managed address from the + // database. + managedAddr, err := w.AddressInfo(ctx, addr) if err != nil { - return Script{}, err + return Script{}, fmt.Errorf("unable to get address info "+ + "for %s: %w", addr.String(), err) } - pubKeyAddr, ok := walletAddr.(waddrmgr.ManagedPubKeyAddress) + pubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) if !ok { - return Script{}, fmt.Errorf("%w: %s", ErrNotPubKeyAddress, - walletAddr.Address()) + return Script{}, fmt.Errorf("%w: addr %s", + ErrNotPubKeyAddress, managedAddr.Address()) } var ( @@ -715,7 +727,7 @@ func (w *Wallet) ScriptForOutput(_ context.Context, output wire.TxOut) ( switch { // If we're spending p2wkh output nested within a p2sh output, then // we'll need to attach a sigScript in addition to witness data. - case walletAddr.AddrType() == waddrmgr.NestedWitnessPubKey: + case managedAddr.AddrType() == waddrmgr.NestedWitnessPubKey: pubKey := pubKeyAddr.PubKey() pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) @@ -753,7 +765,7 @@ func (w *Wallet) ScriptForOutput(_ context.Context, output wire.TxOut) ( } return Script{ - Addr: pubKeyAddr, + Addr: managedAddr, WitnessProgram: witnessProgram, RedeemScript: sigScript, }, nil diff --git a/wallet/deprecated.go b/wallet/deprecated.go index c84f295ca5..eeba33077f 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -3,6 +3,7 @@ package wallet import ( "context" + "fmt" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -167,7 +168,14 @@ func (w *Wallet) ScriptForOutputDeprecated(output *wire.TxOut) ( return nil, nil, nil, err } - return script.Addr, script.WitnessProgram, script.RedeemScript, nil + addr := script.Addr + pubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil, nil, nil, fmt.Errorf("%w: addr %s", + ErrNotPubKeyAddress, addr.Address()) + } + + return pubKeyAddr, script.WitnessProgram, script.RedeemScript, nil } // ComputeInputScript generates a complete InputScript for the passed From 68f92181101ba9369fa61de6347b77d62501c53d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 24 Nov 2025 18:09:35 +0800 Subject: [PATCH 158/691] wallet: finalize `Signer` tests to increase test coverage This commit finalizes the `Signer` implementation and adds comprehensive unit tests to increase the test coverage rate to be 94%. --- wallet/mock_test.go | 24 ++ wallet/signer_test.go | 806 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 825 insertions(+), 5 deletions(-) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 9646b53625..64df84fdd2 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -1046,3 +1046,27 @@ func (m *mockManagedPubKeyAddr) DerivationInfo() (waddrmgr.KeyScope, return args.Get(0).(waddrmgr.KeyScope), args.Get(1).(waddrmgr.DerivationPath), args.Bool(2) } + +// mockSpendDetails is a mock implementation of the SpendDetails interface. +type mockSpendDetails struct { + mock.Mock +} + +// A compile-time assertion to ensure that mockSpendDetails implements the +// SpendDetails interface. +var _ SpendDetails = (*mockSpendDetails)(nil) + +// Sign implements the SpendDetails interface. +func (m *mockSpendDetails) Sign(params *RawSigParams, + privKey *btcec.PrivateKey) (RawSignature, error) { + + args := m.Called(params, privKey) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(RawSignature), args.Error(1) +} + +// isSpendDetails implements the SpendDetails interface. +func (m *mockSpendDetails) isSpendDetails() {} diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 7aab80c93f..7c5c91d858 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -27,6 +27,15 @@ var ( // errDerivationFailed is returned when a key derivation fails. errDerivationFailed = errors.New("derivation failed") + + // errPrivKeyMock is a mock error for private key retrieval. + errPrivKeyMock = errors.New("privkey error") + + // errTweakMock is a mock error for private key tweaking. + errTweakMock = errors.New("tweak error") + + // errSignMock is a mock error for signing operations. + errSignMock = errors.New("sign error") ) // TestComputeInputScript checks that the wallet can create the full @@ -513,6 +522,36 @@ func TestSignMessage(t *testing.T) { } } +// TestSignMessageFail tests failure modes of SignMessage. +func TestSignMessageFail(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + intent := &SignMessageIntent{Msg: []byte("test")} + + // Test Case 1: Fetching the key manager fails. + // We expect an `errManagerNotFound` error to be returned. + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return((*mockAccountStore)(nil), errManagerNotFound).Once() + + _, err := w.SignMessage(t.Context(), path, intent) + require.ErrorIs(t, err, errManagerNotFound) + + // Test Case 2: Obtaining the private key for signing fails. + // We expect a `privkey error` to be returned. + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return((*btcec.PrivateKey)(nil), + errPrivKeyMock).Once() + + _, err = w.SignMessage(t.Context(), path, intent) + require.ErrorContains(t, err, "privkey error") +} + // TestComputeUnlockingScriptP2PKH tests that the wallet can generate a valid // unlocking script for a P2PKH output. func TestComputeUnlockingScriptP2PKH(t *testing.T) { @@ -809,6 +848,213 @@ func TestComputeUnlockingScriptP2TR(t *testing.T) { require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) } +// TestComputeUnlockingScriptFail tests various failure modes of +// ComputeUnlockingScript. +func TestComputeUnlockingScriptFail(t *testing.T) { + t.Parallel() + + // Arrange: Set up common test data (keys, address, transaction) used + // across subtests. + privKey, pubKey := deterministicPrivKey(t) + + // Create P2PKH for testing. + addr, err := btcutil.NewAddressPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + t.Run("ScriptForOutput Fail", func(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet and mocks. + w, mocks := testWalletWithMocks(t) + + // Mock the address store to return an error when looking up + // the address info. This simulates a case where the address is + // not found or there is a database error. + mocks.addrStore.On("Address", mock.Anything, addr). + Return((*mockManagedAddress)(nil), + errManagerNotFound).Once() + + params := &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } + + // Act: Attempt to compute the unlocking script. + _, err = w.ComputeUnlockingScript(t.Context(), params) + + // Assert: Verify that the error from the address store is + // wrapped and returned. + require.ErrorContains(t, err, "unable to get address info") + }) + + t.Run("PrivKey Fail", func(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet and mocks. + w, mocks := testWalletWithMocks(t) + + // Mock the address store to return a valid managed address. + mocks.addrStore.On("Address", mock.Anything, addr). + Return(mocks.pubKeyAddr, nil).Once() + + // Mock the managed address to return the correct type (P2PKH). + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) + + // Mock the private key retrieval to fail. This simulates a + // case where the private key cannot be decrypted or found. + mocks.pubKeyAddr.On("PrivKey").Return((*btcec.PrivateKey)(nil), + errPrivKeyMock).Once() + + params := &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } + + // Act: Attempt to compute the unlocking script. + _, err = w.ComputeUnlockingScript(t.Context(), params) + + // Assert: Verify that the private key retrieval error is + // returned. + require.ErrorContains(t, err, "privkey error") + }) + + t.Run("Tweak Fail", func(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet and mocks. + w, mocks := testWalletWithMocks(t) + + // Mock the address store to return a valid managed address. + mocks.addrStore.On("Address", mock.Anything, addr). + Return(mocks.pubKeyAddr, nil).Once() + + // Mock the managed address to return the correct type (P2PKH). + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) + + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + // Mock the private key retrieval to succeed. + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Define a custom tweaker function that always fails. + params := &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Tweaker: func(*btcec.PrivateKey) ( + *btcec.PrivateKey, error) { + + return nil, errTweakMock + }, + } + + // Act: Attempt to compute the unlocking script with the + // failing tweaker. + _, err = w.ComputeUnlockingScript(t.Context(), params) + + // Assert: Verify that the tweaker error is returned. + require.ErrorContains(t, err, "tweak error") + }) + + t.Run("Unsupported Address Type", func(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet and mocks. + w, mocks := testWalletWithMocks(t) + + // Mock the address store to return a valid managed address. + mocks.addrStore.On("Address", mock.Anything, addr). + Return(mocks.pubKeyAddr, nil).Once() + + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + // Mock the private key retrieval to succeed. + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Mock the address type to be one that is not supported for + // unlocking script generation (e.g., RawPubKey). + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.RawPubKey) + + params := &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } + + // Act: Attempt to compute the unlocking script. + _, err = w.ComputeUnlockingScript(t.Context(), params) + + // Assert: Verify that the unsupported address type error is + // returned. + require.ErrorIs(t, err, ErrUnsupportedAddressType) + }) +} + +// TestComputeUnlockingScriptUnknownAddrType tests the default case in +// signAndAssembleScript by using an address with an unknown type. +func TestComputeUnlockingScriptUnknownAddrType(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, mocks, keys, and transaction. + w, mocks := testWalletWithMocks(t) + + privKey, pubKey := deterministicPrivKey(t) + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + addr, err := btcutil.NewAddressPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), w.chainParams, + ) + require.NoError(t, err) + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + + // Mock address lookup to return a valid managed address. + mocks.addrStore.On("Address", mock.Anything, addr). + Return(mocks.pubKeyAddr, nil).Once() + + // Mock private key retrieval to succeed. + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Mock the address type to return an unknown type (e.g. 99) that falls + // through the switch statement in signAndAssembleScript. + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.AddressType(99)) + + fetcher := txscript.NewCannedPrevOutputFetcher(pkScript, 10000) + + params := &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: txscript.NewTxSigHashes(tx, fetcher), + HashType: txscript.SigHashAll, + } + + // Act: Attempt to compute the unlocking script. + _, err = w.ComputeUnlockingScript(t.Context(), params) + + // Assert: Verify that the unsupported address type error is returned. + require.ErrorIs(t, err, ErrUnsupportedAddressType) +} + // createDummyTestTx creates a dummy transaction for testing purposes. func createDummyTestTx(pkScript []byte) (*wire.TxOut, *wire.MsgTx) { prevOut := wire.NewTxOut(100000, pkScript) @@ -819,9 +1065,9 @@ func createDummyTestTx(pkScript []byte) (*wire.TxOut, *wire.MsgTx) { return prevOut, tx } -// TestComputeRawSigLegacy tests the successful signing of a legacy P2PKH +// TestComputeRawSigLegacyP2PKH tests the successful signing of a legacy P2PKH // input. -func TestComputeRawSigLegacy(t *testing.T) { +func TestComputeRawSigLegacyP2PKH(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, mocks, and a deterministic private key. @@ -897,6 +1143,73 @@ func TestComputeRawSigLegacy(t *testing.T) { require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) } +// TestComputeRawSigLegacyP2SH tests the signing of a legacy P2SH input. +func TestComputeRawSigLegacyP2SH(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet with mocks and a deterministic private + // key for testing. + w, mocks := testWalletWithMocks(t) + privKey, pubKey := deterministicPrivKey(t) + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + // Create a P2SH redeem script. This involves pushing the public key + // and the CHECKSIG opcode. + redeemScript, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_DATA_33). + AddData(pubKey.SerializeCompressed()). + AddOp(txscript.OP_CHECKSIG). + Script() + require.NoError(t, err) + + // Create the P2SH address corresponding to the redeem script hash. + addr, err := btcutil.NewAddressScriptHash(redeemScript, w.chainParams) + require.NoError(t, err) + + // Create the Pay-To-Addr script (P2SH script) which will be the + // pkScript of the previous output. + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + // Create a dummy transaction and a previous output to spend. + prevOut, tx := createDummyTestTx(pkScript) + + // Configure the address manager mock to return the correct key manager + // and address information. P2SH addresses use BIP0049 derivation scope. + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0049Plus} + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Prepare the inputs for the signing operation. + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + params := &RawSigParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Path: path, + Details: LegacySpendDetails{ + RedeemScript: redeemScript, + }, + } + + // Act: Compute the raw signature using the wallet. + rawSig, err := w.ComputeRawSig(t.Context(), params) + + // Assert: Verify that no error occurred and a signature was generated. + require.NoError(t, err) + require.NotEmpty(t, rawSig) +} + // TestComputeRawSigSegwitV0 tests the successful signing of a SegWit v0 P2WKH // input. func TestComputeRawSigSegwitV0(t *testing.T) { @@ -977,9 +1290,9 @@ func TestComputeRawSigSegwitV0(t *testing.T) { require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) } -// TestComputeRawSigTaproot tests the successful signing of a Taproot P2TR -// input using the key-path spend. -func TestComputeRawSigTaproot(t *testing.T) { +// TestComputeRawSigTaprootKeySpendPath tests the successful signing of a +// Taproot P2TR input using the key-path spend. +func TestComputeRawSigTaprootKeySpendPath(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, mocks, and a deterministic private key. @@ -1052,6 +1365,354 @@ func TestComputeRawSigTaproot(t *testing.T) { require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) } +// TestComputeRawSigTaprootScriptPath tests the successful signing of a Taproot +// P2TR input using the script-path spend. +func TestComputeRawSigTaprootScriptPath(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, mocks, and a deterministic private key. + w, mocks := testWalletWithMocks(t) + privKey, internalKey := deterministicPrivKey(t) + + // Create a script to spend. + script, err := txscript.NewScriptBuilder(). + AddData(schnorr.SerializePubKey(internalKey)). + AddOp(txscript.OP_CHECKSIG). + Script() + require.NoError(t, err) + + leaf := txscript.NewBaseTapLeaf(script) + tapScriptTree := txscript.AssembleTaprootScriptTree(leaf) + rootHash := tapScriptTree.RootNode.TapHash() + outputKey := txscript.ComputeTaprootOutputKey(internalKey, rootHash[:]) + + // Create a P2TR address from the output key. + addr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(outputKey), w.chainParams, + ) + require.NoError(t, err) + + // Create a previous output and a transaction to spend it. + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + + // Configure the full mock chain to return the test private key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0086} + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Create the raw signature parameters. + fetcher := txscript.NewMultiPrevOutFetcher( + map[wire.OutPoint]*wire.TxOut{ + {Index: 0}: prevOut, + }, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + params := &RawSigParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashDefault, + Path: path, + Details: TaprootSpendDetails{ + SpendPath: ScriptPathSpend, + WitnessScript: script, + }, + } + + // Act: Compute the raw signature. + rawSig, err := w.ComputeRawSig(t.Context(), params) + require.NoError(t, err) + + // Assert: Verify that the signature is valid. + // For script path, we need the control block. + ctrlBlock := tapScriptTree.LeafMerkleProofs[0].ToControlBlock( + internalKey, + ) + ctrlBlockBytes, err := ctrlBlock.ToBytes() + require.NoError(t, err) + + tx.TxIn[0].Witness = wire.TxWitness{ + rawSig, script, ctrlBlockBytes, + } + vm, err := txscript.NewEngine( + pkScript, tx, 0, txscript.StandardVerifyFlags, nil, sigHashes, + prevOut.Value, txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ), + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "signature verification failed") + + // Finally, assert that the private key is zeroed out. + require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) +} + +// TestComputeRawSigFail tests various failure modes of ComputeRawSig. +func TestComputeRawSigFail(t *testing.T) { + t.Parallel() + + privKey, _ := deterministicPrivKey(t) + + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + prevOut := &wire.TxOut{PkScript: []byte{0x00}} + tx := wire.NewMsgTx(2) + + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + // This subtest ensures that if fetching the key manager fails during + // the raw signature computation, the error is correctly propagated. + t.Run("Fetch Address Fail", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return((*mockAccountStore)(nil), + errManagerNotFound).Once() + + params := &RawSigParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Path: path, + Details: LegacySpendDetails{}, + } + + _, err := w.ComputeRawSig(t.Context(), params) + require.ErrorIs(t, err, errManagerNotFound) + }) + + // This subtest ensures that if obtaining the private key from the + // managed address fails during raw signature computation, the error is + // correctly propagated. + t.Run("PrivKey Fail", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil).Once() + + mocks.pubKeyAddr.On("PrivKey").Return((*btcec.PrivateKey)(nil), + errPrivKeyMock).Once() + + params := &RawSigParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Path: path, + Details: LegacySpendDetails{}, + } + + _, err := w.ComputeRawSig(t.Context(), params) + require.ErrorContains(t, err, "privkey error") + }) + + // This subtest verifies that if the private key tweaking function + // returns an error, the raw signature computation correctly propagates + // that error. + t.Run("Tweak Fail", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil).Once() + + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + params := &RawSigParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Path: path, + Details: LegacySpendDetails{}, + Tweaker: func(*btcec.PrivateKey) ( + *btcec.PrivateKey, error) { + + return nil, errTweakMock + }, + } + + _, err := w.ComputeRawSig(t.Context(), params) + require.ErrorContains(t, err, "tweak error") + }) + + // This subtest ensures that if the underlying `Sign` method of the + // spend details returns an error, the raw signature computation + // correctly propagates that error. + t.Run("Sign Fail", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil).Once() + + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + params := &RawSigParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Path: path, + Details: LegacySpendDetails{}, + } + mockDetails := &mockSpendDetails{} + params.Details = mockDetails + + mockDetails.On("Sign", params, privKeyCopy). + Return((RawSignature)(nil), + errSignMock) + _, err := w.ComputeRawSig(t.Context(), params) + require.ErrorContains(t, err, "sign error") + mockDetails.AssertExpectations(t) + }) + + // This subtest verifies that an error is returned when an unsupported + // Taproot spend path is provided, ensuring robust error handling for + // invalid configurations. + t.Run("Invalid Taproot Path", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0086} + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil).Once() + + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + params := &RawSigParams{ + Tx: wire.NewMsgTx(2), + Path: path, + Details: TaprootSpendDetails{ + SpendPath: TaprootSpendPath(99), // Invalid path + }, + } + + _, err := w.ComputeRawSig(t.Context(), params) + require.ErrorIs(t, err, ErrUnknownSignMethod) + }) + + // This subtest verifies that if the SegWit v0 signing process fails + // (e.g., due to invalid parameters like an invalid hash type), the + // error is correctly propagated. + t.Run("Segwit Sign Fail", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil).Once() + + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + params := &RawSigParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: 0xff, + Path: path, + Details: SegwitV0SpendDetails{ + WitnessScript: []byte{}, + }, + } + + _, err := w.ComputeRawSig(t.Context(), params) + require.Error(t, err) + }) + + // This subtest verifies that if the Taproot KeyPath signing process + // fails (e.g., due to invalid parameters), the error is correctly + // propagated. + t.Run("Taproot KeyPath Sign Fail", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil).Once() + + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + params := &RawSigParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: 0xff, + Path: path, + Details: TaprootSpendDetails{SpendPath: KeyPathSpend}, + } + + _, err := w.ComputeRawSig(t.Context(), params) + require.Error(t, err) + }) + + // This subtest verifies that if the Taproot ScriptPath signing process + // fails (e.g., due to invalid parameters), the error is correctly + // propagated. + t.Run("Taproot ScriptPath Sign Fail", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPath", + mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil).Once() + + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + params := &RawSigParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: 0xff, + Path: path, + Details: TaprootSpendDetails{ + SpendPath: ScriptPathSpend, + WitnessScript: []byte{0x51}, + }, + } + + _, err := w.ComputeRawSig(t.Context(), params) + require.Error(t, err) + }) +} + // TestDerivePrivKeySuccess tests the successful derivation of a private key. func TestDerivePrivKeySuccess(t *testing.T) { t.Parallel() @@ -1108,3 +1769,138 @@ func TestDerivePrivKeyFails(t *testing.T) { // Assert: Check that the error is propagated correctly. require.ErrorIs(t, err, errManagerNotFound) } + +// TestGetPrivKeyForAddressSuccess tests the successful retrieval of a private +// key by address. +func TestGetPrivKeyForAddressSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet, mocks, and a deterministic private key. + w, mocks := testWalletWithMocks(t) + privKey, pubKey := deterministicPrivKey(t) + + // Create a P2PKH address from the public key. + pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) + addr, err := btcutil.NewAddressPubKeyHash( + pubKeyHash, w.chainParams, + ) + require.NoError(t, err) + + // Configure the mock chain to return the test private key. + // + // NOTE: We must use a copy since the method will zero out the key. + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + + mocks.addrStore.On("Address", mock.Anything, addr). + Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + + // Act: Get the private key for the address. + retrievedKey, err := w.GetPrivKeyForAddress(t.Context(), addr) + + // Assert: Check that the correct key is returned. + require.NoError(t, err) + require.Equal(t, privKey.Serialize(), retrievedKey.Serialize()) +} + +// TestGetPrivKeyForAddressFail tests the failure cases for retrieval of a +// private key by address. +func TestGetPrivKeyForAddressFail(t *testing.T) { + t.Parallel() + + // Arrange: Set up the wallet and mocks. + w, mocks := testWalletWithMocks(t) + addr, err := btcutil.NewAddressPubKeyHash( + make([]byte, 20), w.chainParams, + ) + require.NoError(t, err) + + // Case 1: Address lookup fails. + mocks.addrStore.On("Address", mock.Anything, addr). + Return((*mockManagedAddress)(nil), errManagerNotFound).Once() + + _, err = w.GetPrivKeyForAddress(t.Context(), addr) + require.ErrorIs(t, err, errManagerNotFound) + + // Case 2: Address is not a pubkey address. + // We need a separate mock for this to ensure clean separation. + // + // NOTE: We can reuse the existing mocks but need to reset expectations + // or ensure ordering. Since we are in a single test function, we can + // just sequence them. + mockScriptAddr := &mockManagedAddress{} + mocks.addrStore.On("Address", mock.Anything, addr). + Return(mockScriptAddr, nil).Once() + + _, err = w.GetPrivKeyForAddress(t.Context(), addr) + require.ErrorIs(t, err, ErrNoAssocPrivateKey) +} + +// TestDerivePrivKeyFail tests failure modes of DerivePrivKey. +func TestDerivePrivKeyFail(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + + // Test Case 1: Fetching key manager fails. + // + // We mock the address store to return an error when fetching the key + // manager. We expect this error to be propagated. + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return((*mockAccountStore)(nil), errManagerNotFound).Once() + + _, err := w.DerivePrivKey(t.Context(), path) + require.ErrorIs(t, err, errManagerNotFound) + + // Test Case 2: PrivKey retrieval fails. + // + // We mock the key manager to return a valid address, but mock the + // address to return an error when fetching the private key. We expect + // a wrapped error indicating the failure. + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey"). + Return((*btcec.PrivateKey)(nil), errPrivKeyMock).Once() + + _, err = w.DerivePrivKey(t.Context(), path) + require.ErrorContains(t, err, "cannot get private key") +} + +// TestECDHFail tests failure modes of ECDH. +func TestECDHFail(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + privKey, _ := btcec.NewPrivateKey() + + // Test Case 1: Fetching key manager fails. + // + // We mock the address store to return an error when fetching the key + // manager. We expect this error to be propagated. + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return((*mockAccountStore)(nil), errManagerNotFound).Once() + + _, err := w.ECDH(t.Context(), path, privKey.PubKey()) + require.ErrorIs(t, err, errManagerNotFound) + + // Test Case 2: PrivKey retrieval fails. + // + // We mock the key manager to return a valid address, but mock the + // address to return an error when fetching the private key. We expect + // a wrapped error indicating the failure. + mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("PrivKey"). + Return((*btcec.PrivateKey)(nil), errPrivKeyMock).Once() + + _, err = w.ECDH(t.Context(), path, privKey.PubKey()) + require.ErrorContains(t, err, "cannot get private key") +} From 8176098f2ec72559da26ca65a32411ca99601be4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 25 Nov 2025 09:50:47 +0800 Subject: [PATCH 159/691] wallet: optimize test execution speed This commit speeds up unit tests in the wallet package by setting the waddrmgr.DefaultScryptOptions to waddrmgr.FastScryptOptions during tests. This avoids excessive CPU usage during parallel test execution caused by the default, high-work scrypt parameters used for wallet creation. --- wallet/example_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/wallet/example_test.go b/wallet/example_test.go index cc0e0c3b51..431815f7bc 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -188,3 +188,9 @@ func testWalletWatchingOnly(t *testing.T) *Wallet { return w } + +func init() { + // Use fast scrypt options for tests to avoid CPU exhaustion and + // timeouts, especially when running with -race. + waddrmgr.DefaultScryptOptions = waddrmgr.FastScryptOptions +} From 8020ac847f3e35ed4d3ce3a2dd816ba6c553ebcc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 25 Nov 2025 20:45:00 +0800 Subject: [PATCH 160/691] wallet: rename `SignMessage` to `SignDigest` This commit refactors the Signer interface to rename SignMessage to SignDigest, enforcing that the input message is a pre-computed 32-byte digest. It also simplifies the SignDigestIntent struct by removing the nested SchnorrSignOpts and introducing a SignatureType enum and TaprootTweak field. This change decouples the signer from specific hashing logic (like double-SHA256 vs single-SHA256) and improves API clarity and safety. --- wallet/signer.go | 182 +++++++++++++++++++++++------------------- wallet/signer_test.go | 143 +++++++++++++++++++++++++-------- 2 files changed, 210 insertions(+), 115 deletions(-) diff --git a/wallet/signer.go b/wallet/signer.go index 02c64546cf..2eddd2353c 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -24,6 +24,14 @@ var ( // ErrUnsupportedAddressType is returned when a transaction is signed // for an unsupported address type. ErrUnsupportedAddressType = errors.New("unsupported address type") + + // ErrInvalidDigestSize is returned when a signature digest is not 32 + // bytes. + ErrInvalidDigestSize = errors.New("digest must be 32 bytes") + + // ErrInvalidSignParam is returned when the parameters for the signing + // operation are invalid. + ErrInvalidSignParam = errors.New("invalid signing parameters") ) // Signer provides an interface for common, safe cryptographic operations, @@ -41,11 +49,11 @@ type Signer interface { ECDH(ctx context.Context, path BIP32Path, pub *btcec.PublicKey) ( [32]byte, error) - // SignMessage signs a message based on the provided intent. The + // SignDigest signs a message digest based on the provided intent. The // returned Signature is a marker interface that can be asserted to the // concrete signature types, ECDSASignature or SchnorrSignature. - SignMessage(ctx context.Context, path BIP32Path, - intent *SignMessageIntent) (Signature, error) + SignDigest(ctx context.Context, path BIP32Path, + intent *SignDigestIntent) (Signature, error) // ComputeUnlockingScript generates the full sigScript and witness // required to spend a UTXO. The resulting UnlockingScript struct @@ -113,75 +121,72 @@ type BIP32Path struct { DerivationPath waddrmgr.DerivationPath } -// SignMessageIntent represents the user's intent to sign a message. It +// SignatureType represents the type of signature to produce. +type SignatureType uint8 + +const ( + // SigTypeECDSA represents an ECDSA signature. + SigTypeECDSA SignatureType = iota + + // SigTypeSchnorr represents a Schnorr signature. + SigTypeSchnorr +) + +// SignDigestIntent represents the user's intent to sign a message digest. It // serves as a blueprint for the Signer, bundling all the parameters // required to produce a signature into a single, coherent structure. // // # Usage Examples // // ## Standard ECDSA Signature (DER Encoded) -// To produce a standard ECDSA signature, set CompactSig to false and leave the -// Schnorr field nil. +// To produce a standard ECDSA signature, set SigType to SigTypeECDSA. // -// intent := &wallet.SignMessageIntent{ -// Msg: []byte("a message"), -// DoubleHash: true, -// CompactSig: false, +// intent := &wallet.SignDigestIntent{ +// Digest: chainhash.HashB([]byte("a message")), +// SigType: wallet.SigTypeECDSA, // } -// rawSig, err := signer.SignMessage(ctx, path, intent) +// rawSig, err := signer.SignDigest(ctx, path, intent) // // Type-assert the result to ECDSASignature. // ecdsaSig := rawSig.(wallet.ECDSASignature) // // ## Compact, Recoverable ECDSA Signature // To produce a compact, recoverable signature, set CompactSig to true. // -// intent := &wallet.SignMessageIntent{ -// Msg: []byte("a message"), -// DoubleHash: true, +// intent := &wallet.SignDigestIntent{ +// Digest: chainhash.DoubleHashB([]byte("a message")), +// SigType: wallet.SigTypeECDSA, // CompactSig: true, // } -// rawSig, err := signer.SignMessage(ctx, path, intent) +// rawSig, err := signer.SignDigest(ctx, path, intent) // // Type-assert the result to CompactSignature. // compactSig := rawSig.(wallet.CompactSignature) // // ## Schnorr Signature -// To produce a Schnorr signature, populate the Schnorr field. When this field -// is non-nil, all ECDSA-related fields (like CompactSig) are ignored. +// To produce a Schnorr signature, set SigType to SigTypeSchnorr. // -// intent := &wallet.SignMessageIntent{ -// Msg: []byte("a message"), -// Schnorr: &wallet.SchnorrSignOpts{ -// Tag: []byte("my_protocol_tag"), -// }, +// intent := &wallet.SignDigestIntent{ +// Digest: chainhash.TaggedHash( +// []byte("my_protocol_tag"), []byte("a message"), +// ), +// SigType: wallet.SigTypeSchnorr, // } -// rawSig, err := signer.SignMessage(ctx, path, intent) +// rawSig, err := signer.SignDigest(ctx, path, intent) // // Type-assert the result to SchnorrSignature. // schnorrSig := rawSig.(wallet.SchnorrSignature) -type SignMessageIntent struct { - // Msg is the raw message to be signed. - Msg []byte +type SignDigestIntent struct { + // Digest is the 32-byte hash digest to be signed. + Digest []byte - // DoubleHash specifies whether the message should be double-hashed - // (SHA256d) before signing. If false, a single SHA256 hash is used. - DoubleHash bool + // SigType specifies the type of signature to generate. + SigType SignatureType // CompactSig specifies whether the signature should be returned in the // compact, recoverable format. This is only valid for ECDSA signatures. CompactSig bool - // Schnorr specifies the options for a Schnorr signature. If this is - // nil, an ECDSA signature will be produced. - Schnorr *SchnorrSignOpts -} - -// SchnorrSignOpts contains the specific parameters for a Schnorr signature. -type SchnorrSignOpts struct { - // Tweak is an optional private key tweak to be applied before signing. - Tweak []byte - - // Tag is an optional BIP-340 tagged hash to use. If nil, the standard - // SHA256 hash of the message is used. - Tag []byte + // TaprootTweak is an optional private key tweak to be applied before + // signing. This is only valid for Schnorr signatures. + TaprootTweak []byte } // Signature is an interface that represents a cryptographic signature. @@ -558,9 +563,39 @@ func (w *Wallet) ECDH(_ context.Context, path BIP32Path, return sharedSecret, nil } -// SignMessage signs a message based on the provided intent. -func (w *Wallet) SignMessage(_ context.Context, path BIP32Path, - intent *SignMessageIntent) (Signature, error) { +// validateSignDigestIntent validates the parameters of a SignDigestIntent. +func validateSignDigestIntent(intent *SignDigestIntent) error { + // The digest must be exactly 32 bytes. + if len(intent.Digest) != chainhash.HashSize { + return ErrInvalidDigestSize + } + + // Validate parameters based on signature type. + switch intent.SigType { + case SigTypeECDSA: + if intent.TaprootTweak != nil { + return fmt.Errorf("%w: taproot tweak cannot be used "+ + "with ECDSA", ErrInvalidSignParam) + } + + case SigTypeSchnorr: + if intent.CompactSig { + return fmt.Errorf("%w: compact signature cannot be "+ + "used with Schnorr", ErrInvalidSignParam) + } + } + + return nil +} + +// SignDigest signs a message digest based on the provided intent. +func (w *Wallet) SignDigest(_ context.Context, path BIP32Path, + intent *SignDigestIntent) (Signature, error) { + + err := validateSignDigestIntent(intent) + if err != nil { + return nil, err + } managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) if err != nil { @@ -576,46 +611,36 @@ func (w *Wallet) SignMessage(_ context.Context, path BIP32Path, // Now, sign the message using the derived private key. This is all // pure computation, so it can be done outside the DB transaction. - return signMessageWithPrivKey(privKey, intent) + return signDigestWithPrivKey(privKey, intent) } -// signMessageWithPrivKey performs the actual signing of a message with a given -// private key, based on the options specified in the SignMessageIntent. It +// signDigestWithPrivKey performs the actual signing of a digest with a given +// private key, based on the options specified in the SignDigestIntent. It // acts as a dispatcher to the appropriate signing algorithm. -func signMessageWithPrivKey(privKey *btcec.PrivateKey, - intent *SignMessageIntent) (Signature, error) { +func signDigestWithPrivKey(privKey *btcec.PrivateKey, + intent *SignDigestIntent) (Signature, error) { - // If Schnorr options are provided, we'll generate a Schnorr signature. - if intent.Schnorr != nil { - return signMessageSchnorr(privKey, intent) + // If Schnorr is specified, we'll generate a Schnorr signature. + if intent.SigType == SigTypeSchnorr { + return signDigestSchnorr(privKey, intent) } // Otherwise, we'll generate an ECDSA signature. - return signMessageECDSA(privKey, intent) + return signDigestECDSA(privKey, intent) } -// signMessageSchnorr performs the actual signing of a message with a given +// signDigestSchnorr performs the actual signing of a digest with a given // private key, using the Schnorr signature algorithm. -func signMessageSchnorr(privKey *btcec.PrivateKey, - intent *SignMessageIntent) (Signature, error) { +func signDigestSchnorr(privKey *btcec.PrivateKey, + intent *SignDigestIntent) (Signature, error) { - if intent.Schnorr.Tweak != nil { + if intent.TaprootTweak != nil { privKey = txscript.TweakTaprootPrivKey( - *privKey, intent.Schnorr.Tweak, + *privKey, intent.TaprootTweak, ) } - var digest []byte - if intent.Schnorr.Tag != nil { - taggedHash := chainhash.TaggedHash( - intent.Schnorr.Tag, intent.Msg, - ) - digest = taggedHash[:] - } else { - digest = chainhash.HashB(intent.Msg) - } - - sig, err := schnorr.Sign(privKey, digest) + sig, err := schnorr.Sign(privKey, intent.Digest) if err != nil { return nil, fmt.Errorf("cannot create schnorr sig: %w", err) } @@ -623,24 +648,17 @@ func signMessageSchnorr(privKey *btcec.PrivateKey, return SchnorrSignature{sig}, nil } -// signMessageECDSA performs the actual signing of a message with a given +// signDigestECDSA performs the actual signing of a digest with a given // private key, using the ECDSA signature algorithm. -func signMessageECDSA(privKey *btcec.PrivateKey, - intent *SignMessageIntent) (Signature, error) { - - var digest []byte - if intent.DoubleHash { - digest = chainhash.DoubleHashB(intent.Msg) - } else { - digest = chainhash.HashB(intent.Msg) - } +func signDigestECDSA(privKey *btcec.PrivateKey, + intent *SignDigestIntent) (Signature, error) { if intent.CompactSig { - sig := ecdsa.SignCompact(privKey, digest, true) + sig := ecdsa.SignCompact(privKey, intent.Digest, true) return CompactSignature(sig), nil } - sig := ecdsa.Sign(privKey, digest) + sig := ecdsa.Sign(privKey, intent.Digest) return ECDSASignature{sig}, nil } diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 7c5c91d858..9287bb6edf 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -345,9 +345,9 @@ func deterministicPrivKey(t *testing.T) (*btcec.PrivateKey, *btcec.PublicKey) { return privKey, pubKey } -// TestSignMessage tests the signing of a message with different signature +// TestSignDigest tests the signing of a message digest with different signature // types. -func TestSignMessage(t *testing.T) { +func TestSignDigest(t *testing.T) { t.Parallel() // We'll use a common set of parameters for all signing test cases to @@ -362,13 +362,17 @@ func TestSignMessage(t *testing.T) { }, } msg := []byte("test message") + msgHash := chainhash.HashB(msg) + msgDoubleHash := chainhash.DoubleHashB(msg) + tag := []byte("test tag") + taggedHash := chainhash.TaggedHash(tag, msg) testCases := []struct { // name is the name of the test case. name string // intent is the signing intent to use for the test. - intent *SignMessageIntent + intent *SignDigestIntent // verify is a function that verifies the signature produced by // the signing intent. @@ -377,9 +381,9 @@ func TestSignMessage(t *testing.T) { }{ { name: "ECDSA success", - intent: &SignMessageIntent{ - Msg: msg, - DoubleHash: false, + intent: &SignDigestIntent{ + Digest: msgHash, + SigType: SigTypeECDSA, CompactSig: false, }, verify: func(t *testing.T, sig Signature, @@ -389,7 +393,6 @@ func TestSignMessage(t *testing.T) { ecdsaSig, ok := sig.(ECDSASignature) require.True(t, ok, "expected ECDSASignature") - msgHash := chainhash.HashB(msg) require.True( t, ecdsaSig.Verify(msgHash, pubKey), "signature invalid", @@ -398,9 +401,9 @@ func TestSignMessage(t *testing.T) { }, { name: "ECDSA compact success", - intent: &SignMessageIntent{ - Msg: msg, - DoubleHash: true, + intent: &SignDigestIntent{ + Digest: msgDoubleHash, + SigType: SigTypeECDSA, CompactSig: true, }, verify: func(t *testing.T, sig Signature, @@ -410,9 +413,8 @@ func TestSignMessage(t *testing.T) { compactSig, ok := sig.(CompactSignature) require.True(t, ok, "expected CompactSignature") - msgHash := chainhash.DoubleHashB(msg) recoveredKey, _, err := ecdsa.RecoverCompact( - compactSig, msgHash, + compactSig, msgDoubleHash, ) require.NoError(t, err) require.True( @@ -423,11 +425,9 @@ func TestSignMessage(t *testing.T) { }, { name: "Schnorr success", - intent: &SignMessageIntent{ - Msg: msg, - Schnorr: &SchnorrSignOpts{ - Tag: []byte("test tag"), - }, + intent: &SignDigestIntent{ + Digest: taggedHash[:], + SigType: SigTypeSchnorr, }, verify: func(t *testing.T, sig Signature, pubKey *btcec.PublicKey) { @@ -437,22 +437,20 @@ func TestSignMessage(t *testing.T) { schnorrSig, ok := sig.(SchnorrSignature) require.True(t, ok, "expected SchnorrSignature") - msgHash := chainhash.TaggedHash( - []byte("test tag"), msg, - ) require.True(t, - schnorrSig.Verify(msgHash[:], pubKey), + schnorrSig.Verify( + taggedHash[:], pubKey, + ), "signature invalid", ) }, }, { name: "Schnorr success with tweak", - intent: &SignMessageIntent{ - Msg: msg, - Schnorr: &SchnorrSignOpts{ - Tweak: []byte("test tweak"), - }, + intent: &SignDigestIntent{ + Digest: msgHash, + SigType: SigTypeSchnorr, + TaprootTweak: []byte("test tweak"), }, verify: func(t *testing.T, sig Signature, pubKey *btcec.PublicKey) { @@ -468,7 +466,6 @@ func TestSignMessage(t *testing.T) { *privKey, tweak, ) tweakedPub := tweakedKey.PubKey() - msgHash := chainhash.HashB(msg) require.True(t, schnorrSig.Verify(msgHash, tweakedPub), @@ -509,7 +506,7 @@ func TestSignMessage(t *testing.T) { ).Once() // Act: Attempt to sign the message with the wallet. - sig, err := w.SignMessage(t.Context(), path, tc.intent) + sig, err := w.SignDigest(t.Context(), path, tc.intent) // Assert: Verify that the signature was created // successfully and is valid for the given public key. @@ -522,20 +519,22 @@ func TestSignMessage(t *testing.T) { } } -// TestSignMessageFail tests failure modes of SignMessage. -func TestSignMessageFail(t *testing.T) { +// TestSignDigestFail tests failure modes of SignDigest. +func TestSignDigestFail(t *testing.T) { t.Parallel() w, mocks := testWalletWithMocks(t) path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} - intent := &SignMessageIntent{Msg: []byte("test")} + + digest := make([]byte, 32) + intent := &SignDigestIntent{Digest: digest} // Test Case 1: Fetching the key manager fails. // We expect an `errManagerNotFound` error to be returned. mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). Return((*mockAccountStore)(nil), errManagerNotFound).Once() - _, err := w.SignMessage(t.Context(), path, intent) + _, err := w.SignDigest(t.Context(), path, intent) require.ErrorIs(t, err, errManagerNotFound) // Test Case 2: Obtaining the private key for signing fails. @@ -548,10 +547,88 @@ func TestSignMessageFail(t *testing.T) { mocks.pubKeyAddr.On("PrivKey").Return((*btcec.PrivateKey)(nil), errPrivKeyMock).Once() - _, err = w.SignMessage(t.Context(), path, intent) + _, err = w.SignDigest(t.Context(), path, intent) require.ErrorContains(t, err, "privkey error") } +// TestValidateSignDigestIntent tests the validation logic for SignDigestIntent. +func TestValidateSignDigestIntent(t *testing.T) { + t.Parallel() + + validDigest := make([]byte, 32) + invalidDigest := make([]byte, 31) + + testCases := []struct { + name string + intent *SignDigestIntent + wantErr error + }{ + { + // A valid ECDSA intent with a 32-byte digest and no + // restricted fields should pass validation. + name: "valid ECDSA", + intent: &SignDigestIntent{ + Digest: validDigest, + SigType: SigTypeECDSA, + }, + wantErr: nil, + }, + { + // A valid Schnorr intent with a 32-byte digest and no + // restricted fields should pass validation. + name: "valid Schnorr", + intent: &SignDigestIntent{ + Digest: validDigest, + SigType: SigTypeSchnorr, + }, + wantErr: nil, + }, + { + // If the digest length is not 32 bytes, we expect an + // ErrInvalidDigestSize error. + name: "invalid digest length", + intent: &SignDigestIntent{ + Digest: invalidDigest, + SigType: SigTypeECDSA, + }, + wantErr: ErrInvalidDigestSize, + }, + { + // If an ECDSA intent provides a Taproot Tweak, we + // expect an ErrInvalidSignParam error as tweaks are + // Schnorr-specific. + name: "ECDSA with Taproot Tweak", + intent: &SignDigestIntent{ + Digest: validDigest, + SigType: SigTypeECDSA, + TaprootTweak: []byte("tweak"), + }, + wantErr: ErrInvalidSignParam, + }, + { + // If a Schnorr intent requests a Compact Signature, we + // expect an ErrInvalidSignParam error as compact sigs + // are ECDSA-specific. + name: "Schnorr with CompactSig", + intent: &SignDigestIntent{ + Digest: validDigest, + SigType: SigTypeSchnorr, + CompactSig: true, + }, + wantErr: ErrInvalidSignParam, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateSignDigestIntent(tc.intent) + require.ErrorIs(t, err, tc.wantErr) + }) + } +} + // TestComputeUnlockingScriptP2PKH tests that the wallet can generate a valid // unlocking script for a P2PKH output. func TestComputeUnlockingScriptP2PKH(t *testing.T) { From 43d313124864ef5054337de1b585b8b8807c0392 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 25 Nov 2025 20:45:31 +0800 Subject: [PATCH 161/691] wallet: fix linter `goconst` --- wallet/tx_creator_test.go | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index 980de7bda7..bdd3ee16d4 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -33,11 +33,13 @@ var ( func TestValidateTxIntent(t *testing.T) { t.Parallel() + const defaultAccountName = "default" + // Define a set of valid outputs and inputs to be reused across test // cases. validOutput := wire.TxOut{Value: 10000, PkScript: []byte{}} validUTXO := wire.OutPoint{Hash: [32]byte{1}, Index: 0} - validAccountName := "default" + validAccountName := defaultAccountName validScopedAccount := &ScopedAccount{ AccountName: validAccountName, KeyScope: waddrmgr.KeyScopeBIP0086, @@ -58,7 +60,7 @@ func TestValidateTxIntent(t *testing.T) { UTXOs: []wire.OutPoint{validUTXO}, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -73,7 +75,7 @@ func TestValidateTxIntent(t *testing.T) { Source: validScopedAccount, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -91,7 +93,7 @@ func TestValidateTxIntent(t *testing.T) { }, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -103,7 +105,7 @@ func TestValidateTxIntent(t *testing.T) { Outputs: []wire.TxOut{validOutput}, Inputs: &InputsPolicy{Source: nil}, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -115,7 +117,7 @@ func TestValidateTxIntent(t *testing.T) { Outputs: []wire.TxOut{validOutput}, Inputs: nil, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -129,7 +131,7 @@ func TestValidateTxIntent(t *testing.T) { UTXOs: []wire.OutPoint{validUTXO}, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -143,7 +145,7 @@ func TestValidateTxIntent(t *testing.T) { UTXOs: []wire.OutPoint{validUTXO}, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -157,7 +159,7 @@ func TestValidateTxIntent(t *testing.T) { UTXOs: []wire.OutPoint{}, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -173,7 +175,7 @@ func TestValidateTxIntent(t *testing.T) { }, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -187,7 +189,7 @@ func TestValidateTxIntent(t *testing.T) { Source: &ScopedAccount{AccountName: ""}, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -203,7 +205,7 @@ func TestValidateTxIntent(t *testing.T) { }, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -221,7 +223,7 @@ func TestValidateTxIntent(t *testing.T) { }, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -235,7 +237,7 @@ func TestValidateTxIntent(t *testing.T) { Source: &unsupportedCoinSource{}, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -247,7 +249,7 @@ func TestValidateTxIntent(t *testing.T) { Outputs: []wire.TxOut{validOutput}, Inputs: &unsupportedInputs{}, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, }, FeeRate: 1000, }, @@ -276,7 +278,7 @@ func TestValidateTxIntent(t *testing.T) { UTXOs: []wire.OutPoint{validUTXO}, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, KeyScope: waddrmgr.KeyScopeBIP0086, }, FeeRate: 0, @@ -291,7 +293,7 @@ func TestValidateTxIntent(t *testing.T) { UTXOs: []wire.OutPoint{validUTXO}, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, KeyScope: waddrmgr.KeyScopeBIP0086, }, FeeRate: DefaultMaxFeeRate + 1, From 482ffd58aec9e2179023fb75d6092963c279e370 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 18 Nov 2025 20:17:28 +0800 Subject: [PATCH 162/691] btcunit: add missing `KVByte` and `KWeightUnit` size units --- pkg/btcunit/txsize.go | 41 ++++++++++++++++++++++++++++++++++++++ pkg/btcunit/txsize_test.go | 16 +++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/pkg/btcunit/txsize.go b/pkg/btcunit/txsize.go index 6910ca4877..0a45b18a5f 100644 --- a/pkg/btcunit/txsize.go +++ b/pkg/btcunit/txsize.go @@ -57,3 +57,44 @@ func (vb VByte) ToWU() WeightUnit { func (vb VByte) String() string { return fmt.Sprintf("%d vb", vb.val) } + +// KVByte defines a unit to express the transaction size in kilo-virtual-bytes. +type KVByte struct { + val uint64 +} + +// NewKVByte creates a new KVByte from a uint64. +func NewKVByte(val uint64) KVByte { + return KVByte{val: val} +} + +// ToVB converts a value expressed in kilo-virtual-bytes to virtual bytes. +func (kvb KVByte) ToVB() VByte { + return VByte{val: kvb.val * 1000} +} + +// String returns the string representation of the kilo-virtual-byte. +func (kvb KVByte) String() string { + return fmt.Sprintf("%d kvb", kvb.val) +} + +// KWeightUnit defines a unit to express the transaction size in +// kilo-weight-units. +type KWeightUnit struct { + val uint64 +} + +// NewKWeightUnit creates a new KWeightUnit from a uint64. +func NewKWeightUnit(val uint64) KWeightUnit { + return KWeightUnit{val: val} +} + +// ToWU converts a value expressed in kilo-weight-units to weight units. +func (kwu KWeightUnit) ToWU() WeightUnit { + return WeightUnit{val: kwu.val * 1000} +} + +// String returns the string representation of the kilo-weight-unit. +func (kwu KWeightUnit) String() string { + return fmt.Sprintf("%d kwu", kwu.val) +} diff --git a/pkg/btcunit/txsize_test.go b/pkg/btcunit/txsize_test.go index d86936f594..722e74efa2 100644 --- a/pkg/btcunit/txsize_test.go +++ b/pkg/btcunit/txsize_test.go @@ -19,6 +19,18 @@ func TestTxSizeConversion(t *testing.T) { // 250 vb should be equal to 1000 wu. require.Equal(t, wu, NewVByte(250).ToWU()) + + // Create a test kvbyte of 1 kvb. + kvb := NewKVByte(1) + + // 1 kvb should be equal to 1000 vb. + require.Equal(t, NewVByte(1000), kvb.ToVB()) + + // Create a test kweightunit of 1 kwu. + kwu := NewKWeightUnit(1) + + // 1 kwu should be equal to 1000 wu. + require.Equal(t, NewWeightUnit(1000), kwu.ToWU()) } // TestTxSizeStringer tests the stringer methods of the tx size types. @@ -28,8 +40,12 @@ func TestTxSizeStringer(t *testing.T) { // Create a test weight of 1000 wu. wu := NewWeightUnit(1000) vb := NewVByte(250) + kvb := NewKVByte(1) + kwu := NewKWeightUnit(1) // Test String. require.Equal(t, "1000 wu", wu.String()) require.Equal(t, "250 vb", vb.String()) + require.Equal(t, "1 kvb", kvb.String()) + require.Equal(t, "1 kwu", kwu.String()) } From 275a27105e5f661c947cac2f220a00c20a9005fb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 18 Nov 2025 20:19:26 +0800 Subject: [PATCH 163/691] btcunit: Add comprehensive conversion and fee calculation methods This commit enhances the package by implementing a full set of conversion and fee calculation methods for , , and types. Specifically, for each fee rate type: - Two conversion methods are now available to convert to other fee rate units. - Three fee calculation methods are now available to easily calculate fees given various size units (VByte, KVByte, WeightUnit). This change improves the consistency, completeness, and usability of the API, making it more robust for fee-related operations. Corresponding unit tests have been added and updated to ensure the correctness of these new methods. --- pkg/btcunit/rates.go | 65 ++++++++++++++++++++++++++++++++++----- pkg/btcunit/rates_test.go | 33 +++++++++++++++++--- 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go index 97f886a22e..558ce444d4 100644 --- a/pkg/btcunit/rates.go +++ b/pkg/btcunit/rates.go @@ -57,6 +57,29 @@ func (s SatPerVByte) FeePerKVByte() SatPerKVByte { return SatPerKVByte{kvbRate} } +// FeeForVSize calculates the fee resulting from this fee rate and the given +// vsize in vbytes. +func (s SatPerVByte) FeeForVSize(vbytes VByte) btcutil.Amount { + fee := new(big.Rat).Mul( + s.Rat, + new(big.Rat).SetInt64(safeUint64ToInt64(vbytes.val)), + ) + + return roundToAmount(fee) +} + +// FeeForWeight calculates the fee resulting from this fee rate and the given +// weight in weight units. +func (s SatPerVByte) FeeForWeight(wu WeightUnit) btcutil.Amount { + return s.FeeForVSize(wu.ToVB()) +} + +// FeeForKVByte calculates the fee resulting from this fee rate and the given +// vsize in kilo-vbytes. +func (s SatPerVByte) FeeForKVByte(kvbytes KVByte) btcutil.Amount { + return s.FeeForVSize(kvbytes.ToVB()) +} + // String returns a human-readable string of the fee rate. func (s SatPerVByte) String() string { return s.FloatString(floatStringPrecision) + " sat/vb" @@ -95,16 +118,16 @@ type SatPerKVByte struct { *big.Rat } -// NewSatPerKVByte creates a new fee rate in sat/kvb. The given fee and kvbytes -// are used to calculate the fee rate. -func NewSatPerKVByte(fee btcutil.Amount, kvb VByte) SatPerKVByte { +// NewSatPerKVByte creates a new fee rate in sat/kvb. The given fee and +// kvbytes are used to calculate the fee rate. +func NewSatPerKVByte(fee btcutil.Amount, kvb KVByte) SatPerKVByte { if kvb.val == 0 { return SatPerKVByte{big.NewRat(0, 1)} } return SatPerKVByte{ big.NewRat( - int64(fee)*SatsPerKilo, + int64(fee), safeUint64ToInt64(kvb.val), ), } @@ -115,12 +138,25 @@ func NewSatPerKVByte(fee btcutil.Amount, kvb VByte) SatPerKVByte { func (s SatPerKVByte) FeeForVSize(vbytes VByte) btcutil.Amount { fee := new(big.Rat).Mul( s.Rat, - big.NewRat(safeUint64ToInt64(vbytes.val), SatsPerKilo), + new(big.Rat).SetInt64(safeUint64ToInt64(vbytes.val)), ) + fee.Quo(fee, new(big.Rat).SetInt64(SatsPerKilo)) return roundToAmount(fee) } +// FeeForKVByte calculates the fee resulting from this fee rate and the given +// vsize in kilo-vbytes. +func (s SatPerKVByte) FeeForKVByte(kvbytes KVByte) btcutil.Amount { + return s.FeeForVSize(kvbytes.ToVB()) +} + +// FeeForWeight calculates the fee resulting from this fee rate and the given +// weight in weight units. +func (s SatPerKVByte) FeeForWeight(wu WeightUnit) btcutil.Amount { + return s.FeeForVSize(wu.ToVB()) +} + // FeePerKWeight converts the current fee rate from sat/kb to sat/kw. func (s SatPerKVByte) FeePerKWeight() SatPerKWeight { kvbToKwRate := big.NewRat(1, blockchain.WitnessScaleFactor) @@ -129,6 +165,15 @@ func (s SatPerKVByte) FeePerKWeight() SatPerKWeight { return SatPerKWeight{kwRate} } +// FeePerVByte converts the current fee rate from sat/kvb to sat/vb. +func (s SatPerKVByte) FeePerVByte() SatPerVByte { + kvbToVbRate := new(big.Rat).Quo( + s.Rat, new(big.Rat).SetInt64(SatsPerKilo), + ) + + return SatPerVByte{kvbToVbRate} +} + // String returns a human-readable string of the fee rate. func (s SatPerKVByte) String() string { return s.FloatString(floatStringPrecision) + " sat/kvb" @@ -222,12 +267,18 @@ func (s SatPerKWeight) FeeForVByte(vb VByte) btcutil.Amount { return s.FeePerKVByte().FeeForVSize(vb) } +// FeeForKVByte calculates the fee resulting from this fee rate and the given +// vsize in kilo-vbytes. +func (s SatPerKWeight) FeeForKVByte(kvb KVByte) btcutil.Amount { + return s.FeeForVByte(kvb.ToVB()) +} + // FeePerKVByte converts the current fee rate from sat/kw to sat/kb. func (s SatPerKWeight) FeePerKVByte() SatPerKVByte { kwToKvbRate := big.NewRat(blockchain.WitnessScaleFactor, 1) - kvbRate := new(big.Rat).Mul(s.Rat, kwToKvbRate) + kwRate := new(big.Rat).Mul(s.Rat, kwToKvbRate) - return SatPerKVByte{kvbRate} + return SatPerKVByte{kwRate} } // FeePerVByte converts the current fee rate from sat/kw to sat/vb. diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go index 29cbc967a1..5e9a8084b1 100644 --- a/pkg/btcunit/rates_test.go +++ b/pkg/btcunit/rates_test.go @@ -177,8 +177,8 @@ func TestNewFeeRateConstructors(t *testing.T) { ) // Test NewSatPerKVByte. - kvb := NewVByte(1) - expectedRateKVB := SatPerKVByte{big.NewRat(1000000, 1)} + kvb := NewKVByte(1) + expectedRateKVB := SatPerKVByte{big.NewRat(1000, 1)} require.Zero( t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat), ) @@ -276,12 +276,36 @@ func TestFeeForSize(t *testing.T) { // Create a set of fee rates to test. r1 := SatPerKVByte{big.NewRat(1000, 1)} r2 := SatPerKWeight{big.NewRat(250, 1)} + r3 := SatPerVByte{big.NewRat(1, 1)} // Test FeeForVSize. require.Equal(t, btcutil.Amount(250), r1.FeeForVSize(NewVByte(250))) // Test FeeForVByte. require.Equal(t, btcutil.Amount(250), r2.FeeForVByte(NewVByte(250))) + + // Test FeeForVSize with SatPerVByte. + require.Equal(t, btcutil.Amount(1000), r3.FeeForVSize(NewVByte(1000))) + + // Test FeeForKVByte with SatPerVByte. + require.Equal(t, btcutil.Amount(1000), r3.FeeForKVByte(NewKVByte(1))) + + // Test FeeForWeight with SatPerVByte. + require.Equal(t, btcutil.Amount(250), + r3.FeeForWeight(NewWeightUnit(1000))) + + // Test FeePerVByte with SatPerKVByte. + require.True(t, r3.Equal(r1.FeePerVByte())) + + // Test FeeForKVByte with SatPerKVByte. + require.Equal(t, btcutil.Amount(1000), r1.FeeForKVByte(NewKVByte(1))) + + // Test FeeForWeight with SatPerKVByte. + require.Equal(t, btcutil.Amount(250), + r1.FeeForWeight(NewWeightUnit(1000))) + + // Test FeeForKVByte with SatPerKWeight. + require.Equal(t, btcutil.Amount(1000), r2.FeeForKVByte(NewKVByte(1))) } // TestNewFeeRateConstructorsZero tests the New* fee rate constructors with @@ -305,7 +329,7 @@ func TestNewFeeRateConstructorsZero(t *testing.T) { ) // Test NewSatPerKVByte with zero kvbytes. - kvb := NewVByte(0) + kvb := NewKVByte(0) expectedRateKVB := SatPerKVByte{big.NewRat(0, 1)} require.Zero( t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat), @@ -328,7 +352,8 @@ func TestSafeUint64ToInt64Overflow(t *testing.T) { // Test NewSatPerKVByte with an overflowing kvb value. // The denominator should be capped at math.MaxInt64. - rateKVB := NewSatPerKVByte(fee, overflowVByte) + overflowKVByte := NewKVByte(math.MaxInt64 + 1) + rateKVB := NewSatPerKVByte(fee, overflowKVByte) require.Zero(t, expectedDenom.Cmp(rateKVB.Denom())) // Test NewSatPerKWeight with an overflowing weight unit value. From 3e9a00d0dd3a7980b85e82cdfba9513bece0a158 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 18 Nov 2025 20:27:22 +0800 Subject: [PATCH 164/691] btcunit: rename `FeePer...` for clarity `FeePer...` looks very similar to `FeeFor...`, we now rename it to be `ToXXX` for clarity. --- pkg/btcunit/rates.go | 26 +++++++++++++------------- pkg/btcunit/rates_test.go | 23 ++++++++++++----------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go index 558ce444d4..7e97064a31 100644 --- a/pkg/btcunit/rates.go +++ b/pkg/btcunit/rates.go @@ -41,16 +41,16 @@ func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { } } -// FeePerKWeight converts the current fee rate from sat/vb to sat/kw. -func (s SatPerVByte) FeePerKWeight() SatPerKWeight { +// ToSatPerKWeight converts the current fee rate from sat/vb to sat/kw. +func (s SatPerVByte) ToSatPerKWeight() SatPerKWeight { vbToKwRate := big.NewRat(SatsPerKilo, blockchain.WitnessScaleFactor) kwRate := new(big.Rat).Mul(s.Rat, vbToKwRate) return SatPerKWeight{kwRate} } -// FeePerKVByte converts the current fee rate from sat/vb to sat/kvb. -func (s SatPerVByte) FeePerKVByte() SatPerKVByte { +// ToSatPerKVByte converts the current fee rate from sat/vb to sat/kvb. +func (s SatPerVByte) ToSatPerKVByte() SatPerKVByte { vbToKvbRate := big.NewRat(SatsPerKilo, 1) kvbRate := new(big.Rat).Mul(s.Rat, vbToKvbRate) @@ -157,16 +157,16 @@ func (s SatPerKVByte) FeeForWeight(wu WeightUnit) btcutil.Amount { return s.FeeForVSize(wu.ToVB()) } -// FeePerKWeight converts the current fee rate from sat/kb to sat/kw. -func (s SatPerKVByte) FeePerKWeight() SatPerKWeight { +// ToSatPerKWeight converts the current fee rate from sat/kb to sat/kw. +func (s SatPerKVByte) ToSatPerKWeight() SatPerKWeight { kvbToKwRate := big.NewRat(1, blockchain.WitnessScaleFactor) kwRate := new(big.Rat).Mul(s.Rat, kvbToKwRate) return SatPerKWeight{kwRate} } -// FeePerVByte converts the current fee rate from sat/kvb to sat/vb. -func (s SatPerKVByte) FeePerVByte() SatPerVByte { +// ToSatPerVByte converts the current fee rate from sat/kvb to sat/vb. +func (s SatPerKVByte) ToSatPerVByte() SatPerVByte { kvbToVbRate := new(big.Rat).Quo( s.Rat, new(big.Rat).SetInt64(SatsPerKilo), ) @@ -264,7 +264,7 @@ func (s SatPerKWeight) FeeForWeightRoundUp( // FeeForVByte calculates the fee resulting from this fee rate and the given // size in vbytes (vb). func (s SatPerKWeight) FeeForVByte(vb VByte) btcutil.Amount { - return s.FeePerKVByte().FeeForVSize(vb) + return s.ToSatPerKVByte().FeeForVSize(vb) } // FeeForKVByte calculates the fee resulting from this fee rate and the given @@ -273,16 +273,16 @@ func (s SatPerKWeight) FeeForKVByte(kvb KVByte) btcutil.Amount { return s.FeeForVByte(kvb.ToVB()) } -// FeePerKVByte converts the current fee rate from sat/kw to sat/kb. -func (s SatPerKWeight) FeePerKVByte() SatPerKVByte { +// ToSatPerKVByte converts the current fee rate from sat/kw to sat/kb. +func (s SatPerKWeight) ToSatPerKVByte() SatPerKVByte { kwToKvbRate := big.NewRat(blockchain.WitnessScaleFactor, 1) kwRate := new(big.Rat).Mul(s.Rat, kwToKvbRate) return SatPerKVByte{kwRate} } -// FeePerVByte converts the current fee rate from sat/kw to sat/vb. -func (s SatPerKWeight) FeePerVByte() SatPerVByte { +// ToSatPerVByte converts the current fee rate from sat/kw to sat/vb. +func (s SatPerKWeight) ToSatPerVByte() SatPerVByte { kwToVbRate := big.NewRat(blockchain.WitnessScaleFactor, SatsPerKilo) vbRate := new(big.Rat).Mul(s.Rat, kwToVbRate) diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go index 5e9a8084b1..e2b5ed9ef0 100644 --- a/pkg/btcunit/rates_test.go +++ b/pkg/btcunit/rates_test.go @@ -64,10 +64,10 @@ func TestFeeRateConversions(t *testing.T) { case SatPerVByte: require.True(t, tc.expectedVB.Equal(r)) require.True(t, tc.expectedKVB.Equal( - r.FeePerKVByte()), + r.ToSatPerKVByte()), ) require.True(t, tc.expectedKW.Equal( - r.FeePerKWeight()), + r.ToSatPerKWeight()), ) // The expected sats is the floor of the fee @@ -80,11 +80,11 @@ func TestFeeRateConversions(t *testing.T) { case SatPerKVByte: require.True(t, tc.expectedVB.Equal( - r.FeePerKWeight().FeePerVByte()), + r.ToSatPerKWeight().ToSatPerVByte()), ) require.True(t, tc.expectedKVB.Equal(r)) require.True(t, tc.expectedKW.Equal( - r.FeePerKWeight()), + r.ToSatPerKWeight()), ) floor := new(big.Int).Div(r.Num(), r.Denom()) require.Equal( @@ -94,10 +94,10 @@ func TestFeeRateConversions(t *testing.T) { case SatPerKWeight: require.True(t, tc.expectedVB.Equal( - r.FeePerVByte()), + r.ToSatPerVByte()), ) require.True(t, tc.expectedKVB.Equal( - r.FeePerKVByte()), + r.ToSatPerKVByte()), ) require.True(t, tc.expectedKW.Equal(r)) floor := new(big.Int).Div(r.Num(), r.Denom()) @@ -110,8 +110,9 @@ func TestFeeRateConversions(t *testing.T) { } } -// TestFeeRateComparisons tests the comparison methods of the fee rate types. -func TestFeeRateComparisons(t *testing.T) { +// TestFeeRateComparisonsVB tests the comparison methods of the SatPerVByte +// type. +func TestFeeRateComparisonsVB(t *testing.T) { t.Parallel() // Create a set of fee rates to compare. @@ -149,7 +150,7 @@ func TestFeeRateComparisons(t *testing.T) { func TestFeeForWeightRoundUp(t *testing.T) { t.Parallel() - feeRate := SatPerVByte{big.NewRat(1, 1)}.FeePerKWeight() + feeRate := SatPerVByte{big.NewRat(1, 1)}.ToSatPerKWeight() txWeight := NewWeightUnit(674) // 674 weight units is 168.5 vb. require.EqualValues(t, 168, feeRate.FeeForWeight(txWeight)) @@ -294,8 +295,8 @@ func TestFeeForSize(t *testing.T) { require.Equal(t, btcutil.Amount(250), r3.FeeForWeight(NewWeightUnit(1000))) - // Test FeePerVByte with SatPerKVByte. - require.True(t, r3.Equal(r1.FeePerVByte())) + // Test ToSatPerVByte with SatPerKVByte. + require.True(t, r3.Equal(r1.ToSatPerVByte())) // Test FeeForKVByte with SatPerKVByte. require.Equal(t, btcutil.Amount(1000), r1.FeeForKVByte(NewKVByte(1))) From ad75a55e5d4daa271a6f8a829775624d0b8f7e26 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 19 Nov 2025 01:56:08 +0800 Subject: [PATCH 165/691] btcunit: refactor transaction size units with baseUnit embedding This commit refactors the transaction size unit types within the `btcunit` package to improve code organization, reduce boilerplate, and adhere to idiomatic Go practices. Previously, a `size` struct was used internally, and individual unit types (`WeightUnit`, `VByte`, `KVByte`, `KWeightUnit`) had redundant conversion methods. This approach led to duplicated logic and a less cohesive API. This change introduces a `baseUnit` struct, which now encapsulates the canonical representation of transaction size in weight units (`wu`). All core conversion methods (`ToWU`, `ToVB`, `ToKVB`, `ToKWU`) are implemented directly on this `baseUnit` and are exported. By embedding `baseUnit` into the specific unit types, these conversion methods are promoted, eliminating the need for boilerplate wrapper methods. Additionally, this refactoring includes: - Renaming `ToVByte` to `ToVB`, `ToKVByte` to `ToKVB`, and `ToKWeightUnit` to `ToKWU` across the codebase for brevity and consistency. - Introducing an unexported `kilo` constant to replace magic numbers, resolving `mnd` linter warnings and improving code readability. All affected call sites in `rates.go`, `txsize.go`, `txsize_test.go`, and `rates_test.go` have been updated to reflect these changes. Unit tests continue to pass, ensuring no regressions. --- pkg/btcunit/rates.go | 33 +++++++----- pkg/btcunit/rates_test.go | 8 +-- pkg/btcunit/txsize.go | 96 ++++++++++++++++++--------------- pkg/btcunit/txsize_test.go | 106 ++++++++++++++++++++++++++++++++----- 4 files changed, 172 insertions(+), 71 deletions(-) diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go index 7e97064a31..ee5db38d48 100644 --- a/pkg/btcunit/rates.go +++ b/pkg/btcunit/rates.go @@ -18,6 +18,9 @@ const ( // SatsPerKilo is the number of satoshis in a kilo-satoshi. SatsPerKilo = 1000 + // kilo is a generic multiplier for kilo units. + kilo = 1000 + // floatStringPrecision is the number of decimal places to use when // converting a fee rate to a string. floatStringPrecision = 2 @@ -32,12 +35,15 @@ type SatPerVByte struct { // NewSatPerVByte creates a new fee rate in sat/vb. The given fee and vbytes // are used to calculate the fee rate. func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { - if vb.val == 0 { + if vb.wu == 0 { return SatPerVByte{big.NewRat(0, 1)} } return SatPerVByte{ - big.NewRat(int64(fee), safeUint64ToInt64(vb.val)), + big.NewRat( + int64(fee)*blockchain.WitnessScaleFactor, + safeUint64ToInt64(vb.wu), + ), } } @@ -62,8 +68,9 @@ func (s SatPerVByte) ToSatPerKVByte() SatPerKVByte { func (s SatPerVByte) FeeForVSize(vbytes VByte) btcutil.Amount { fee := new(big.Rat).Mul( s.Rat, - new(big.Rat).SetInt64(safeUint64ToInt64(vbytes.val)), + new(big.Rat).SetInt64(safeUint64ToInt64(vbytes.wu)), ) + fee.Quo(fee, new(big.Rat).SetInt64(blockchain.WitnessScaleFactor)) return roundToAmount(fee) } @@ -121,14 +128,14 @@ type SatPerKVByte struct { // NewSatPerKVByte creates a new fee rate in sat/kvb. The given fee and // kvbytes are used to calculate the fee rate. func NewSatPerKVByte(fee btcutil.Amount, kvb KVByte) SatPerKVByte { - if kvb.val == 0 { + if kvb.wu == 0 { return SatPerKVByte{big.NewRat(0, 1)} } return SatPerKVByte{ big.NewRat( - int64(fee), - safeUint64ToInt64(kvb.val), + int64(fee)*blockchain.WitnessScaleFactor*SatsPerKilo, + safeUint64ToInt64(kvb.wu), ), } } @@ -138,9 +145,11 @@ func NewSatPerKVByte(fee btcutil.Amount, kvb KVByte) SatPerKVByte { func (s SatPerKVByte) FeeForVSize(vbytes VByte) btcutil.Amount { fee := new(big.Rat).Mul( s.Rat, - new(big.Rat).SetInt64(safeUint64ToInt64(vbytes.val)), + new(big.Rat).SetInt64(safeUint64ToInt64(vbytes.wu)), + ) + fee.Quo(fee, new(big.Rat).SetInt64( + blockchain.WitnessScaleFactor*SatsPerKilo), ) - fee.Quo(fee, new(big.Rat).SetInt64(SatsPerKilo)) return roundToAmount(fee) } @@ -215,14 +224,14 @@ type SatPerKWeight struct { // NewSatPerKWeight creates a new fee rate in sat/kw. The given fee and weight // are used to calculate the fee rate. func NewSatPerKWeight(fee btcutil.Amount, wu WeightUnit) SatPerKWeight { - if wu.val == 0 { + if wu.wu == 0 { return SatPerKWeight{big.NewRat(0, 1)} } return SatPerKWeight{ big.NewRat( int64(fee)*SatsPerKilo, - safeUint64ToInt64(wu.val), + safeUint64ToInt64(wu.wu), ), } } @@ -232,7 +241,7 @@ func NewSatPerKWeight(fee btcutil.Amount, wu WeightUnit) SatPerKWeight { func (s SatPerKWeight) FeeForWeight(wu WeightUnit) btcutil.Amount { // The resulting fee is rounded down, as specified in BOLT#03. fee := new(big.Rat).Mul( - s.Rat, big.NewRat(safeUint64ToInt64(wu.val), SatsPerKilo), + s.Rat, big.NewRat(safeUint64ToInt64(wu.wu), SatsPerKilo), ) return btcutil.Amount(new(big.Int).Div(fee.Num(), fee.Denom()).Int64()) @@ -249,7 +258,7 @@ func (s SatPerKWeight) FeeForWeightRoundUp( // This ensures that any fractional part of the fee is rounded up to // the next whole satoshi. feeRat := new(big.Rat).Mul( - s.Rat, big.NewRat(safeUint64ToInt64(wu.val), SatsPerKilo), + s.Rat, big.NewRat(safeUint64ToInt64(wu.wu), SatsPerKilo), ) num := feeRat.Num() diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go index e2b5ed9ef0..2bc5a80241 100644 --- a/pkg/btcunit/rates_test.go +++ b/pkg/btcunit/rates_test.go @@ -343,22 +343,24 @@ func TestSafeUint64ToInt64Overflow(t *testing.T) { t.Parallel() fee := btcutil.Amount(1) - overflowVByte := NewVByte(math.MaxInt64 + 1) // Test NewSatPerVByte with an overflowing vbyte value. // The denominator should be capped at math.MaxInt64. + // We manually construct the VByte to ensure wu > MaxInt64 without + // overflowing the constructor's internal multiplication. + overflowVByte := VByte{baseUnit{wu: math.MaxInt64 + 1}} rateVB := NewSatPerVByte(fee, overflowVByte) expectedDenom := big.NewInt(math.MaxInt64) require.Zero(t, expectedDenom.Cmp(rateVB.Denom())) // Test NewSatPerKVByte with an overflowing kvb value. // The denominator should be capped at math.MaxInt64. - overflowKVByte := NewKVByte(math.MaxInt64 + 1) + overflowKVByte := KVByte{baseUnit{wu: math.MaxInt64 + 1}} rateKVB := NewSatPerKVByte(fee, overflowKVByte) require.Zero(t, expectedDenom.Cmp(rateKVB.Denom())) // Test NewSatPerKWeight with an overflowing weight unit value. - overflowWU := NewWeightUnit(math.MaxInt64 + 1) + overflowWU := WeightUnit{baseUnit{wu: math.MaxInt64 + 1}} rateKW := NewSatPerKWeight(fee, overflowWU) require.Zero(t, expectedDenom.Cmp(rateKW.Denom())) } diff --git a/pkg/btcunit/txsize.go b/pkg/btcunit/txsize.go index 0a45b18a5f..bc61cbe57f 100644 --- a/pkg/btcunit/txsize.go +++ b/pkg/btcunit/txsize.go @@ -2,11 +2,36 @@ package btcunit import ( "fmt" - "math" "github.com/btcsuite/btcd/blockchain" ) +// baseUnit stores the canonical representation of a transaction size, which is +// weight units (wu). All other size units are derived from this. +type baseUnit struct { + wu uint64 +} + +// ToWU converts the unit to a WeightUnit. +func (b baseUnit) ToWU() WeightUnit { + return WeightUnit{b} +} + +// ToVB converts the unit to a VByte. +func (b baseUnit) ToVB() VByte { + return VByte{b} +} + +// ToKVB converts the unit to a KVByte. +func (b baseUnit) ToKVB() KVByte { + return KVByte{b} +} + +// ToKWU converts the unit to a KWeightUnit. +func (b baseUnit) ToKWU() KWeightUnit { + return KWeightUnit{b} +} + // WeightUnit defines a unit to express the transaction size. One weight unit // is 1/4_000_000 of the max block size. The tx weight is calculated using // `Base tx size * 3 + Total tx size`. @@ -15,86 +40,73 @@ import ( // - Total tx size is the transaction size in bytes serialized according // #BIP144. type WeightUnit struct { - val uint64 + // The internal size is recorded in weight units. + baseUnit } -// NewWeightUnit creates a new WeightUnit from a uint64. +// NewWeightUnit creates a new WeightUnit from a uint64 value. func NewWeightUnit(val uint64) WeightUnit { - return WeightUnit{val: val} -} - -// ToVB converts a value expressed in weight units to virtual bytes. -func (wu WeightUnit) ToVB() VByte { - // According to BIP141: Virtual transaction size is defined as - // Transaction weight / 4 (rounded up to the next integer). - vbytes := math.Ceil(float64(wu.val) / blockchain.WitnessScaleFactor) - return VByte{val: uint64(vbytes)} + return WeightUnit{baseUnit{wu: val}} } // String returns the string representation of the weight unit. -func (wu WeightUnit) String() string { - return fmt.Sprintf("%d wu", wu.val) +func (w WeightUnit) String() string { + return fmt.Sprintf("%d wu", w.wu) } // VByte defines a unit to express the transaction size. One virtual byte is // 1/4th of a weight unit. The tx virtual bytes is calculated using `TxWeight / // 4`. type VByte struct { - val uint64 + // The internal size is recorded in weight units. + baseUnit } -// NewVByte creates a new VByte from a uint64. +// NewVByte creates a new VByte from a uint64 value. func NewVByte(val uint64) VByte { - return VByte{val: val} -} - -// ToWU converts a value expressed in virtual bytes to weight units. -func (vb VByte) ToWU() WeightUnit { - return WeightUnit{val: vb.val * blockchain.WitnessScaleFactor} + return VByte{baseUnit{wu: val * blockchain.WitnessScaleFactor}} } // String returns the string representation of the virtual byte. -func (vb VByte) String() string { - return fmt.Sprintf("%d vb", vb.val) +func (v VByte) String() string { + vbytes := (v.wu + blockchain.WitnessScaleFactor - 1) / + blockchain.WitnessScaleFactor + + return fmt.Sprintf("%d vb", vbytes) } // KVByte defines a unit to express the transaction size in kilo-virtual-bytes. type KVByte struct { - val uint64 + // The internal size is recorded in weight units. + baseUnit } // NewKVByte creates a new KVByte from a uint64. func NewKVByte(val uint64) KVByte { - return KVByte{val: val} -} - -// ToVB converts a value expressed in kilo-virtual-bytes to virtual bytes. -func (kvb KVByte) ToVB() VByte { - return VByte{val: kvb.val * 1000} + return KVByte{baseUnit{wu: val * kilo * blockchain.WitnessScaleFactor}} } // String returns the string representation of the kilo-virtual-byte. -func (kvb KVByte) String() string { - return fmt.Sprintf("%d kvb", kvb.val) +func (k KVByte) String() string { + vbytes := (k.wu + blockchain.WitnessScaleFactor - 1) / + blockchain.WitnessScaleFactor + + return fmt.Sprintf("%d kvb", vbytes/kilo) } // KWeightUnit defines a unit to express the transaction size in // kilo-weight-units. type KWeightUnit struct { - val uint64 + // The internal size is recorded in weight units. + baseUnit } // NewKWeightUnit creates a new KWeightUnit from a uint64. func NewKWeightUnit(val uint64) KWeightUnit { - return KWeightUnit{val: val} -} - -// ToWU converts a value expressed in kilo-weight-units to weight units. -func (kwu KWeightUnit) ToWU() WeightUnit { - return WeightUnit{val: kwu.val * 1000} + return KWeightUnit{baseUnit{wu: val * kilo}} } // String returns the string representation of the kilo-weight-unit. -func (kwu KWeightUnit) String() string { - return fmt.Sprintf("%d kwu", kwu.val) +func (k KWeightUnit) String() string { + return fmt.Sprintf("%d kwu", k.wu/kilo) } diff --git a/pkg/btcunit/txsize_test.go b/pkg/btcunit/txsize_test.go index 722e74efa2..63835dcdcb 100644 --- a/pkg/btcunit/txsize_test.go +++ b/pkg/btcunit/txsize_test.go @@ -6,31 +6,109 @@ import ( "github.com/stretchr/testify/require" ) +// TestBaseUnitConversions checks that the conversion methods of baseUnit are +// correct. +func TestBaseUnitConversions(t *testing.T) { + t.Parallel() + + // Test data: 1000 weight units. + base := baseUnit{wu: 1000} + + // Test ToWU: 1000 wu. + wu := base.ToWU() + require.Equal(t, uint64(1000), wu.wu) + + // Test ToVByte: 1000 wu (250 vb). + vb := base.ToVB() + require.Equal(t, uint64(1000), vb.wu) + + // Test ToKVByte: 1000 wu (0.25 kvb). + kvb := base.ToKVB() + require.Equal(t, uint64(1000), kvb.wu) + + // Test ToKWeightUnit: 1000 wu (1 kwu). + kwu := base.ToKWU() + require.Equal(t, uint64(1000), kwu.wu) +} + // TestTxSizeConversion checks that the conversion between weight units and // virtual bytes is correct. func TestTxSizeConversion(t *testing.T) { t.Parallel() - // Create a test weight of 1000 wu. - wu := NewWeightUnit(1000) + // We'll use 4000 weight units (wu) as our base for testing. This is + // equivalent to 1000 virtual bytes (vb), 1 kilo-virtual-byte (kvb), + // and 4 kilo-weight-units (kwu). + // + // Initialize the same size in different units. + wu := NewWeightUnit(4000) + vb := NewVByte(1000) + kvb := NewKVByte(1) + kwu := NewKWeightUnit(4) - // 1000 wu should be equal to 250 vb. - require.Equal(t, NewVByte(250), wu.ToVB()) + // Check that the internal 'wu' values are consistent across different + // unit types representing the same size. + require.Equal(t, uint64(4000), wu.wu) + require.Equal(t, uint64(4000), vb.wu) + require.Equal(t, uint64(4000), kvb.wu) + require.Equal(t, uint64(4000), kwu.wu) - // 250 vb should be equal to 1000 wu. - require.Equal(t, wu, NewVByte(250).ToWU()) + // Test conversions from WeightUnit. After conversion, the underlying + // weight units (wu) should remain 4000. + require.Equal(t, uint64(4000), wu.ToWU().wu) + require.Equal(t, uint64(4000), wu.ToVB().wu) + require.Equal(t, uint64(4000), wu.ToKVB().wu) + require.Equal(t, uint64(4000), wu.ToKWU().wu) + require.Equal(t, "4000 wu", wu.String()) - // Create a test kvbyte of 1 kvb. - kvb := NewKVByte(1) + // Test conversions from VByte. After conversion, the underlying weight + // units (wu) should remain 4000. + require.Equal(t, uint64(4000), vb.ToWU().wu) + require.Equal(t, uint64(4000), vb.ToVB().wu) + require.Equal(t, uint64(4000), vb.ToKVB().wu) + require.Equal(t, uint64(4000), vb.ToKWU().wu) + require.Equal(t, "1000 vb", vb.String()) - // 1 kvb should be equal to 1000 vb. - require.Equal(t, NewVByte(1000), kvb.ToVB()) + // Test conversions from KVByte. After conversion, the underlying + // weight units (wu) should remain 4000. + require.Equal(t, uint64(4000), kvb.ToWU().wu) + require.Equal(t, uint64(4000), kvb.ToVB().wu) + require.Equal(t, uint64(4000), kvb.ToKVB().wu) + require.Equal(t, uint64(4000), kvb.ToKWU().wu) + require.Equal(t, "1 kvb", kvb.String()) - // Create a test kweightunit of 1 kwu. - kwu := NewKWeightUnit(1) + // Test conversions from KWeightUnit. After conversion, the underlying + // weight units (wu) should remain 4000. + require.Equal(t, uint64(4000), kwu.ToWU().wu) + require.Equal(t, uint64(4000), kwu.ToVB().wu) + require.Equal(t, uint64(4000), kwu.ToKVB().wu) + require.Equal(t, uint64(4000), kwu.ToKWU().wu) + require.Equal(t, "4 kwu", kwu.String()) +} + +// TestTxSizePrecision checks that precision is preserved when converting +// between units for values that are not perfectly divisible by the witness +// scale factor. +func TestTxSizePrecision(t *testing.T) { + t.Parallel() + + // Use a weight unit value that is not divisible by 4 + // (WitnessScaleFactor). + // 3999 % 4 = 3. + wu := NewWeightUnit(3999) + + // Convert to VByte. This should wrap the same underlying wu value. + vb := wu.ToVB() + require.Equal(t, uint64(3999), vb.wu) + + // Convert back to WeightUnit. Should still be 3999. + wu2 := vb.ToWU() + require.Equal(t, uint64(3999), wu2.wu) - // 1 kwu should be equal to 1000 wu. - require.Equal(t, NewWeightUnit(1000), kwu.ToWU()) + // The string representation should still perform the rounding for + // display. + // ceil(3999 / 4) = 1000. + require.Equal(t, "1000 vb", vb.String()) } // TestTxSizeStringer tests the stringer methods of the tx size types. From d0166e4c159c8e22e4ed445f4389995a60cce750 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 19 Nov 2025 05:32:01 +0800 Subject: [PATCH 166/691] btcunit+wallet: introduce baseFeeRate for precise fee calculations This commit refactors the `btcunit` package to improve the precision and safety of fee rate calculations. Key changes include: 1. **Introduction of `baseFeeRate`**: A new internal struct `baseFeeRate` has been added to serve as the canonical representation for all fee rates. It stores the rate as satoshis per kilo-weight-unit (`sat/kwu`) using `*math/big.Rat`. * **Motivation**: Previously, fee rates were often converted between units (e.g., `sat/vb` to `sat/kw`) using integer arithmetic or floats, leading to potential rounding errors or loss of precision, especially for low fee rates (e.g., < 1 sat/vb) or when scaling values. By normalizing all rates to `sat/kwu` (the finest granularity aligned with consensus) and using arbitrary-precision arithmetic, we ensure that fee calculations remain accurate regardless of the input unit. 2. **Unified Fee Rate Types**: `SatPerVByte`, `SatPerKVByte`, and `SatPerKWeight` now embed `baseFeeRate`. This centralizes logic for comparisons (`Equal`, `GreaterThan`) and fee calculations (`FeeForWeight`), reducing code duplication and ensuring consistent behavior across all units. 3. **Testing & Linting**: Updated unit tests to verify the new precision and formatting rules. Addressed `goconst` and `revive` linter issues in `wallet` and `btcunit` packages. --- pkg/btcunit/rates.go | 443 +++++++++++++++++++++----------------- pkg/btcunit/rates_test.go | 265 +++++++++++++++-------- wallet/tx_creator_test.go | 7 +- wallet/tx_reader.go | 3 +- wallet/tx_reader_test.go | 5 +- 5 files changed, 427 insertions(+), 296 deletions(-) diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go index ee5db38d48..c2fda5f8dd 100644 --- a/pkg/btcunit/rates.go +++ b/pkg/btcunit/rates.go @@ -15,328 +15,367 @@ import ( ) const ( - // SatsPerKilo is the number of satoshis in a kilo-satoshi. - SatsPerKilo = 1000 - // kilo is a generic multiplier for kilo units. kilo = 1000 // floatStringPrecision is the number of decimal places to use when - // converting a fee rate to a string. - floatStringPrecision = 2 + // converting a fee rate to a string. We use 3 decimal places to ensure + // that low fee rates (e.g., 1 sat/kvb = 0.001 sat/vbyte) are displayed + // with sufficient precision and not rounded to zero. + floatStringPrecision = 3 ) -// SatPerVByte represents a fee rate in sat/vbyte. The fee rate is encoded -// as a big.Rat to allow for fractional (sub-satoshi) fee rates. -type SatPerVByte struct { - *big.Rat -} +var ( + // ZeroSatPerVByte is a fee rate of 0 sat/vb. + ZeroSatPerVByte = NewSatPerVByte(0, NewVByte(1)) -// NewSatPerVByte creates a new fee rate in sat/vb. The given fee and vbytes -// are used to calculate the fee rate. -func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { - if vb.wu == 0 { - return SatPerVByte{big.NewRat(0, 1)} - } + // ZeroSatPerKVByte is a fee rate of 0 sat/kvb. + ZeroSatPerKVByte = NewSatPerKVByte(0, NewKVByte(1)) - return SatPerVByte{ - big.NewRat( - int64(fee)*blockchain.WitnessScaleFactor, - safeUint64ToInt64(vb.wu), - ), + // ZeroSatPerKWeight is a fee rate of 0 sat/kw. + ZeroSatPerKWeight = NewSatPerKWeight(0, NewKWeightUnit(1)) +) + +// baseFeeRate stores the canonical representation of a fee rate, which is +// satoshis per kilo-weight-unit (sat/kwu). All other fee rate units are +// derived from this. +type baseFeeRate struct { + // satsPerKWU is the fee rate in satoshis per kilo-weight-unit. This is + // the canonical representation for all fee rates within this package, + // chosen for its direct alignment with Bitcoin's weight unit for fee + // calculations and to minimize rounding errors. + satsPerKWU *big.Rat +} + +// newBaseFeeRate creates a new baseFeeRate with the given numerator and +// denominator. It handles the zero denominator case by returning a zero fee +// rate. +func newBaseFeeRate(numerator btcutil.Amount, denominator uint64) baseFeeRate { + if denominator == 0 { + return baseFeeRate{satsPerKWU: big.NewRat(0, 1)} } + + return baseFeeRate{satsPerKWU: big.NewRat( + int64(numerator), + safeUint64ToInt64(denominator), + )} } -// ToSatPerKWeight converts the current fee rate from sat/vb to sat/kw. -func (s SatPerVByte) ToSatPerKWeight() SatPerKWeight { - vbToKwRate := big.NewRat(SatsPerKilo, blockchain.WitnessScaleFactor) - kwRate := new(big.Rat).Mul(s.Rat, vbToKwRate) +// ToSatPerVByte converts the fee rate to sat/vb. +func (f baseFeeRate) ToSatPerVByte() SatPerVByte { + return SatPerVByte{f} +} - return SatPerKWeight{kwRate} +// ToSatPerKVByte converts the fee rate to sat/kvb. +func (f baseFeeRate) ToSatPerKVByte() SatPerKVByte { + return SatPerKVByte{f} } -// ToSatPerKVByte converts the current fee rate from sat/vb to sat/kvb. -func (s SatPerVByte) ToSatPerKVByte() SatPerKVByte { - vbToKvbRate := big.NewRat(SatsPerKilo, 1) - kvbRate := new(big.Rat).Mul(s.Rat, vbToKvbRate) +// ToSatPerKWeight converts the fee rate to sat/kw. +func (f baseFeeRate) ToSatPerKWeight() SatPerKWeight { + return SatPerKWeight{f} +} + +// FeeForWeight calculates the fee resulting from this fee rate and the given +// weight in weight units (wu). +func (f baseFeeRate) FeeForWeight(weightUnit WeightUnit) btcutil.Amount { + // The fee rate is stored as satoshis per kilo-weight-unit (sat/kwu). + // To calculate the fee for a given weight, we need to multiply the + // rate by the weight expressed in kilo-weight-units. We do this by + // creating a rational number of weightUnit.wu / kilo. + // + // The resulting fee is rounded down (truncated). + feeRateRational := big.NewRat(0, 1) + feeRateRational.Mul( + f.satsPerKWU, + big.NewRat(safeUint64ToInt64(weightUnit.wu), kilo), + ) + + // Extract the numerator and denominator for integer division. + numerator := feeRateRational.Num() + denominator := feeRateRational.Denom() - return SatPerKVByte{kvbRate} + // Perform integer division to truncate the result (round down). + quotient := big.NewInt(0) + quotient.Div(numerator, denominator) + + return btcutil.Amount(quotient.Int64()) } -// FeeForVSize calculates the fee resulting from this fee rate and the given -// vsize in vbytes. -func (s SatPerVByte) FeeForVSize(vbytes VByte) btcutil.Amount { - fee := new(big.Rat).Mul( - s.Rat, - new(big.Rat).SetInt64(safeUint64ToInt64(vbytes.wu)), +// FeeForWeightRoundUp calculates the fee resulting from this fee rate and the +// given weight in weight units (wu), rounding up to the nearest satoshi. +func (f baseFeeRate) FeeForWeightRoundUp(weightUnit WeightUnit) btcutil.Amount { + // The rounding logic for ceiling division is based on the formula: + // (numerator + denominator - 1) / denominator + // This ensures that any fractional part of the fee is rounded up to + // the next whole satoshi. + // + // Calculate the fee rate as a rational number. + feeRateRational := big.NewRat(0, 1) + feeRateRational.Mul( + f.satsPerKWU, big.NewRat( + safeUint64ToInt64(weightUnit.wu), kilo, + ), ) - fee.Quo(fee, new(big.Rat).SetInt64(blockchain.WitnessScaleFactor)) - return roundToAmount(fee) + // Get the numerator and denominator of the calculated fee. + numerator := feeRateRational.Num() + denominator := feeRateRational.Denom() + + // Initialize a new big.Int to store the result of the ceiling division. + result := big.NewInt(0) + + // Apply the ceiling division formula: + // (numerator + denominator - 1) / denominator. + result.Add(numerator, denominator) + result.Sub(result, big.NewInt(1)) + result.Div(result, denominator) + + return btcutil.Amount(result.Int64()) } -// FeeForWeight calculates the fee resulting from this fee rate and the given -// weight in weight units. -func (s SatPerVByte) FeeForWeight(wu WeightUnit) btcutil.Amount { - return s.FeeForVSize(wu.ToVB()) +// FeeForVByte calculates the fee resulting from this fee rate and the given +// size in vbytes (vb). +func (f baseFeeRate) FeeForVByte(vb VByte) btcutil.Amount { + return f.FeeForWeight(vb.ToWU()) } // FeeForKVByte calculates the fee resulting from this fee rate and the given // vsize in kilo-vbytes. -func (s SatPerVByte) FeeForKVByte(kvbytes KVByte) btcutil.Amount { - return s.FeeForVSize(kvbytes.ToVB()) +func (f baseFeeRate) FeeForKVByte(kvb KVByte) btcutil.Amount { + // Directly convert kilo-virtual-bytes to weight units for fee + // calculation to maintain precision and avoid intermediate rounding + // effects. + return f.FeeForWeight(kvb.ToWU()) +} + +// FeeForKWeight calculates the fee resulting from this fee rate and the given +// weight in kilo-weight-units (kwu). +func (f baseFeeRate) FeeForKWeight(kwu KWeightUnit) btcutil.Amount { + return f.FeeForWeight(kwu.ToWU()) +} + +// equal returns true if the fee rate is equal to the other fee rate. +func (f baseFeeRate) equal(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) == 0 +} + +// greaterThan returns true if the fee rate is greater than the other fee rate. +func (f baseFeeRate) greaterThan(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) > 0 +} + +// lessThan returns true if the fee rate is less than the other fee rate. +func (f baseFeeRate) lessThan(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) < 0 +} + +// greaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (f baseFeeRate) greaterThanOrEqual(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) >= 0 +} + +// lessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (f baseFeeRate) lessThanOrEqual(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) <= 0 +} + +// SatPerVByte represents a fee rate in sat/vbyte. Internally, all fee rates +// are stored and operated on as satoshis per kilo-weight-unit (sat/kw). +// Conversions to other units and fee calculations are performed using this +// canonical internal representation. The `String()` method is the only one +// that presents the fee rate in its specific sat/vbyte unit. +type SatPerVByte struct { + baseFeeRate +} + +// NewSatPerVByte creates a new fee rate in sat/vb. The given fee and vbytes +// are used to calculate the fee rate. +func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { + // The fee is provided in satoshis. To convert this to our internal + // canonical unit of satoshis per kilo-weight-unit (sat/kwu), we need + // to scale the numerator. Multiplying by `kilo` (1000) effectively + // converts the 'per vbyte' rate to a 'per kilo-vbyte' rate, which then + // aligns with the 'kilo' in sat/kwu after the denominator is processed. + numerator := fee.MulF64(kilo) + + // The denominator is the size in vbytes. `vb.wu` provides the + // equivalent transaction weight in weight units (wu), which implicitly + // accounts for the `WitnessScaleFactor` (4). + denominator := vb.wu + + return SatPerVByte{newBaseFeeRate(numerator, denominator)} } // String returns a human-readable string of the fee rate. func (s SatPerVByte) String() string { - return s.FloatString(floatStringPrecision) + " sat/vb" + // Calculate the fee rate in sat/vb from the canonical sat/kwu. + // The WitnessScaleFactor (4) is used to convert weight units to vbytes. + // The `kilo` constant is used to scale kilo-weight-units. + kwToVbRate := big.NewRat(0, 1) + kwToVbRate.Mul(s.satsPerKWU, + big.NewRat(blockchain.WitnessScaleFactor, kilo), + ) + + // Format the rational number to a string with the specified precision. + return kwToVbRate.FloatString(floatStringPrecision) + " sat/vb" } // Equal returns true if the fee rate is equal to the other fee rate. func (s SatPerVByte) Equal(other SatPerVByte) bool { - return s.Cmp(other.Rat) == 0 + return s.equal(other.baseFeeRate) } // GreaterThan returns true if the fee rate is greater than the other fee rate. func (s SatPerVByte) GreaterThan(other SatPerVByte) bool { - return s.Cmp(other.Rat) > 0 + return s.greaterThan(other.baseFeeRate) } // LessThan returns true if the fee rate is less than the other fee rate. func (s SatPerVByte) LessThan(other SatPerVByte) bool { - return s.Cmp(other.Rat) < 0 + return s.lessThan(other.baseFeeRate) } // GreaterThanOrEqual returns true if the fee rate is greater than or equal to // the other fee rate. func (s SatPerVByte) GreaterThanOrEqual(other SatPerVByte) bool { - return s.Cmp(other.Rat) >= 0 + return s.greaterThanOrEqual(other.baseFeeRate) } // LessThanOrEqual returns true if the fee rate is less than or equal to the // other fee rate. func (s SatPerVByte) LessThanOrEqual(other SatPerVByte) bool { - return s.Cmp(other.Rat) <= 0 + return s.lessThanOrEqual(other.baseFeeRate) } -// SatPerKVByte represents a fee rate in sat/kb. The fee rate is encoded as a -// big.Rat to allow for fractional (sub-satoshi) fee rates. +// SatPerKVByte represents a fee rate in sat/kvb. Internally, all fee rates +// are stored and operated on as satoshis per kilo-weight-unit (sat/kw). +// Conversions to other units and fee calculations are performed using this +// canonical internal representation. The `String()` method is the only one +// that presents the fee rate in its specific sat/kvb unit. type SatPerKVByte struct { - *big.Rat + baseFeeRate } // NewSatPerKVByte creates a new fee rate in sat/kvb. The given fee and // kvbytes are used to calculate the fee rate. func NewSatPerKVByte(fee btcutil.Amount, kvb KVByte) SatPerKVByte { - if kvb.wu == 0 { - return SatPerKVByte{big.NewRat(0, 1)} - } + // The fee is provided in satoshis. To convert this to our internal + // canonical unit of satoshis per kilo-weight-unit (sat/kwu), we need + // to scale the numerator. Multiplying by `kilo` (1000) effectively + // scales the fee to align with the 'kilo' in sat/kwu after the + // denominator is processed. + numerator := fee.MulF64(kilo) - return SatPerKVByte{ - big.NewRat( - int64(fee)*blockchain.WitnessScaleFactor*SatsPerKilo, - safeUint64ToInt64(kvb.wu), - ), - } -} - -// FeeForVSize calculates the fee resulting from this fee rate and the given -// vsize in vbytes. -func (s SatPerKVByte) FeeForVSize(vbytes VByte) btcutil.Amount { - fee := new(big.Rat).Mul( - s.Rat, - new(big.Rat).SetInt64(safeUint64ToInt64(vbytes.wu)), - ) - fee.Quo(fee, new(big.Rat).SetInt64( - blockchain.WitnessScaleFactor*SatsPerKilo), - ) - - return roundToAmount(fee) -} - -// FeeForKVByte calculates the fee resulting from this fee rate and the given -// vsize in kilo-vbytes. -func (s SatPerKVByte) FeeForKVByte(kvbytes KVByte) btcutil.Amount { - return s.FeeForVSize(kvbytes.ToVB()) -} - -// FeeForWeight calculates the fee resulting from this fee rate and the given -// weight in weight units. -func (s SatPerKVByte) FeeForWeight(wu WeightUnit) btcutil.Amount { - return s.FeeForVSize(wu.ToVB()) -} + // The denominator is the size in kilo-vbytes. `kvb.wu` provides the + // equivalent transaction weight in weight units (wu), which implicitly + // accounts for the `WitnessScaleFactor` (4) and the `kilo` scaling for + // kilo-vbytes. + denominator := kvb.wu -// ToSatPerKWeight converts the current fee rate from sat/kb to sat/kw. -func (s SatPerKVByte) ToSatPerKWeight() SatPerKWeight { - kvbToKwRate := big.NewRat(1, blockchain.WitnessScaleFactor) - kwRate := new(big.Rat).Mul(s.Rat, kvbToKwRate) - - return SatPerKWeight{kwRate} -} - -// ToSatPerVByte converts the current fee rate from sat/kvb to sat/vb. -func (s SatPerKVByte) ToSatPerVByte() SatPerVByte { - kvbToVbRate := new(big.Rat).Quo( - s.Rat, new(big.Rat).SetInt64(SatsPerKilo), - ) - - return SatPerVByte{kvbToVbRate} + return SatPerKVByte{newBaseFeeRate(numerator, denominator)} } // String returns a human-readable string of the fee rate. func (s SatPerKVByte) String() string { - return s.FloatString(floatStringPrecision) + " sat/kvb" + // Calculate the fee rate in sat/kvb from the canonical sat/kwu. + // The WitnessScaleFactor (4) is used to convert weight units to vbytes. + // No `kilo` division here as we are converting to *kilo*-vbytes. + kwToKvbRate := big.NewRat(0, 1) + kwToKvbRate.Mul(s.satsPerKWU, + big.NewRat(blockchain.WitnessScaleFactor, 1), + ) + + // Format the rational number to a string with the specified precision. + return kwToKvbRate.FloatString(floatStringPrecision) + + " sat/kvb" } // Equal returns true if the fee rate is equal to the other fee rate. func (s SatPerKVByte) Equal(other SatPerKVByte) bool { - return s.Cmp(other.Rat) == 0 + return s.equal(other.baseFeeRate) } // GreaterThan returns true if the fee rate is greater than the other fee rate. func (s SatPerKVByte) GreaterThan(other SatPerKVByte) bool { - return s.Cmp(other.Rat) > 0 + return s.greaterThan(other.baseFeeRate) } // LessThan returns true if the fee rate is less than the other fee rate. func (s SatPerKVByte) LessThan(other SatPerKVByte) bool { - return s.Cmp(other.Rat) < 0 + return s.lessThan(other.baseFeeRate) } // GreaterThanOrEqual returns true if the fee rate is greater than or equal to // the other fee rate. func (s SatPerKVByte) GreaterThanOrEqual(other SatPerKVByte) bool { - return s.Cmp(other.Rat) >= 0 + return s.greaterThanOrEqual(other.baseFeeRate) } // LessThanOrEqual returns true if the fee rate is less than or equal to the // other fee rate. func (s SatPerKVByte) LessThanOrEqual(other SatPerKVByte) bool { - return s.Cmp(other.Rat) <= 0 + return s.lessThanOrEqual(other.baseFeeRate) } -// SatPerKWeight represents a fee rate in sat/kw. The fee rate is encoded as a -// big.Rat to allow for fractional (sub-satoshi) fee rates. +// SatPerKWeight represents a fee rate in sat/kw. Internally, all fee rates +// are stored and operated on as satoshis per kilo-weight-unit (sat/kw). +// Conversions to other units and fee calculations are performed using this +// canonical internal representation. The `String()` method is the only one +// that presents the fee rate in its specific sat/kw unit. type SatPerKWeight struct { - *big.Rat -} - -// NewSatPerKWeight creates a new fee rate in sat/kw. The given fee and weight -// are used to calculate the fee rate. -func NewSatPerKWeight(fee btcutil.Amount, wu WeightUnit) SatPerKWeight { - if wu.wu == 0 { - return SatPerKWeight{big.NewRat(0, 1)} - } - - return SatPerKWeight{ - big.NewRat( - int64(fee)*SatsPerKilo, - safeUint64ToInt64(wu.wu), - ), - } -} - -// FeeForWeight calculates the fee resulting from this fee rate and the given -// weight in weight units (wu). -func (s SatPerKWeight) FeeForWeight(wu WeightUnit) btcutil.Amount { - // The resulting fee is rounded down, as specified in BOLT#03. - fee := new(big.Rat).Mul( - s.Rat, big.NewRat(safeUint64ToInt64(wu.wu), SatsPerKilo), - ) - - return btcutil.Amount(new(big.Int).Div(fee.Num(), fee.Denom()).Int64()) -} - -// FeeForWeightRoundUp calculates the fee resulting from this fee rate and the -// given weight in weight units (wu), rounding up to the nearest satoshi. -func (s SatPerKWeight) FeeForWeightRoundUp( - wu WeightUnit) btcutil.Amount { - - // The rounding logic is based on the ceiling division formula: - // (numerator + denominator - 1) / denominator - // - // This ensures that any fractional part of the fee is rounded up to - // the next whole satoshi. - feeRat := new(big.Rat).Mul( - s.Rat, big.NewRat(safeUint64ToInt64(wu.wu), SatsPerKilo), - ) - - num := feeRat.Num() - den := feeRat.Denom() - num.Add(num, den) - num.Sub(num, big.NewInt(1)) - num.Div(num, den) - - return btcutil.Amount(num.Int64()) + baseFeeRate } -// FeeForVByte calculates the fee resulting from this fee rate and the given -// size in vbytes (vb). -func (s SatPerKWeight) FeeForVByte(vb VByte) btcutil.Amount { - return s.ToSatPerKVByte().FeeForVSize(vb) -} +// NewSatPerKWeight creates a new fee rate in sat/kw. The given fee and +// kweight units are used to calculate the fee rate. +func NewSatPerKWeight(fee btcutil.Amount, kwu KWeightUnit) SatPerKWeight { + // The fee is provided in satoshis. To convert this to our internal + // canonical unit of satoshis per kilo-weight-unit (sat/kwu), we need + // to scale the numerator. Multiplying by `kilo` (1000) is consistent + // with the definition of sat/kwu. + numerator := fee.MulF64(kilo) -// FeeForKVByte calculates the fee resulting from this fee rate and the given -// vsize in kilo-vbytes. -func (s SatPerKWeight) FeeForKVByte(kvb KVByte) btcutil.Amount { - return s.FeeForVByte(kvb.ToVB()) -} + // The denominator is the size in kilo-weight-units. `kwu.wu` provides + // the equivalent transaction weight in weight units (wu), which + // implicitly accounts for the `kilo` scaling for kilo-weight-units. + denominator := kwu.wu -// ToSatPerKVByte converts the current fee rate from sat/kw to sat/kb. -func (s SatPerKWeight) ToSatPerKVByte() SatPerKVByte { - kwToKvbRate := big.NewRat(blockchain.WitnessScaleFactor, 1) - kwRate := new(big.Rat).Mul(s.Rat, kwToKvbRate) - - return SatPerKVByte{kwRate} -} - -// ToSatPerVByte converts the current fee rate from sat/kw to sat/vb. -func (s SatPerKWeight) ToSatPerVByte() SatPerVByte { - kwToVbRate := big.NewRat(blockchain.WitnessScaleFactor, SatsPerKilo) - vbRate := new(big.Rat).Mul(s.Rat, kwToVbRate) - - return SatPerVByte{vbRate} + return SatPerKWeight{newBaseFeeRate(numerator, denominator)} } // String returns a human-readable string of the fee rate. func (s SatPerKWeight) String() string { - return s.FloatString(floatStringPrecision) + " sat/kw" + return s.satsPerKWU.FloatString(floatStringPrecision) + " sat/kw" } // Equal returns true if the fee rate is equal to the other fee rate. func (s SatPerKWeight) Equal(other SatPerKWeight) bool { - return s.Cmp(other.Rat) == 0 + return s.equal(other.baseFeeRate) } // GreaterThan returns true if the fee rate is greater than the other fee rate. func (s SatPerKWeight) GreaterThan(other SatPerKWeight) bool { - return s.Cmp(other.Rat) > 0 + return s.greaterThan(other.baseFeeRate) } // LessThan returns true if the fee rate is less than the other fee rate. func (s SatPerKWeight) LessThan(other SatPerKWeight) bool { - return s.Cmp(other.Rat) < 0 + return s.lessThan(other.baseFeeRate) } // GreaterThanOrEqual returns true if the fee rate is greater than or equal to // the other fee rate. func (s SatPerKWeight) GreaterThanOrEqual(other SatPerKWeight) bool { - return s.Cmp(other.Rat) >= 0 + return s.greaterThanOrEqual(other.baseFeeRate) } // LessThanOrEqual returns true if the fee rate is less than or equal to the // other fee rate. func (s SatPerKWeight) LessThanOrEqual(other SatPerKWeight) bool { - return s.Cmp(other.Rat) <= 0 -} - -// roundToAmount rounds a big.Rat to the nearest btcutil.Amount (int64), -// with halves rounded away from zero. For example, 2.4 rounds to 2, 2.5 -// rounds to 3, and -2.5 rounds to -3. -func roundToAmount(r *big.Rat) btcutil.Amount { - f, _ := r.Float64() - - return btcutil.Amount(math.Round(f)) + return s.lessThanOrEqual(other.baseFeeRate) } // safeUint64ToInt64 converts a uint64 to an int64, capping at math.MaxInt64. diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go index 2bc5a80241..fdb060e576 100644 --- a/pkg/btcunit/rates_test.go +++ b/pkg/btcunit/rates_test.go @@ -24,35 +24,37 @@ func TestFeeRateConversions(t *testing.T) { }{ { name: "1 sat/vb", - rate: SatPerVByte{big.NewRat(1, 1)}, - expectedVB: SatPerVByte{big.NewRat(1, 1)}, - expectedKVB: SatPerKVByte{big.NewRat(1000, 1)}, - expectedKW: SatPerKWeight{big.NewRat(250, 1)}, - expectedSats: 1, + rate: NewSatPerVByte(1, NewVByte(1)), + expectedVB: NewSatPerVByte(1, NewVByte(1)), + expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), + expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), + expectedSats: 250, }, { name: "1000 sat/kvb", - rate: SatPerKVByte{big.NewRat(1000, 1)}, - expectedVB: SatPerVByte{big.NewRat(1, 1)}, - expectedKVB: SatPerKVByte{big.NewRat(1000, 1)}, - expectedKW: SatPerKWeight{big.NewRat(250, 1)}, - expectedSats: 1000, + rate: NewSatPerKVByte(1000, NewKVByte(1)), + expectedVB: NewSatPerVByte(1, NewVByte(1)), + expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), + expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), + expectedSats: 250, }, { name: "250 sat/kw", - rate: SatPerKWeight{big.NewRat(250, 1)}, - expectedVB: SatPerVByte{big.NewRat(1, 1)}, - expectedKVB: SatPerKVByte{big.NewRat(1000, 1)}, - expectedKW: SatPerKWeight{big.NewRat(250, 1)}, + rate: NewSatPerKWeight(250, NewKWeightUnit(1)), + expectedVB: NewSatPerVByte(1, NewVByte(1)), + expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), + expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), expectedSats: 250, }, { - name: "0.11 sat/vb", - rate: SatPerVByte{big.NewRat(11, 100)}, - expectedVB: SatPerVByte{big.NewRat(11, 100)}, - expectedKVB: SatPerKVByte{big.NewRat(110, 1)}, - expectedKW: SatPerKWeight{big.NewRat(11000, 400)}, - expectedSats: 0, + name: "0.11 sat/vb", + rate: NewSatPerVByte(11, NewVByte(100)), + expectedVB: NewSatPerVByte(11, NewVByte(100)), + expectedKVB: NewSatPerKVByte(110, NewKVByte(1)), + expectedKW: NewSatPerKWeight( + 27500, NewKWeightUnit(1000), + ), + expectedSats: 27, }, } @@ -62,45 +64,68 @@ func TestFeeRateConversions(t *testing.T) { switch r := tc.rate.(type) { case SatPerVByte: - require.True(t, tc.expectedVB.Equal(r)) - require.True(t, tc.expectedKVB.Equal( - r.ToSatPerKVByte()), + require.True(t, tc.expectedVB.equal( + r.ToSatPerVByte().baseFeeRate, + )) + require.True(t, tc.expectedKVB.equal( + r.ToSatPerKVByte().baseFeeRate, + )) + require.True(t, tc.expectedKW.equal( + r.ToSatPerKWeight().baseFeeRate, + )) + + // Calculate the floor of the fee rate. + floor := big.NewInt(0) + floor.Div( + r.satsPerKWU.Num(), + r.satsPerKWU.Denom(), ) - require.True(t, tc.expectedKW.Equal( - r.ToSatPerKWeight()), - ) - - // The expected sats is the floor of the fee - // rate. - floor := new(big.Int).Div(r.Num(), r.Denom()) require.Equal( t, tc.expectedSats, btcutil.Amount(floor.Int64()), ) case SatPerKVByte: - require.True(t, tc.expectedVB.Equal( - r.ToSatPerKWeight().ToSatPerVByte()), + require.True(t, tc.expectedVB.equal( + r.ToSatPerVByte().baseFeeRate, + )) + require.True( + t, tc.expectedKVB.equal(r.baseFeeRate), ) - require.True(t, tc.expectedKVB.Equal(r)) - require.True(t, tc.expectedKW.Equal( - r.ToSatPerKWeight()), + require.True(t, tc.expectedKW.equal( + r.ToSatPerKWeight().baseFeeRate, + )) + + // Calculate the floor of the fee rate. + floor := big.NewInt(0) + floor.Div( + r.satsPerKWU.Num(), + r.satsPerKWU.Denom(), ) - floor := new(big.Int).Div(r.Num(), r.Denom()) require.Equal( t, tc.expectedSats, btcutil.Amount(floor.Int64()), ) case SatPerKWeight: - require.True(t, tc.expectedVB.Equal( - r.ToSatPerVByte()), + require.True(t, + tc.expectedVB.equal( + r.ToSatPerVByte().baseFeeRate, + ), ) - require.True(t, tc.expectedKVB.Equal( - r.ToSatPerKVByte()), + require.True(t, tc.expectedKVB.equal( + r.ToSatPerKVByte().baseFeeRate, + )) + require.True( + t, tc.expectedKW.equal(r.baseFeeRate), + ) + + // Calculate the floor of the fee rate. + floor := big.NewInt(0) + floor.Div( + r.satsPerKWU.Num(), + r.satsPerKWU.Denom(), ) - require.True(t, tc.expectedKW.Equal(r)) - floor := new(big.Int).Div(r.Num(), r.Denom()) require.Equal( t, tc.expectedSats, btcutil.Amount(floor.Int64()), @@ -116,9 +141,9 @@ func TestFeeRateComparisonsVB(t *testing.T) { t.Parallel() // Create a set of fee rates to compare. - r1 := SatPerVByte{big.NewRat(1, 1)} - r2 := SatPerVByte{big.NewRat(2, 1)} - r3 := SatPerVByte{big.NewRat(1, 1)} + r1 := NewSatPerVByte(1, NewVByte(1)) + r2 := NewSatPerVByte(2, NewVByte(1)) + r3 := NewSatPerVByte(1, NewVByte(1)) // Test Equal. require.True(t, r1.Equal(r3)) @@ -150,7 +175,7 @@ func TestFeeRateComparisonsVB(t *testing.T) { func TestFeeForWeightRoundUp(t *testing.T) { t.Parallel() - feeRate := SatPerVByte{big.NewRat(1, 1)}.ToSatPerKWeight() + feeRate := NewSatPerVByte(1, NewVByte(1)).ToSatPerKWeight() txWeight := NewWeightUnit(674) // 674 weight units is 168.5 vb. require.EqualValues(t, 168, feeRate.FeeForWeight(txWeight)) @@ -165,23 +190,29 @@ func TestNewFeeRateConstructors(t *testing.T) { // Test NewSatPerKWeight. fee := btcutil.Amount(1000) wu := NewWeightUnit(1000) - expectedRate := SatPerKWeight{big.NewRat(1000, 1)} + expectedRate := NewSatPerKWeight(1000, NewKWeightUnit(1)) require.Zero( - t, expectedRate.Cmp(NewSatPerKWeight(fee, wu).Rat), + t, expectedRate.satsPerKWU.Cmp( + NewSatPerKWeight(fee, wu.ToKWU()).satsPerKWU, + ), ) // Test NewSatPerVByte. vb := NewVByte(250) - expectedRateVB := SatPerVByte{big.NewRat(4, 1)} + expectedRateVB := NewSatPerVByte(4, NewVByte(1)) require.Zero( - t, expectedRateVB.Cmp(NewSatPerVByte(fee, vb).Rat), + t, expectedRateVB.satsPerKWU.Cmp( + NewSatPerVByte(fee, vb).satsPerKWU, + ), ) // Test NewSatPerKVByte. kvb := NewKVByte(1) - expectedRateKVB := SatPerKVByte{big.NewRat(1000, 1)} + expectedRateKVB := NewSatPerKVByte(1000, NewKVByte(1)) require.Zero( - t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat), + t, expectedRateKVB.satsPerKWU.Cmp( + NewSatPerKVByte(fee, kvb).satsPerKWU, + ), ) } @@ -190,14 +221,14 @@ func TestStringer(t *testing.T) { t.Parallel() // Create a set of fee rates to test. - r1 := SatPerVByte{big.NewRat(1, 1)} - r2 := SatPerKVByte{big.NewRat(1000, 1)} - r3 := SatPerKWeight{big.NewRat(250, 1)} + r1 := NewSatPerVByte(1, NewVByte(1)) + r2 := NewSatPerKVByte(1000, NewKVByte(1)) + r3 := NewSatPerKWeight(250, NewKWeightUnit(1)) // Test String. - require.Equal(t, "1.00 sat/vb", r1.String()) - require.Equal(t, "1000.00 sat/kvb", r2.String()) - require.Equal(t, "250.00 sat/kw", r3.String()) + require.Equal(t, "1.000 sat/vb", r1.String()) + require.Equal(t, "1000.000 sat/kvb", r2.String()) + require.Equal(t, "250.000 sat/kw", r3.String()) } // TestFeeRateComparisonsKVB tests the comparison methods of the SatPerKVByte @@ -206,9 +237,9 @@ func TestFeeRateComparisonsKVB(t *testing.T) { t.Parallel() // Create a set of fee rates to compare. - r1 := SatPerKVByte{big.NewRat(1, 1)} - r2 := SatPerKVByte{big.NewRat(2, 1)} - r3 := SatPerKVByte{big.NewRat(1, 1)} + r1 := NewSatPerKVByte(1, NewKVByte(1)) + r2 := NewSatPerKVByte(2, NewKVByte(1)) + r3 := NewSatPerKVByte(1, NewKVByte(1)) // Test Equal. require.True(t, r1.Equal(r3)) @@ -241,9 +272,9 @@ func TestFeeRateComparisonsKW(t *testing.T) { t.Parallel() // Create a set of fee rates to compare. - r1 := SatPerKWeight{big.NewRat(1, 1)} - r2 := SatPerKWeight{big.NewRat(2, 1)} - r3 := SatPerKWeight{big.NewRat(1, 1)} + r1 := NewSatPerKWeight(1, NewKWeightUnit(1)) + r2 := NewSatPerKWeight(2, NewKWeightUnit(1)) + r3 := NewSatPerKWeight(1, NewKWeightUnit(1)) // Test Equal. require.True(t, r1.Equal(r3)) @@ -275,38 +306,78 @@ func TestFeeForSize(t *testing.T) { t.Parallel() // Create a set of fee rates to test. - r1 := SatPerKVByte{big.NewRat(1000, 1)} - r2 := SatPerKWeight{big.NewRat(250, 1)} - r3 := SatPerVByte{big.NewRat(1, 1)} - - // Test FeeForVSize. - require.Equal(t, btcutil.Amount(250), r1.FeeForVSize(NewVByte(250))) - - // Test FeeForVByte. + // r1: 1000 sat/kvb = 1000 sat / 1000 vbyte = 1 sat/vbyte. + // Since 1 vbyte = 4 weight units, this is 1 sat / 4 wu = 0.25 sat/wu. + // In canonical units: 0.25 * 1000 = 250 sat/kwu. + r1 := NewSatPerKVByte(1000, NewKVByte(1)) + + // r2: 250 sat/kwu. This matches r1. + r2 := NewSatPerKWeight(250, NewKWeightUnit(1)) + + // r3: 1 sat/vbyte. + // 1 sat / 4 wu = 0.25 sat/wu = 250 sat/kwu. + // All three rates are equivalent. + r3 := NewSatPerVByte(1, NewVByte(1)) + + // Test FeeForVByte with r1 (1000 sat/kvb). + // Size: 250 vbytes. + // Fee: 250 vbytes * 1 sat/vbyte = 250 sats. + require.Equal(t, btcutil.Amount(250), r1.FeeForVByte(NewVByte(250))) + + // Test FeeForVByte with r2 (250 sat/kwu). + // Size: 250 vbytes = 1000 weight units. + // Rate: 250 sat/1000 wu = 0.25 sat/wu. + // Fee: 1000 wu * 0.25 sat/wu = 250 sats. require.Equal(t, btcutil.Amount(250), r2.FeeForVByte(NewVByte(250))) - // Test FeeForVSize with SatPerVByte. - require.Equal(t, btcutil.Amount(1000), r3.FeeForVSize(NewVByte(1000))) + // Test FeeForVByte with SatPerVByte. + // Size: 1000 vbytes. + // Rate: 1 sat/vbyte. + // Fee: 1000 sats. + require.Equal(t, btcutil.Amount(1000), r3.FeeForVByte(NewVByte(1000))) // Test FeeForKVByte with SatPerVByte. + // Size: 1 kvb = 1000 vbytes. + // Rate: 1 sat/vbyte. + // Fee: 1000 sats. require.Equal(t, btcutil.Amount(1000), r3.FeeForKVByte(NewKVByte(1))) // Test FeeForWeight with SatPerVByte. + // Size: 1000 weight units. + // Rate: 1 sat/vbyte = 0.25 sat/wu. + // Fee: 1000 * 0.25 = 250 sats. require.Equal(t, btcutil.Amount(250), r3.FeeForWeight(NewWeightUnit(1000))) // Test ToSatPerVByte with SatPerKVByte. + // 1000 sat/kvb should equal 1 sat/vbyte. require.True(t, r3.Equal(r1.ToSatPerVByte())) // Test FeeForKVByte with SatPerKVByte. + // Size: 1 kvb. + // Rate: 1000 sat/kvb. + // Fee: 1000 sats. require.Equal(t, btcutil.Amount(1000), r1.FeeForKVByte(NewKVByte(1))) // Test FeeForWeight with SatPerKVByte. + // Size: 1000 weight units. + // Rate: 1000 sat/kvb = 0.25 sat/wu. + // Fee: 1000 * 0.25 = 250 sats. require.Equal(t, btcutil.Amount(250), r1.FeeForWeight(NewWeightUnit(1000))) // Test FeeForKVByte with SatPerKWeight. + // Size: 1 kvb = 1000 vbytes = 4000 weight units. + // Rate: 250 sat/kwu = 0.25 sat/wu. + // Fee: 4000 * 0.25 = 1000 sats. require.Equal(t, btcutil.Amount(1000), r2.FeeForKVByte(NewKVByte(1))) + + // Test FeeForKWeight with SatPerKWeight. + // Size: 1 kwu = 1000 weight units. + // Rate: 250 sat/kwu = 0.25 sat/wu. + // Fee: 1000 * 0.25 = 250 sats. + require.Equal(t, btcutil.Amount(250), + r2.FeeForKWeight(NewKWeightUnit(1))) } // TestNewFeeRateConstructorsZero tests the New* fee rate constructors with @@ -316,25 +387,46 @@ func TestNewFeeRateConstructorsZero(t *testing.T) { // Test NewSatPerKWeight with zero weight. fee := btcutil.Amount(1000) - wu := NewWeightUnit(0) - expectedRate := SatPerKWeight{big.NewRat(0, 1)} + kwu := NewKWeightUnit(0) + expectedRate := NewSatPerKWeight(0, NewKWeightUnit(1)) require.Zero( - t, expectedRate.Cmp(NewSatPerKWeight(fee, wu).Rat), + t, expectedRate.satsPerKWU.Cmp( + NewSatPerKWeight(fee, kwu).satsPerKWU, + ), ) // Test NewSatPerVByte with zero vbytes. vb := NewVByte(0) - expectedRateVB := SatPerVByte{big.NewRat(0, 1)} + expectedRateVB := NewSatPerVByte(0, NewVByte(1)) require.Zero( - t, expectedRateVB.Cmp(NewSatPerVByte(fee, vb).Rat), + t, expectedRateVB.satsPerKWU.Cmp( + NewSatPerVByte(fee, vb).satsPerKWU, + ), ) // Test NewSatPerKVByte with zero kvbytes. kvb := NewKVByte(0) - expectedRateKVB := SatPerKVByte{big.NewRat(0, 1)} + expectedRateKVB := NewSatPerKVByte(0, NewKVByte(1)) require.Zero( - t, expectedRateKVB.Cmp(NewSatPerKVByte(fee, kvb).Rat), + t, expectedRateKVB.satsPerKWU.Cmp( + NewSatPerKVByte(fee, kvb).satsPerKWU, + ), ) + + // Test zero constants. + require.True(t, ZeroSatPerVByte.Equal( + NewSatPerVByte(0, NewVByte(1)), + )) + require.True(t, ZeroSatPerKVByte.Equal( + NewSatPerKVByte(0, NewKVByte(1)), + )) + require.True(t, ZeroSatPerKWeight.Equal( + NewSatPerKWeight(0, NewKWeightUnit(1)), + )) + + require.Equal(t, "0.000 sat/vb", ZeroSatPerVByte.String()) + require.Equal(t, "0.000 sat/kvb", ZeroSatPerKVByte.String()) + require.Equal(t, "0.000 sat/kw", ZeroSatPerKWeight.String()) } // TestSafeUint64ToInt64Overflow tests the overflow condition in @@ -349,18 +441,19 @@ func TestSafeUint64ToInt64Overflow(t *testing.T) { // We manually construct the VByte to ensure wu > MaxInt64 without // overflowing the constructor's internal multiplication. overflowVByte := VByte{baseUnit{wu: math.MaxInt64 + 1}} - rateVB := NewSatPerVByte(fee, overflowVByte) expectedDenom := big.NewInt(math.MaxInt64) - require.Zero(t, expectedDenom.Cmp(rateVB.Denom())) + + rateVB := NewSatPerVByte(fee, overflowVByte) + require.Zero(t, expectedDenom.Cmp(rateVB.satsPerKWU.Denom())) // Test NewSatPerKVByte with an overflowing kvb value. // The denominator should be capped at math.MaxInt64. overflowKVByte := KVByte{baseUnit{wu: math.MaxInt64 + 1}} rateKVB := NewSatPerKVByte(fee, overflowKVByte) - require.Zero(t, expectedDenom.Cmp(rateKVB.Denom())) + require.Zero(t, expectedDenom.Cmp(rateKVB.satsPerKWU.Denom())) // Test NewSatPerKWeight with an overflowing weight unit value. - overflowWU := WeightUnit{baseUnit{wu: math.MaxInt64 + 1}} + overflowWU := KWeightUnit{baseUnit{wu: math.MaxInt64 + 1}} rateKW := NewSatPerKWeight(fee, overflowWU) - require.Zero(t, expectedDenom.Cmp(rateKW.Denom())) + require.Zero(t, expectedDenom.Cmp(rateKW.satsPerKWU.Denom())) } diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index bdd3ee16d4..65bb6c2d14 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -23,6 +23,9 @@ var ( // errDB is used to simulate database operation failures within tests. errDB = errors.New("db error") + + // defaultAccountName is the name of the default account. + defaultAccountName = "default" ) // TestValidateTxIntent ensures that the validateTxIntent function returns @@ -605,7 +608,7 @@ func TestGetEligibleUTXOs(t *testing.T) { utxo := wire.OutPoint{} credit := &wtxmgr.Credit{} scopedAccount := &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, KeyScope: waddrmgr.KeyScopeBIP0086, } @@ -1014,7 +1017,7 @@ func TestCreateTransactionSuccessManualInputs(t *testing.T) { UTXOs: []wire.OutPoint{validUTXO}, }, ChangeSource: &ScopedAccount{ - AccountName: "default", + AccountName: defaultAccountName, KeyScope: waddrmgr.KeyScopeBIP0086, }, FeeRate: 1000, diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go index 6f394359a5..804f6bb668 100644 --- a/wallet/tx_reader.go +++ b/wallet/tx_reader.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "math" - "math/big" "time" "github.com/btcsuite/btcd/blockchain" @@ -283,7 +282,7 @@ func (w *Wallet) buildBasicTxDetail(txDetails *wtxmgr.TxDetails) *TxDetail { Label: txDetails.Label, ReceivedTime: txDetails.Received, Weight: safeInt64ToWeightUnit(txWeight), - FeeRate: btcunit.SatPerVByte{Rat: big.NewRat(0, 1)}, + FeeRate: btcunit.ZeroSatPerVByte, } } diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go index bef5c3d5ed..a941531bc9 100644 --- a/wallet/tx_reader_test.go +++ b/wallet/tx_reader_test.go @@ -5,7 +5,6 @@ package wallet import ( - "math/big" "testing" "time" @@ -30,9 +29,7 @@ func TestBuildTxDetail(t *testing.T) { unminedNoFeeDetails, unminedNoFeeTxDetail := createUnminedTxDetail(t) unminedNoFeeDetails.Debits = nil unminedNoFeeTxDetail.Fee = 0 - unminedNoFeeTxDetail.FeeRate = btcunit.SatPerVByte{ - Rat: big.NewRat(0, 1), - } + unminedNoFeeTxDetail.FeeRate = btcunit.ZeroSatPerVByte unminedNoFeeTxDetail.Value = unminedNoFeeDetails.Credits[0].Amount + unminedNoFeeDetails.Credits[1].Amount unminedNoFeeTxDetail.PrevOuts[0].IsOurs = false From 7d9c817c24f62d09d192104bac33ebef33032440 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 19 Nov 2025 05:47:41 +0800 Subject: [PATCH 167/691] btcunit: implement `SatPerKWeight` We now add `SatPerKWeight` for completeness. --- pkg/btcunit/rates.go | 70 +++++++++++++++++++++ pkg/btcunit/rates_test.go | 129 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go index c2fda5f8dd..873fe43acc 100644 --- a/pkg/btcunit/rates.go +++ b/pkg/btcunit/rates.go @@ -34,6 +34,9 @@ var ( // ZeroSatPerKWeight is a fee rate of 0 sat/kw. ZeroSatPerKWeight = NewSatPerKWeight(0, NewKWeightUnit(1)) + + // ZeroSatPerWeight is a fee rate of 0 sat/wu. + ZeroSatPerWeight = NewSatPerWeight(0, NewWeightUnit(1)) ) // baseFeeRate stores the canonical representation of a fee rate, which is @@ -76,6 +79,11 @@ func (f baseFeeRate) ToSatPerKWeight() SatPerKWeight { return SatPerKWeight{f} } +// ToSatPerWeight converts the fee rate to sat/wu. +func (f baseFeeRate) ToSatPerWeight() SatPerWeight { + return SatPerWeight{f} +} + // FeeForWeight calculates the fee resulting from this fee rate and the given // weight in weight units (wu). func (f baseFeeRate) FeeForWeight(weightUnit WeightUnit) btcutil.Amount { @@ -378,6 +386,68 @@ func (s SatPerKWeight) LessThanOrEqual(other SatPerKWeight) bool { return s.lessThanOrEqual(other.baseFeeRate) } +// SatPerWeight represents a fee rate in sat/wu. Internally, all fee rates +// are stored and operated on as satoshis per kilo-weight-unit (sat/kw). +// Conversions to other units and fee calculations are performed using this +// canonical internal representation. The `String()` method is the only one +// that presents the fee rate in its specific sat/wu unit. +type SatPerWeight struct { + baseFeeRate +} + +// NewSatPerWeight creates a new fee rate in sat/wu. The given fee and +// weight units are used to calculate the fee rate. +func NewSatPerWeight(fee btcutil.Amount, wu WeightUnit) SatPerWeight { + // The fee is provided in satoshis. To convert this to our internal + // canonical unit of satoshis per kilo-weight-unit (sat/kwu), we need + // to scale the numerator. Multiplying by `kilo` (1000) is consistent + // with the definition of sat/kwu (1 sat/wu = 1000 sat/kwu). + numerator := fee.MulF64(kilo) + + // The denominator is the size in weight units. `wu.wu` provides the + // equivalent transaction weight in weight units (wu). + denominator := wu.wu + + return SatPerWeight{newBaseFeeRate(numerator, denominator)} +} + +// String returns a human-readable string of the fee rate. +func (s SatPerWeight) String() string { + // Calculate the fee rate in sat/wu from the canonical sat/kwu. + // 1 sat/wu = 1000 sat/kwu. So we need to divide by kilo. + wuRate := big.NewRat(0, 1) + wuRate.Mul(s.satsPerKWU, big.NewRat(1, kilo)) + + return wuRate.FloatString(floatStringPrecision) + " sat/wu" +} + +// Equal returns true if the fee rate is equal to the other fee rate. +func (s SatPerWeight) Equal(other SatPerWeight) bool { + return s.equal(other.baseFeeRate) +} + +// GreaterThan returns true if the fee rate is greater than the other fee rate. +func (s SatPerWeight) GreaterThan(other SatPerWeight) bool { + return s.greaterThan(other.baseFeeRate) +} + +// LessThan returns true if the fee rate is less than the other fee rate. +func (s SatPerWeight) LessThan(other SatPerWeight) bool { + return s.lessThan(other.baseFeeRate) +} + +// GreaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (s SatPerWeight) GreaterThanOrEqual(other SatPerWeight) bool { + return s.greaterThanOrEqual(other.baseFeeRate) +} + +// LessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (s SatPerWeight) LessThanOrEqual(other SatPerWeight) bool { + return s.lessThanOrEqual(other.baseFeeRate) +} + // safeUint64ToInt64 converts a uint64 to an int64, capping at math.MaxInt64. // This is used to silence gosec warnings about integer overflows. In practice, // the values being converted are transaction weights or sizes, which are diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go index fdb060e576..646a021c60 100644 --- a/pkg/btcunit/rates_test.go +++ b/pkg/btcunit/rates_test.go @@ -20,6 +20,7 @@ func TestFeeRateConversions(t *testing.T) { expectedVB SatPerVByte expectedKVB SatPerKVByte expectedKW SatPerKWeight + expectedW SatPerWeight expectedSats btcutil.Amount }{ { @@ -28,6 +29,7 @@ func TestFeeRateConversions(t *testing.T) { expectedVB: NewSatPerVByte(1, NewVByte(1)), expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), + expectedW: NewSatPerWeight(1, NewWeightUnit(4)), expectedSats: 250, }, { @@ -36,6 +38,7 @@ func TestFeeRateConversions(t *testing.T) { expectedVB: NewSatPerVByte(1, NewVByte(1)), expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), + expectedW: NewSatPerWeight(1, NewWeightUnit(4)), expectedSats: 250, }, { @@ -44,6 +47,16 @@ func TestFeeRateConversions(t *testing.T) { expectedVB: NewSatPerVByte(1, NewVByte(1)), expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), + expectedW: NewSatPerWeight(1, NewWeightUnit(4)), + expectedSats: 250, + }, + { + name: "0.25 sat/wu", + rate: NewSatPerWeight(1, NewWeightUnit(4)), + expectedVB: NewSatPerVByte(1, NewVByte(1)), + expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), + expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), + expectedW: NewSatPerWeight(1, NewWeightUnit(4)), expectedSats: 250, }, { @@ -54,6 +67,9 @@ func TestFeeRateConversions(t *testing.T) { expectedKW: NewSatPerKWeight( 27500, NewKWeightUnit(1000), ), + expectedW: NewSatPerWeight( + 11, NewWeightUnit(400), + ), expectedSats: 27, }, } @@ -73,6 +89,9 @@ func TestFeeRateConversions(t *testing.T) { require.True(t, tc.expectedKW.equal( r.ToSatPerKWeight().baseFeeRate, )) + require.True(t, tc.expectedW.equal( + r.ToSatPerWeight().baseFeeRate, + )) // Calculate the floor of the fee rate. floor := big.NewInt(0) @@ -95,6 +114,9 @@ func TestFeeRateConversions(t *testing.T) { require.True(t, tc.expectedKW.equal( r.ToSatPerKWeight().baseFeeRate, )) + require.True(t, tc.expectedW.equal( + r.ToSatPerWeight().baseFeeRate, + )) // Calculate the floor of the fee rate. floor := big.NewInt(0) @@ -119,6 +141,34 @@ func TestFeeRateConversions(t *testing.T) { require.True( t, tc.expectedKW.equal(r.baseFeeRate), ) + require.True(t, tc.expectedW.equal( + r.ToSatPerWeight().baseFeeRate, + )) + + // Calculate the floor of the fee rate. + floor := big.NewInt(0) + floor.Div( + r.satsPerKWU.Num(), + r.satsPerKWU.Denom(), + ) + require.Equal( + t, tc.expectedSats, + btcutil.Amount(floor.Int64()), + ) + + case SatPerWeight: + require.True(t, tc.expectedVB.equal( + r.ToSatPerVByte().baseFeeRate, + )) + require.True(t, tc.expectedKVB.equal( + r.ToSatPerKVByte().baseFeeRate, + )) + require.True(t, tc.expectedKW.equal( + r.ToSatPerKWeight().baseFeeRate, + )) + require.True( + t, tc.expectedW.equal(r.baseFeeRate), + ) // Calculate the floor of the fee rate. floor := big.NewInt(0) @@ -197,6 +247,14 @@ func TestNewFeeRateConstructors(t *testing.T) { ), ) + // Test NewSatPerWeight. + expectedRateW := NewSatPerWeight(1000, NewWeightUnit(1)) + require.Zero( + t, expectedRateW.satsPerKWU.Cmp( + NewSatPerWeight(fee, NewWeightUnit(1)).satsPerKWU, + ), + ) + // Test NewSatPerVByte. vb := NewVByte(250) expectedRateVB := NewSatPerVByte(4, NewVByte(1)) @@ -224,11 +282,13 @@ func TestStringer(t *testing.T) { r1 := NewSatPerVByte(1, NewVByte(1)) r2 := NewSatPerKVByte(1000, NewKVByte(1)) r3 := NewSatPerKWeight(250, NewKWeightUnit(1)) + r4 := NewSatPerWeight(1, NewWeightUnit(4)) // 1 sat / 4 wu = 0.25 sat/wu // Test String. require.Equal(t, "1.000 sat/vb", r1.String()) require.Equal(t, "1000.000 sat/kvb", r2.String()) require.Equal(t, "250.000 sat/kw", r3.String()) + require.Equal(t, "0.250 sat/wu", r4.String()) } // TestFeeRateComparisonsKVB tests the comparison methods of the SatPerKVByte @@ -301,6 +361,41 @@ func TestFeeRateComparisonsKW(t *testing.T) { require.False(t, r2.LessThanOrEqual(r1)) } +// TestFeeRateComparisonsW tests the comparison methods of the SatPerWeight +// type. +func TestFeeRateComparisonsW(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to compare. + r1 := NewSatPerWeight(1, NewWeightUnit(1)) + r2 := NewSatPerWeight(2, NewWeightUnit(1)) + r3 := NewSatPerWeight(1, NewWeightUnit(1)) + + // Test Equal. + require.True(t, r1.Equal(r3)) + require.False(t, r1.Equal(r2)) + + // Test GreaterThan. + require.True(t, r2.GreaterThan(r1)) + require.False(t, r1.GreaterThan(r2)) + require.False(t, r1.GreaterThan(r3)) + + // Test LessThan. + require.True(t, r1.LessThan(r2)) + require.False(t, r2.LessThan(r1)) + require.False(t, r1.LessThan(r3)) + + // Test GreaterThanOrEqual. + require.True(t, r2.GreaterThanOrEqual(r1)) + require.True(t, r1.GreaterThanOrEqual(r3)) + require.False(t, r1.GreaterThanOrEqual(r2)) + + // Test LessThanOrEqual. + require.True(t, r1.LessThanOrEqual(r2)) + require.True(t, r1.LessThanOrEqual(r3)) + require.False(t, r2.LessThanOrEqual(r1)) +} + // TestFeeForSize tests the FeeForVSize and FeeForVByte methods. func TestFeeForSize(t *testing.T) { t.Parallel() @@ -319,6 +414,11 @@ func TestFeeForSize(t *testing.T) { // All three rates are equivalent. r3 := NewSatPerVByte(1, NewVByte(1)) + // r4: 0.25 sat/wu. + // 0.25 sat/wu * 1000 = 250 sat/kwu. + // All four rates are equivalent. + r4 := NewSatPerWeight(1, NewWeightUnit(4)) + // Test FeeForVByte with r1 (1000 sat/kvb). // Size: 250 vbytes. // Fee: 250 vbytes * 1 sat/vbyte = 250 sats. @@ -378,6 +478,17 @@ func TestFeeForSize(t *testing.T) { // Fee: 1000 * 0.25 = 250 sats. require.Equal(t, btcutil.Amount(250), r2.FeeForKWeight(NewKWeightUnit(1))) + + // Test FeeForWeight with SatPerWeight. + // Size: 1000 weight units. + // Rate: 0.25 sat/wu. + // Fee: 1000 * 0.25 = 250 sats. + require.Equal(t, btcutil.Amount(250), + r4.FeeForWeight(NewWeightUnit(1000))) + + // Test ToSatPerWeight with SatPerVByte. + // 1 sat/vbyte should equal 0.25 sat/wu. + require.True(t, r4.Equal(r3.ToSatPerWeight())) } // TestNewFeeRateConstructorsZero tests the New* fee rate constructors with @@ -413,6 +524,15 @@ func TestNewFeeRateConstructorsZero(t *testing.T) { ), ) + // Test NewSatPerWeight with zero weight units. + wu := NewWeightUnit(0) + expectedRateW := NewSatPerWeight(0, NewWeightUnit(1)) + require.Zero( + t, expectedRateW.satsPerKWU.Cmp( + NewSatPerWeight(fee, wu).satsPerKWU, + ), + ) + // Test zero constants. require.True(t, ZeroSatPerVByte.Equal( NewSatPerVByte(0, NewVByte(1)), @@ -423,10 +543,14 @@ func TestNewFeeRateConstructorsZero(t *testing.T) { require.True(t, ZeroSatPerKWeight.Equal( NewSatPerKWeight(0, NewKWeightUnit(1)), )) + require.True(t, ZeroSatPerWeight.Equal( + NewSatPerWeight(0, NewWeightUnit(1)), + )) require.Equal(t, "0.000 sat/vb", ZeroSatPerVByte.String()) require.Equal(t, "0.000 sat/kvb", ZeroSatPerKVByte.String()) require.Equal(t, "0.000 sat/kw", ZeroSatPerKWeight.String()) + require.Equal(t, "0.000 sat/wu", ZeroSatPerWeight.String()) } // TestSafeUint64ToInt64Overflow tests the overflow condition in @@ -456,4 +580,9 @@ func TestSafeUint64ToInt64Overflow(t *testing.T) { overflowWU := KWeightUnit{baseUnit{wu: math.MaxInt64 + 1}} rateKW := NewSatPerKWeight(fee, overflowWU) require.Zero(t, expectedDenom.Cmp(rateKW.satsPerKWU.Denom())) + + // Test NewSatPerWeight with an overflowing weight unit value. + overflowWeight := WeightUnit{baseUnit{wu: math.MaxInt64 + 1}} + rateW := NewSatPerWeight(fee, overflowWeight) + require.Zero(t, expectedDenom.Cmp(rateW.satsPerKWU.Denom())) } From f8dd1b1e6b3ac53169e417c7e521733b6de211a8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 19 Nov 2025 06:25:29 +0800 Subject: [PATCH 168/691] btcunit+wallet: refactor fee rate constructors for clarity and precision This commit refactors the pkg/btcunit package to separate the construction of fee rates from known values vs. calculation from total fee and size. Previously, constructors like NewSatPerKVByte(fee, size) acted as calculation methods, which was confusing when a direct rate was known. Changes: - Renamed calculation constructors: - NewSatPerVByte -> CalcSatPerVByte - NewSatPerKVByte -> CalcSatPerKVByte - NewSatPerKWeight -> CalcSatPerKWeight - NewSatPerWeight -> CalcSatPerWeight - Added new direct constructors taking a single rate argument: - NewSatPerVByte(rate) - NewSatPerKVByte(rate) - NewSatPerKWeight(rate) - NewSatPerWeight(rate) - Ensured 100% test coverage for pkg/btcunit. --- pkg/btcunit/rates.go | 103 ++++++++++----------- pkg/btcunit/rates_test.go | 188 +++++++++++++++++--------------------- wallet/tx_reader.go | 2 +- wallet/tx_reader_test.go | 2 +- 4 files changed, 140 insertions(+), 155 deletions(-) diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go index 873fe43acc..4b5849d912 100644 --- a/pkg/btcunit/rates.go +++ b/pkg/btcunit/rates.go @@ -27,16 +27,16 @@ const ( var ( // ZeroSatPerVByte is a fee rate of 0 sat/vb. - ZeroSatPerVByte = NewSatPerVByte(0, NewVByte(1)) + ZeroSatPerVByte = NewSatPerVByte(0) // ZeroSatPerKVByte is a fee rate of 0 sat/kvb. - ZeroSatPerKVByte = NewSatPerKVByte(0, NewKVByte(1)) + ZeroSatPerKVByte = NewSatPerKVByte(0) // ZeroSatPerKWeight is a fee rate of 0 sat/kw. - ZeroSatPerKWeight = NewSatPerKWeight(0, NewKWeightUnit(1)) + ZeroSatPerKWeight = NewSatPerKWeight(0) // ZeroSatPerWeight is a fee rate of 0 sat/wu. - ZeroSatPerWeight = NewSatPerWeight(0, NewWeightUnit(1)) + ZeroSatPerWeight = NewSatPerWeight(0) ) // baseFeeRate stores the canonical representation of a fee rate, which is @@ -199,19 +199,19 @@ type SatPerVByte struct { baseFeeRate } -// NewSatPerVByte creates a new fee rate in sat/vb. The given fee and vbytes -// are used to calculate the fee rate. -func NewSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { - // The fee is provided in satoshis. To convert this to our internal - // canonical unit of satoshis per kilo-weight-unit (sat/kwu), we need - // to scale the numerator. Multiplying by `kilo` (1000) effectively - // converts the 'per vbyte' rate to a 'per kilo-vbyte' rate, which then - // aligns with the 'kilo' in sat/kwu after the denominator is processed. - numerator := fee.MulF64(kilo) +// NewSatPerVByte creates a new fee rate in sat/vb. +func NewSatPerVByte(rate btcutil.Amount) SatPerVByte { + return CalcSatPerVByte(rate, NewVByte(1)) +} - // The denominator is the size in vbytes. `vb.wu` provides the - // equivalent transaction weight in weight units (wu), which implicitly - // accounts for the `WitnessScaleFactor` (4). +// CalcSatPerVByte calculates the fee rate in sat/vb for a given fee and size. +func CalcSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { + // To convert the rate to the canonical sat/kwu unit, we use the + // formula: (fee * 1000) / size_in_wu. + // + // vb.wu provides the size in weight units (wu), implicitly accounting + // for the WitnessScaleFactor. + numerator := fee * kilo denominator := vb.wu return SatPerVByte{newBaseFeeRate(numerator, denominator)} @@ -267,20 +267,19 @@ type SatPerKVByte struct { baseFeeRate } -// NewSatPerKVByte creates a new fee rate in sat/kvb. The given fee and -// kvbytes are used to calculate the fee rate. -func NewSatPerKVByte(fee btcutil.Amount, kvb KVByte) SatPerKVByte { - // The fee is provided in satoshis. To convert this to our internal - // canonical unit of satoshis per kilo-weight-unit (sat/kwu), we need - // to scale the numerator. Multiplying by `kilo` (1000) effectively - // scales the fee to align with the 'kilo' in sat/kwu after the - // denominator is processed. - numerator := fee.MulF64(kilo) - - // The denominator is the size in kilo-vbytes. `kvb.wu` provides the - // equivalent transaction weight in weight units (wu), which implicitly - // accounts for the `WitnessScaleFactor` (4) and the `kilo` scaling for - // kilo-vbytes. +// NewSatPerKVByte creates a new fee rate in sat/kvb. +func NewSatPerKVByte(rate btcutil.Amount) SatPerKVByte { + return CalcSatPerKVByte(rate, NewKVByte(1)) +} + +// CalcSatPerKVByte calculates the fee rate in sat/kvb for a given fee and size. +func CalcSatPerKVByte(fee btcutil.Amount, kvb KVByte) SatPerKVByte { + // To convert the rate to the canonical sat/kwu unit, we use the + // formula: (fee * 1000) / size_in_wu. + // + // kvb.wu provides the size in weight units (wu), implicitly accounting + // for the WitnessScaleFactor and kilo scaling. + numerator := fee * kilo denominator := kvb.wu return SatPerKVByte{newBaseFeeRate(numerator, denominator)} @@ -337,18 +336,19 @@ type SatPerKWeight struct { baseFeeRate } -// NewSatPerKWeight creates a new fee rate in sat/kw. The given fee and -// kweight units are used to calculate the fee rate. -func NewSatPerKWeight(fee btcutil.Amount, kwu KWeightUnit) SatPerKWeight { - // The fee is provided in satoshis. To convert this to our internal - // canonical unit of satoshis per kilo-weight-unit (sat/kwu), we need - // to scale the numerator. Multiplying by `kilo` (1000) is consistent - // with the definition of sat/kwu. - numerator := fee.MulF64(kilo) +// NewSatPerKWeight creates a new fee rate in sat/kw. +func NewSatPerKWeight(rate btcutil.Amount) SatPerKWeight { + return CalcSatPerKWeight(rate, NewKWeightUnit(1)) +} - // The denominator is the size in kilo-weight-units. `kwu.wu` provides - // the equivalent transaction weight in weight units (wu), which - // implicitly accounts for the `kilo` scaling for kilo-weight-units. +// CalcSatPerKWeight calculates the fee rate in sat/kw for a given fee and size. +func CalcSatPerKWeight(fee btcutil.Amount, kwu KWeightUnit) SatPerKWeight { + // To convert the rate to the canonical sat/kwu unit, we use the + // formula: (fee * 1000) / size_in_wu. + // + // kwu.wu provides the size in weight units (wu), implicitly accounting + // for the kilo scaling. + numerator := fee * kilo denominator := kwu.wu return SatPerKWeight{newBaseFeeRate(numerator, denominator)} @@ -395,17 +395,18 @@ type SatPerWeight struct { baseFeeRate } -// NewSatPerWeight creates a new fee rate in sat/wu. The given fee and -// weight units are used to calculate the fee rate. -func NewSatPerWeight(fee btcutil.Amount, wu WeightUnit) SatPerWeight { - // The fee is provided in satoshis. To convert this to our internal - // canonical unit of satoshis per kilo-weight-unit (sat/kwu), we need - // to scale the numerator. Multiplying by `kilo` (1000) is consistent - // with the definition of sat/kwu (1 sat/wu = 1000 sat/kwu). - numerator := fee.MulF64(kilo) +// NewSatPerWeight creates a new fee rate in sat/wu. +func NewSatPerWeight(rate btcutil.Amount) SatPerWeight { + return CalcSatPerWeight(rate, NewWeightUnit(1)) +} - // The denominator is the size in weight units. `wu.wu` provides the - // equivalent transaction weight in weight units (wu). +// CalcSatPerWeight calculates the fee rate in sat/wu for a given fee and size. +func CalcSatPerWeight(fee btcutil.Amount, wu WeightUnit) SatPerWeight { + // To convert the rate to the canonical sat/kwu unit, we use the + // formula: (fee * 1000) / size_in_wu. + // + // wu.wu provides the size in weight units (wu). + numerator := fee * kilo denominator := wu.wu return SatPerWeight{newBaseFeeRate(numerator, denominator)} diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go index 646a021c60..b6529f3252 100644 --- a/pkg/btcunit/rates_test.go +++ b/pkg/btcunit/rates_test.go @@ -25,51 +25,47 @@ func TestFeeRateConversions(t *testing.T) { }{ { name: "1 sat/vb", - rate: NewSatPerVByte(1, NewVByte(1)), - expectedVB: NewSatPerVByte(1, NewVByte(1)), - expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), - expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), - expectedW: NewSatPerWeight(1, NewWeightUnit(4)), + rate: NewSatPerVByte(1), + expectedVB: NewSatPerVByte(1), + expectedKVB: NewSatPerKVByte(1000), + expectedKW: NewSatPerKWeight(250), + expectedW: CalcSatPerWeight(1, NewWeightUnit(4)), expectedSats: 250, }, { name: "1000 sat/kvb", - rate: NewSatPerKVByte(1000, NewKVByte(1)), - expectedVB: NewSatPerVByte(1, NewVByte(1)), - expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), - expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), - expectedW: NewSatPerWeight(1, NewWeightUnit(4)), + rate: NewSatPerKVByte(1000), + expectedVB: NewSatPerVByte(1), + expectedKVB: NewSatPerKVByte(1000), + expectedKW: NewSatPerKWeight(250), + expectedW: CalcSatPerWeight(1, NewWeightUnit(4)), expectedSats: 250, }, { name: "250 sat/kw", - rate: NewSatPerKWeight(250, NewKWeightUnit(1)), - expectedVB: NewSatPerVByte(1, NewVByte(1)), - expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), - expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), - expectedW: NewSatPerWeight(1, NewWeightUnit(4)), + rate: NewSatPerKWeight(250), + expectedVB: NewSatPerVByte(1), + expectedKVB: NewSatPerKVByte(1000), + expectedKW: NewSatPerKWeight(250), + expectedW: CalcSatPerWeight(1, NewWeightUnit(4)), expectedSats: 250, }, { name: "0.25 sat/wu", - rate: NewSatPerWeight(1, NewWeightUnit(4)), - expectedVB: NewSatPerVByte(1, NewVByte(1)), - expectedKVB: NewSatPerKVByte(1000, NewKVByte(1)), - expectedKW: NewSatPerKWeight(250, NewKWeightUnit(1)), - expectedW: NewSatPerWeight(1, NewWeightUnit(4)), + rate: CalcSatPerWeight(1, NewWeightUnit(4)), + expectedVB: NewSatPerVByte(1), + expectedKVB: NewSatPerKVByte(1000), + expectedKW: NewSatPerKWeight(250), + expectedW: CalcSatPerWeight(1, NewWeightUnit(4)), expectedSats: 250, }, { - name: "0.11 sat/vb", - rate: NewSatPerVByte(11, NewVByte(100)), - expectedVB: NewSatPerVByte(11, NewVByte(100)), - expectedKVB: NewSatPerKVByte(110, NewKVByte(1)), - expectedKW: NewSatPerKWeight( - 27500, NewKWeightUnit(1000), - ), - expectedW: NewSatPerWeight( - 11, NewWeightUnit(400), - ), + name: "0.11 sat/vb", + rate: CalcSatPerVByte(11, NewVByte(100)), + expectedVB: CalcSatPerVByte(11, NewVByte(100)), + expectedKVB: NewSatPerKVByte(110), + expectedKW: CalcSatPerKWeight(55, NewKWeightUnit(2)), + expectedW: CalcSatPerWeight(11, NewWeightUnit(400)), expectedSats: 27, }, } @@ -191,9 +187,9 @@ func TestFeeRateComparisonsVB(t *testing.T) { t.Parallel() // Create a set of fee rates to compare. - r1 := NewSatPerVByte(1, NewVByte(1)) - r2 := NewSatPerVByte(2, NewVByte(1)) - r3 := NewSatPerVByte(1, NewVByte(1)) + r1 := NewSatPerVByte(1) + r2 := NewSatPerVByte(2) + r3 := NewSatPerVByte(1) // Test Equal. require.True(t, r1.Equal(r3)) @@ -225,51 +221,51 @@ func TestFeeRateComparisonsVB(t *testing.T) { func TestFeeForWeightRoundUp(t *testing.T) { t.Parallel() - feeRate := NewSatPerVByte(1, NewVByte(1)).ToSatPerKWeight() + feeRate := NewSatPerVByte(1).ToSatPerKWeight() txWeight := NewWeightUnit(674) // 674 weight units is 168.5 vb. require.EqualValues(t, 168, feeRate.FeeForWeight(txWeight)) require.EqualValues(t, 169, feeRate.FeeForWeightRoundUp(txWeight)) } -// TestNewFeeRateConstructors checks that the New* fee rate constructors work -// as expected. +// TestNewFeeRateConstructors checks that the New* and Calc* fee rate +// constructors work as expected. func TestNewFeeRateConstructors(t *testing.T) { t.Parallel() - // Test NewSatPerKWeight. + // Test CalcSatPerKWeight. fee := btcutil.Amount(1000) wu := NewWeightUnit(1000) - expectedRate := NewSatPerKWeight(1000, NewKWeightUnit(1)) + expectedRate := NewSatPerKWeight(1000) require.Zero( t, expectedRate.satsPerKWU.Cmp( - NewSatPerKWeight(fee, wu.ToKWU()).satsPerKWU, + CalcSatPerKWeight(fee, wu.ToKWU()).satsPerKWU, ), ) - // Test NewSatPerWeight. - expectedRateW := NewSatPerWeight(1000, NewWeightUnit(1)) + // Test CalcSatPerWeight. + expectedRateW := NewSatPerWeight(1000) require.Zero( t, expectedRateW.satsPerKWU.Cmp( - NewSatPerWeight(fee, NewWeightUnit(1)).satsPerKWU, + CalcSatPerWeight(fee, NewWeightUnit(1)).satsPerKWU, ), ) - // Test NewSatPerVByte. + // Test CalcSatPerVByte. vb := NewVByte(250) - expectedRateVB := NewSatPerVByte(4, NewVByte(1)) + expectedRateVB := NewSatPerVByte(4) require.Zero( t, expectedRateVB.satsPerKWU.Cmp( - NewSatPerVByte(fee, vb).satsPerKWU, + CalcSatPerVByte(fee, vb).satsPerKWU, ), ) - // Test NewSatPerKVByte. + // Test CalcSatPerKVByte. kvb := NewKVByte(1) - expectedRateKVB := NewSatPerKVByte(1000, NewKVByte(1)) + expectedRateKVB := NewSatPerKVByte(1000) require.Zero( t, expectedRateKVB.satsPerKWU.Cmp( - NewSatPerKVByte(fee, kvb).satsPerKWU, + CalcSatPerKVByte(fee, kvb).satsPerKWU, ), ) } @@ -279,10 +275,10 @@ func TestStringer(t *testing.T) { t.Parallel() // Create a set of fee rates to test. - r1 := NewSatPerVByte(1, NewVByte(1)) - r2 := NewSatPerKVByte(1000, NewKVByte(1)) - r3 := NewSatPerKWeight(250, NewKWeightUnit(1)) - r4 := NewSatPerWeight(1, NewWeightUnit(4)) // 1 sat / 4 wu = 0.25 sat/wu + r1 := NewSatPerVByte(1) + r2 := NewSatPerKVByte(1000) + r3 := NewSatPerKWeight(250) + r4 := CalcSatPerWeight(1, NewWeightUnit(4)) // 0.25 sat/wu // Test String. require.Equal(t, "1.000 sat/vb", r1.String()) @@ -297,9 +293,9 @@ func TestFeeRateComparisonsKVB(t *testing.T) { t.Parallel() // Create a set of fee rates to compare. - r1 := NewSatPerKVByte(1, NewKVByte(1)) - r2 := NewSatPerKVByte(2, NewKVByte(1)) - r3 := NewSatPerKVByte(1, NewKVByte(1)) + r1 := NewSatPerKVByte(1) + r2 := NewSatPerKVByte(2) + r3 := NewSatPerKVByte(1) // Test Equal. require.True(t, r1.Equal(r3)) @@ -332,9 +328,9 @@ func TestFeeRateComparisonsKW(t *testing.T) { t.Parallel() // Create a set of fee rates to compare. - r1 := NewSatPerKWeight(1, NewKWeightUnit(1)) - r2 := NewSatPerKWeight(2, NewKWeightUnit(1)) - r3 := NewSatPerKWeight(1, NewKWeightUnit(1)) + r1 := NewSatPerKWeight(1) + r2 := NewSatPerKWeight(2) + r3 := NewSatPerKWeight(1) // Test Equal. require.True(t, r1.Equal(r3)) @@ -367,9 +363,9 @@ func TestFeeRateComparisonsW(t *testing.T) { t.Parallel() // Create a set of fee rates to compare. - r1 := NewSatPerWeight(1, NewWeightUnit(1)) - r2 := NewSatPerWeight(2, NewWeightUnit(1)) - r3 := NewSatPerWeight(1, NewWeightUnit(1)) + r1 := NewSatPerWeight(1) + r2 := NewSatPerWeight(2) + r3 := NewSatPerWeight(1) // Test Equal. require.True(t, r1.Equal(r3)) @@ -402,22 +398,17 @@ func TestFeeForSize(t *testing.T) { // Create a set of fee rates to test. // r1: 1000 sat/kvb = 1000 sat / 1000 vbyte = 1 sat/vbyte. - // Since 1 vbyte = 4 weight units, this is 1 sat / 4 wu = 0.25 sat/wu. - // In canonical units: 0.25 * 1000 = 250 sat/kwu. - r1 := NewSatPerKVByte(1000, NewKVByte(1)) + r1 := NewSatPerKVByte(1000) // r2: 250 sat/kwu. This matches r1. - r2 := NewSatPerKWeight(250, NewKWeightUnit(1)) + r2 := NewSatPerKWeight(250) // r3: 1 sat/vbyte. - // 1 sat / 4 wu = 0.25 sat/wu = 250 sat/kwu. - // All three rates are equivalent. - r3 := NewSatPerVByte(1, NewVByte(1)) + r3 := NewSatPerVByte(1) // r4: 0.25 sat/wu. // 0.25 sat/wu * 1000 = 250 sat/kwu. - // All four rates are equivalent. - r4 := NewSatPerWeight(1, NewWeightUnit(4)) + r4 := CalcSatPerWeight(1, NewWeightUnit(4)) // Test FeeForVByte with r1 (1000 sat/kvb). // Size: 250 vbytes. @@ -496,56 +487,48 @@ func TestFeeForSize(t *testing.T) { func TestNewFeeRateConstructorsZero(t *testing.T) { t.Parallel() - // Test NewSatPerKWeight with zero weight. + // Test CalcSatPerKWeight with zero weight. fee := btcutil.Amount(1000) kwu := NewKWeightUnit(0) - expectedRate := NewSatPerKWeight(0, NewKWeightUnit(1)) + expectedRate := NewSatPerKWeight(0) require.Zero( t, expectedRate.satsPerKWU.Cmp( - NewSatPerKWeight(fee, kwu).satsPerKWU, + CalcSatPerKWeight(fee, kwu).satsPerKWU, ), ) - // Test NewSatPerVByte with zero vbytes. + // Test CalcSatPerVByte with zero vbytes. vb := NewVByte(0) - expectedRateVB := NewSatPerVByte(0, NewVByte(1)) + expectedRateVB := NewSatPerVByte(0) require.Zero( t, expectedRateVB.satsPerKWU.Cmp( - NewSatPerVByte(fee, vb).satsPerKWU, + CalcSatPerVByte(fee, vb).satsPerKWU, ), ) - // Test NewSatPerKVByte with zero kvbytes. + // Test CalcSatPerKVByte with zero kvbytes. kvb := NewKVByte(0) - expectedRateKVB := NewSatPerKVByte(0, NewKVByte(1)) + expectedRateKVB := NewSatPerKVByte(0) require.Zero( t, expectedRateKVB.satsPerKWU.Cmp( - NewSatPerKVByte(fee, kvb).satsPerKWU, + CalcSatPerKVByte(fee, kvb).satsPerKWU, ), ) - // Test NewSatPerWeight with zero weight units. + // Test CalcSatPerWeight with zero weight units. wu := NewWeightUnit(0) - expectedRateW := NewSatPerWeight(0, NewWeightUnit(1)) + expectedRateW := NewSatPerWeight(0) require.Zero( t, expectedRateW.satsPerKWU.Cmp( - NewSatPerWeight(fee, wu).satsPerKWU, + CalcSatPerWeight(fee, wu).satsPerKWU, ), ) // Test zero constants. - require.True(t, ZeroSatPerVByte.Equal( - NewSatPerVByte(0, NewVByte(1)), - )) - require.True(t, ZeroSatPerKVByte.Equal( - NewSatPerKVByte(0, NewKVByte(1)), - )) - require.True(t, ZeroSatPerKWeight.Equal( - NewSatPerKWeight(0, NewKWeightUnit(1)), - )) - require.True(t, ZeroSatPerWeight.Equal( - NewSatPerWeight(0, NewWeightUnit(1)), - )) + require.True(t, ZeroSatPerVByte.Equal(NewSatPerVByte(0))) + require.True(t, ZeroSatPerKVByte.Equal(NewSatPerKVByte(0))) + require.True(t, ZeroSatPerKWeight.Equal(NewSatPerKWeight(0))) + require.True(t, ZeroSatPerWeight.Equal(NewSatPerWeight(0))) require.Equal(t, "0.000 sat/vb", ZeroSatPerVByte.String()) require.Equal(t, "0.000 sat/kvb", ZeroSatPerKVByte.String()) @@ -560,29 +543,30 @@ func TestSafeUint64ToInt64Overflow(t *testing.T) { fee := btcutil.Amount(1) - // Test NewSatPerVByte with an overflowing vbyte value. + // Test CalcSatPerVByte with an overflowing vbyte value. // The denominator should be capped at math.MaxInt64. // We manually construct the VByte to ensure wu > MaxInt64 without // overflowing the constructor's internal multiplication. overflowVByte := VByte{baseUnit{wu: math.MaxInt64 + 1}} expectedDenom := big.NewInt(math.MaxInt64) - rateVB := NewSatPerVByte(fee, overflowVByte) + rateVB := CalcSatPerVByte(fee, overflowVByte) require.Zero(t, expectedDenom.Cmp(rateVB.satsPerKWU.Denom())) - // Test NewSatPerKVByte with an overflowing kvb value. + // Test CalcSatPerKVByte with an overflowing kvb value. // The denominator should be capped at math.MaxInt64. overflowKVByte := KVByte{baseUnit{wu: math.MaxInt64 + 1}} - rateKVB := NewSatPerKVByte(fee, overflowKVByte) + rateKVB := CalcSatPerKVByte(fee, overflowKVByte) require.Zero(t, expectedDenom.Cmp(rateKVB.satsPerKWU.Denom())) - // Test NewSatPerKWeight with an overflowing weight unit value. + // Test CalcSatPerKWeight with an overflowing weight unit value. overflowWU := KWeightUnit{baseUnit{wu: math.MaxInt64 + 1}} - rateKW := NewSatPerKWeight(fee, overflowWU) + rateKW := CalcSatPerKWeight(fee, overflowWU) require.Zero(t, expectedDenom.Cmp(rateKW.satsPerKWU.Denom())) - // Test NewSatPerWeight with an overflowing weight unit value. + // Test CalcSatPerWeight with an overflowing weight unit value. overflowWeight := WeightUnit{baseUnit{wu: math.MaxInt64 + 1}} - rateW := NewSatPerWeight(fee, overflowWeight) + rateW := CalcSatPerWeight(fee, overflowWeight) require.Zero(t, expectedDenom.Cmp(rateW.satsPerKWU.Denom())) } + diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go index 804f6bb668..487330fc1e 100644 --- a/wallet/tx_reader.go +++ b/wallet/tx_reader.go @@ -339,7 +339,7 @@ func (w *Wallet) calculateValueAndFee(details *TxDetail, } details.Fee = totalInput - totalOutput - details.FeeRate = btcunit.NewSatPerVByte( + details.FeeRate = btcunit.CalcSatPerVByte( details.Fee, details.Weight.ToVB(), ) } diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go index a941531bc9..4aba310f23 100644 --- a/wallet/tx_reader_test.go +++ b/wallet/tx_reader_test.go @@ -280,7 +280,7 @@ func createUnminedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) { Label: testLabel, Value: totalCredits - debitAmt, Fee: fee, - FeeRate: btcunit.NewSatPerVByte(fee, weight.ToVB()), + FeeRate: btcunit.CalcSatPerVByte(fee, weight.ToVB()), Confirmations: 0, Weight: weight, ReceivedTime: txTime, From 0efa54cbe2a70dd4fd81d12e0c87771f6346cce8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 19 Nov 2025 06:40:29 +0800 Subject: [PATCH 169/691] btcunit: add Val method for backward compatibility This commit adds a Val() method to SatPerKVByte and SatPerKWeight in btcunit to provide access to the underlying integer rate for backward compatibility with legacy APIs that expect btcutil.Amount. --- pkg/btcunit/rates.go | 18 ++++ pkg/btcunit/rates_test.go | 176 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go index 4b5849d912..7df09c8ae8 100644 --- a/pkg/btcunit/rates.go +++ b/pkg/btcunit/rates.go @@ -285,6 +285,15 @@ func CalcSatPerKVByte(fee btcutil.Amount, kvb KVByte) SatPerKVByte { return SatPerKVByte{newBaseFeeRate(numerator, denominator)} } +// Val returns the fee rate in sat/kvb. +// +// NOTE: This method is provided for backward compatibility with legacy APIs +// that expect a raw integer fee rate. New code should use the btcunit types +// directly. +func (s SatPerKVByte) Val() btcutil.Amount { + return s.FeeForKVByte(NewKVByte(1)) +} + // String returns a human-readable string of the fee rate. func (s SatPerKVByte) String() string { // Calculate the fee rate in sat/kvb from the canonical sat/kwu. @@ -354,6 +363,15 @@ func CalcSatPerKWeight(fee btcutil.Amount, kwu KWeightUnit) SatPerKWeight { return SatPerKWeight{newBaseFeeRate(numerator, denominator)} } +// Val returns the fee rate in sat/kw. +// +// NOTE: This method is provided for backward compatibility with legacy APIs +// that expect a raw integer fee rate. New code should use the btcunit types +// directly. +func (s SatPerKWeight) Val() btcutil.Amount { + return s.FeeForKWeight(NewKWeightUnit(1)) +} + // String returns a human-readable string of the fee rate. func (s SatPerKWeight) String() string { return s.satsPerKWU.FloatString(floatStringPrecision) + " sat/kw" diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go index b6529f3252..53727681e2 100644 --- a/pkg/btcunit/rates_test.go +++ b/pkg/btcunit/rates_test.go @@ -570,3 +570,179 @@ func TestSafeUint64ToInt64Overflow(t *testing.T) { require.Zero(t, expectedDenom.Cmp(rateW.satsPerKWU.Denom())) } +// TestVal checks that the Val method returns the correct integer fee rate. +func TestVal(t *testing.T) { + t.Parallel() + + // Test SatPerKVByte.Val(). + rateKVB := NewSatPerKVByte(1000) + require.Equal(t, btcutil.Amount(1000), rateKVB.Val()) + + // Test SatPerKWeight.Val(). + rateKW := NewSatPerKWeight(250) + require.Equal(t, btcutil.Amount(250), rateKW.Val()) +} + +// TestRatePrecision checks that baseFeeRate preserves precision for +// non-integer rates (e.g., repeating decimals) during conversions and fee +// calculations for all rate units. +func TestRatePrecision(t *testing.T) { + t.Parallel() + + // We choose a test payload size of 12,000 weight units. + // This specific number is chosen because it is cleanly divisible by + // all unit factors, allowing us to pass exact integer amounts to all + // FeeFor... methods. + // + // 12,000 wu = 12 kwu + // 12,000 wu = 3,000 vb + // 12,000 wu = 3 kvb + const ( + payloadWU = 12000 + payloadKWU = 12 + payloadVB = 3000 + payloadKVB = 3 + ) + + // expectedFee is always 1 satoshi because we define the rate in each + // test case as (1 sat / payload_size). + const expectedFee = btcutil.Amount(1) + + // 1. Test SatPerWeight. + // Rate: 1 sat / 12,000 wu = 0.0000833... sat/wu. + t.Run("SatPerWeight", func(t *testing.T) { + t.Parallel() + + rate := CalcSatPerWeight(1, NewWeightUnit(payloadWU)) + + // The rate 0.0000833... rounds to 0.000 when displayed with 3 + // decimal places, but the internal precision is preserved. + require.Equal(t, "0.000 sat/wu", rate.String()) + require.Equal(t, expectedFee, + rate.FeeForWeight(NewWeightUnit(payloadWU))) + + // Convert to SatPerKWeight. + // Rate: 1 sat / 12 kwu = 0.0833... sat/kw. + kw := rate.ToSatPerKWeight() + require.Equal(t, "0.083 sat/kw", kw.String()) + require.Equal(t, expectedFee, + kw.FeeForKWeight(NewKWeightUnit(payloadKWU))) + + // Convert to SatPerVByte. + // Rate: 1 sat / 3,000 vb = 0.00033... sat/vb. + // This rounds to 0.000 at 3 decimals. + vb := rate.ToSatPerVByte() + require.Equal(t, "0.000 sat/vb", vb.String()) + require.Equal(t, expectedFee, + vb.FeeForVByte(NewVByte(payloadVB))) + + // Convert to SatPerKVByte. + // Rate: 1 sat / 3 kvb = 0.333... sat/kvb. + kvb := rate.ToSatPerKVByte() + require.Equal(t, "0.333 sat/kvb", kvb.String()) + require.Equal(t, expectedFee, + kvb.FeeForKVByte(NewKVByte(payloadKVB))) + }) + + // 2. Test SatPerKWeight. + // Rate: 1 sat / 12 kwu = 0.0833... sat/kw. + t.Run("SatPerKWeight", func(t *testing.T) { + t.Parallel() + + rate := CalcSatPerKWeight(1, NewKWeightUnit(payloadKWU)) + require.Equal(t, "0.083 sat/kw", rate.String()) + require.Equal(t, expectedFee, + rate.FeeForKWeight(NewKWeightUnit(payloadKWU))) + + // Convert to SatPerWeight. + // Rate: 1 sat / 12,000 wu = 0.0000833... sat/wu. + // Rounds to 0.000. + w := rate.ToSatPerWeight() + require.Equal(t, "0.000 sat/wu", w.String()) + require.Equal(t, expectedFee, + w.FeeForWeight(NewWeightUnit(payloadWU))) + + // Convert to SatPerVByte. + // Rate: 1 sat / 3,000 vb = 0.00033... sat/vb. + // Rounds to 0.000. + vb := rate.ToSatPerVByte() + require.Equal(t, "0.000 sat/vb", vb.String()) + require.Equal(t, expectedFee, + vb.FeeForVByte(NewVByte(payloadVB))) + + // Convert to SatPerKVByte. + // Rate: 1 sat / 3 kvb = 0.333... sat/kvb. + kvb := rate.ToSatPerKVByte() + require.Equal(t, "0.333 sat/kvb", kvb.String()) + require.Equal(t, expectedFee, + kvb.FeeForKVByte(NewKVByte(payloadKVB))) + }) + + // 3. Test SatPerVByte. + // Rate: 1 sat / 3,000 vb = 0.00033... sat/vb. + t.Run("SatPerVByte", func(t *testing.T) { + t.Parallel() + + rate := CalcSatPerVByte(1, NewVByte(payloadVB)) + // Rounds to 0.000 at 3 decimals. + require.Equal(t, "0.000 sat/vb", rate.String()) + require.Equal(t, expectedFee, + rate.FeeForVByte(NewVByte(payloadVB))) + + // Convert to SatPerKVByte. + // Rate: 1 sat / 3 kvb = 0.333... sat/kvb. + kvb := rate.ToSatPerKVByte() + require.Equal(t, "0.333 sat/kvb", kvb.String()) + require.Equal(t, expectedFee, + kvb.FeeForKVByte(NewKVByte(payloadKVB))) + + // Convert to SatPerKWeight. + // Rate: 1 sat / 12 kwu = 0.0833... sat/kw. + kw := rate.ToSatPerKWeight() + require.Equal(t, "0.083 sat/kw", kw.String()) + require.Equal(t, expectedFee, + kw.FeeForKWeight(NewKWeightUnit(payloadKWU))) + + // Convert to SatPerWeight. + // Rate: 1 sat / 12,000 wu = 0.0000833... sat/wu. + // Rounds to 0.000. + w := rate.ToSatPerWeight() + require.Equal(t, "0.000 sat/wu", w.String()) + require.Equal(t, expectedFee, + w.FeeForWeight(NewWeightUnit(payloadWU))) + }) + + // 4. Test SatPerKVByte. + // Rate: 1 sat / 3 kvb = 0.333... sat/kvb. + t.Run("SatPerKVByte", func(t *testing.T) { + t.Parallel() + + rate := CalcSatPerKVByte(1, NewKVByte(payloadKVB)) + require.Equal(t, "0.333 sat/kvb", rate.String()) + require.Equal(t, expectedFee, + rate.FeeForKVByte(NewKVByte(payloadKVB))) + + // Convert to SatPerVByte. + // Rate: 1 sat / 3,000 vb = 0.00033... sat/vb. + // Rounds to 0.000. + vb := rate.ToSatPerVByte() + require.Equal(t, "0.000 sat/vb", vb.String()) + require.Equal(t, expectedFee, + vb.FeeForVByte(NewVByte(payloadVB))) + + // Convert to SatPerKWeight. + // Rate: 1 sat / 12 kwu = 0.0833... sat/kw. + kw := rate.ToSatPerKWeight() + require.Equal(t, "0.083 sat/kw", kw.String()) + require.Equal(t, expectedFee, + kw.FeeForKWeight(NewKWeightUnit(payloadKWU))) + + // Convert to SatPerWeight. + // Rate: 1 sat / 12,000 wu = 0.0000833... sat/wu. + // Rounds to 0.000. + w := rate.ToSatPerWeight() + require.Equal(t, "0.000 sat/wu", w.String()) + require.Equal(t, expectedFee, + w.FeeForWeight(NewWeightUnit(payloadWU))) + }) +} From 147c0e1f40f574dc3f0fb4efbd522c29466cae6d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 19 Nov 2025 06:26:48 +0800 Subject: [PATCH 170/691] wallet: use `btcunit` for `TxIntent.FeeRate` --- wallet/tx_creator.go | 29 ++++++++++------------ wallet/tx_creator_test.go | 51 +++++++++++++++++++++------------------ 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index 160541f4f7..38652ffed1 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -15,8 +15,8 @@ import ( "errors" "fmt" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" @@ -73,14 +73,16 @@ var ( ErrNilTxIntent = errors.New("nil TxIntent") ) -const ( +var ( // DefaultMaxFeeRate is the default maximum fee rate in sat/kvb that // the wallet will consider sane. This is currently set to 1000 sat/vb // (1,000,000 sat/kvb). // // TODO(yy): The max fee rate should be made configurable as part of // the WalletController interface implementation. - DefaultMaxFeeRate SatPerKVByte = 1000 * 1000 + // + //nolint:mnd // 1M sat/kvb default max fee. + DefaultMaxFeeRate = btcunit.NewSatPerKVByte(1_000_000) ) // TxCreator provides an interface for creating transactions. Its primary @@ -97,11 +99,6 @@ type TxCreator interface { // A compile time check to ensure that Wallet implements the interface. var _ TxCreator = (*Wallet)(nil) -// SatPerKVByte is a type that represents a fee rate in satoshis per -// kilo-virtual-byte. This is the standard unit for fee estimation in modern -// Bitcoin transactions that use SegWit. -type SatPerKVByte btcutil.Amount - // TxIntent represents the user's intent to create a transaction. It serves as // a blueprint for the TxCreator, bundling all the parameters required to // construct a transaction into a single, coherent structure. @@ -198,7 +195,7 @@ type TxIntent struct { // FeeRate specifies the desired fee rate for the transaction, // expressed in satoshis per kilo-virtual-byte (sat/kvb). This field is // required. - FeeRate SatPerKVByte + FeeRate btcunit.SatPerKVByte // Label is an optional, human-readable label for the transaction. This // can be used to associate a memo with the transaction for later @@ -406,15 +403,15 @@ func validateTxIntent(intent *TxIntent) error { } // The intent must have a non-zero fee rate. - if intent.FeeRate == 0 { + if intent.FeeRate.LessThanOrEqual(btcunit.ZeroSatPerKVByte) { return ErrMissingFeeRate } // Ensure the fee rate is not "insane". This prevents users from // accidentally paying exorbitant fees. - if intent.FeeRate > DefaultMaxFeeRate { - return fmt.Errorf("%w: fee rate of %d sat/kvb is too high, "+ - "max sane fee rate is %d sat/kvb", ErrFeeRateTooLarge, + if intent.FeeRate.GreaterThan(DefaultMaxFeeRate) { + return fmt.Errorf("%w: fee rate of %s is too high, "+ + "max sane fee rate is %s", ErrFeeRateTooLarge, intent.FeeRate, DefaultMaxFeeRate) } @@ -540,7 +537,7 @@ func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( // With the input source and change source prepared, we can now call the // txauthor package to perform the actual coin selection and create the // unsigned transaction. - feeSatPerKb := btcutil.Amount(intent.FeeRate) + feeSatPerKb := intent.FeeRate.Val() tx, err := txauthor.NewUnsignedTransaction( outputs, feeSatPerKb, inputSource, changeSource, @@ -652,7 +649,7 @@ func (w *Wallet) createManualInputSource(dbtx walletdb.ReadTx, // createPolicyInputSource creates an input source that will perform automatic // coin selection based on the provided policy. func (w *Wallet) createPolicyInputSource(dbtx walletdb.ReadTx, - policy *InputsPolicy, feeRate SatPerKVByte) ( + policy *InputsPolicy, feeRate btcunit.SatPerKVByte) ( txauthor.InputSource, error) { // Fall back to the default coin selection strategy if none is supplied. @@ -691,7 +688,7 @@ func (w *Wallet) createPolicyInputSource(dbtx walletdb.ReadTx, // Arrange the eligible coins according to the chosen strategy (e.g., // sort by largest first, or shuffle for random selection). - feeSatPerKb := btcutil.Amount(feeRate) + feeSatPerKb := feeRate.Val() arrangedCoins, err := strategy.ArrangeCoins( wrappedEligible, feeSatPerKb, diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index 65bb6c2d14..7c53b969f1 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" @@ -47,6 +48,7 @@ func TestValidateTxIntent(t *testing.T) { AccountName: validAccountName, KeyScope: waddrmgr.KeyScopeBIP0086, } + defaultFeeRate := btcunit.NewSatPerKVByte(1000) // Define the test cases, each representing a different scenario for // validating a TxIntent. @@ -65,7 +67,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: nil, }, @@ -80,7 +82,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: nil, }, @@ -98,7 +100,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: nil, }, @@ -110,7 +112,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: nil, }, @@ -122,7 +124,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: ErrMissingInputs, }, @@ -136,7 +138,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: ErrNoTxOutputs, }, @@ -150,7 +152,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: txrules.ErrOutputIsDust, }, @@ -164,7 +166,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: ErrManualInputsEmpty, }, @@ -180,7 +182,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: ErrDuplicatedUtxo, }, @@ -194,7 +196,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: ErrMissingAccountName, }, @@ -210,7 +212,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: ErrManualInputsEmpty, }, @@ -228,7 +230,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: ErrDuplicatedUtxo, }, @@ -242,7 +244,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: ErrUnsupportedCoinSource, }, @@ -254,7 +256,7 @@ func TestValidateTxIntent(t *testing.T) { ChangeSource: &ScopedAccount{ AccountName: defaultAccountName, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: nil, }, @@ -269,7 +271,7 @@ func TestValidateTxIntent(t *testing.T) { AccountName: "", KeyScope: waddrmgr.KeyScopeBIP0086, }, - FeeRate: 1000, + FeeRate: defaultFeeRate, }, expectedErr: ErrMissingAccountName, }, @@ -284,7 +286,7 @@ func TestValidateTxIntent(t *testing.T) { AccountName: defaultAccountName, KeyScope: waddrmgr.KeyScopeBIP0086, }, - FeeRate: 0, + FeeRate: btcunit.ZeroSatPerKVByte, }, expectedErr: ErrMissingFeeRate, }, @@ -299,7 +301,7 @@ func TestValidateTxIntent(t *testing.T) { AccountName: defaultAccountName, KeyScope: waddrmgr.KeyScopeBIP0086, }, - FeeRate: DefaultMaxFeeRate + 1, + FeeRate: btcunit.NewSatPerKVByte(2_000_000), }, expectedErr: ErrFeeRateTooLarge, }, @@ -777,7 +779,7 @@ func TestCreatePolicyInputSource(t *testing.T) { t.Parallel() dbtx := &mockReadTx{} - feeRate := SatPerKVByte(1000) + feeRate := btcunit.NewSatPerKVByte(1000) utxo1 := wtxmgr.Credit{ OutPoint: wire.OutPoint{Hash: [32]byte{1}, Index: 0}, @@ -910,7 +912,10 @@ func TestCreateInputSource(t *testing.T) { unsupported := &unsupportedInputs{} intentManual := &TxIntent{Inputs: manualInputs} - intentPolicy := &TxIntent{Inputs: policyInputs, FeeRate: 1000} + intentPolicy := &TxIntent{ + Inputs: policyInputs, + FeeRate: btcunit.NewSatPerKVByte(1000), + } intentUnsupported := &TxIntent{Inputs: unsupported} testCases := []struct { @@ -1020,7 +1025,7 @@ func TestCreateTransactionSuccessManualInputs(t *testing.T) { AccountName: defaultAccountName, KeyScope: waddrmgr.KeyScopeBIP0086, }, - FeeRate: 1000, + FeeRate: btcunit.NewSatPerKVByte(1000), } accountStore := &mockAccountStore{} @@ -1110,7 +1115,7 @@ func TestCreateTransactionSuccessNilChangeSourceManualInputs(t *testing.T) { UTXOs: []wire.OutPoint{validUTXO}, }, ChangeSource: nil, - FeeRate: 1000, + FeeRate: btcunit.NewSatPerKVByte(1000), } accountStore := &mockAccountStore{} @@ -1205,7 +1210,7 @@ func TestCreateTransactionSuccessNilChangeSourcePolicyInputs(t *testing.T) { }, }, ChangeSource: nil, - FeeRate: 1000, + FeeRate: btcunit.NewSatPerKVByte(1000), } accountStore := &mockAccountStore{} @@ -1327,7 +1332,7 @@ func TestCreateTransactionAccountNotFound(t *testing.T) { AccountName: "unknown", KeyScope: waddrmgr.KeyScopeBIP0086, }, - FeeRate: 1000, + FeeRate: btcunit.NewSatPerKVByte(1000), } accountStore := &mockAccountStore{} From a1caf94589f957698be2ace147e8670f2301275e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 28 Nov 2025 20:14:27 +0800 Subject: [PATCH 171/691] btcunit: panic on zero denominator in fee rate calculation --- pkg/btcunit/rates.go | 5 ++-- pkg/btcunit/rates_test.go | 53 ++++++++++++++++----------------------- 2 files changed, 23 insertions(+), 35 deletions(-) diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go index 7df09c8ae8..7ca05ee98f 100644 --- a/pkg/btcunit/rates.go +++ b/pkg/btcunit/rates.go @@ -51,11 +51,10 @@ type baseFeeRate struct { } // newBaseFeeRate creates a new baseFeeRate with the given numerator and -// denominator. It handles the zero denominator case by returning a zero fee -// rate. +// denominator. It panics if the denominator is zero. func newBaseFeeRate(numerator btcutil.Amount, denominator uint64) baseFeeRate { if denominator == 0 { - return baseFeeRate{satsPerKWU: big.NewRat(0, 1)} + panic("fee rate calculation: denominator cannot be zero") } return baseFeeRate{satsPerKWU: big.NewRat( diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go index 53727681e2..02da8eaeb2 100644 --- a/pkg/btcunit/rates_test.go +++ b/pkg/btcunit/rates_test.go @@ -487,44 +487,33 @@ func TestFeeForSize(t *testing.T) { func TestNewFeeRateConstructorsZero(t *testing.T) { t.Parallel() - // Test CalcSatPerKWeight with zero weight. + // Test CalcSatPerKWeight with zero weight should panic. fee := btcutil.Amount(1000) - kwu := NewKWeightUnit(0) - expectedRate := NewSatPerKWeight(0) - require.Zero( - t, expectedRate.satsPerKWU.Cmp( - CalcSatPerKWeight(fee, kwu).satsPerKWU, - ), - ) + require.Panics(t, func() { + kwu := NewKWeightUnit(0) + _ = CalcSatPerKWeight(fee, kwu) + }) - // Test CalcSatPerVByte with zero vbytes. - vb := NewVByte(0) - expectedRateVB := NewSatPerVByte(0) - require.Zero( - t, expectedRateVB.satsPerKWU.Cmp( - CalcSatPerVByte(fee, vb).satsPerKWU, - ), - ) + // Test CalcSatPerVByte with zero vbytes should panic. + require.Panics(t, func() { + vb := NewVByte(0) + _ = CalcSatPerVByte(fee, vb) + }) - // Test CalcSatPerKVByte with zero kvbytes. - kvb := NewKVByte(0) - expectedRateKVB := NewSatPerKVByte(0) - require.Zero( - t, expectedRateKVB.satsPerKWU.Cmp( - CalcSatPerKVByte(fee, kvb).satsPerKWU, - ), - ) + // Test CalcSatPerKVByte with zero kvbytes should panic. + require.Panics(t, func() { + kvb := NewKVByte(0) + _ = CalcSatPerKVByte(fee, kvb) + }) - // Test CalcSatPerWeight with zero weight units. - wu := NewWeightUnit(0) - expectedRateW := NewSatPerWeight(0) - require.Zero( - t, expectedRateW.satsPerKWU.Cmp( - CalcSatPerWeight(fee, wu).satsPerKWU, - ), - ) + // Test CalcSatPerWeight with zero weight units should panic. + require.Panics(t, func() { + wu := NewWeightUnit(0) + _ = CalcSatPerWeight(fee, wu) + }) // Test zero constants. + // NewSatPerVByte(0) -> Rate 0 sats / 1 vb. Valid. require.True(t, ZeroSatPerVByte.Equal(NewSatPerVByte(0))) require.True(t, ZeroSatPerKVByte.Equal(NewSatPerKVByte(0))) require.True(t, ZeroSatPerKWeight.Equal(NewSatPerKWeight(0))) From fd265a5afa9976603e9405cca93b45f8ffb51924 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:20:31 +0000 Subject: [PATCH 172/691] wallet: benchmark `DerivePubKey` --- wallet/signer_benchmark_test.go | 92 +++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 wallet/signer_benchmark_test.go diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go new file mode 100644 index 0000000000..4f78358df6 --- /dev/null +++ b/wallet/signer_benchmark_test.go @@ -0,0 +1,92 @@ +package wallet + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// BenchmarkDerivePubKey benchmarks the DerivePubKey method across different +// wallet sizes. The benchmark measures the performance of deriving a public +// key from a BIP-32 path, which involves database lookups and cryptographic +// operations. +func BenchmarkDerivePubKey(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + // accountGrowth uses linearGrowth to test how performance + // scales with the number of accounts in the wallet. Key + // derivation uses the account index in the BIP-32 path, so + // database lookup time should remain constant due to indexed + // lookups. + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // affect the key derivation's time complexity - it derives from + // an explicit path without address search. + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // utxoGrowth uses constantGrowth since UTXO count doesn't + // affect the key derivation's time complexity. + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{ + waddrmgr.KeyScopeBIP0084, + waddrmgr.KeyScopeBIP0086, + } + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name, func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + // Use a path from the middle of the account range + // for representative performance. + accountIndex := uint32(accountGrowth[i] / 2) + path := BIP32Path{ + KeyScope: scopes[0], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: accountIndex, + Branch: 0, + Index: 0, + }, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := w.DerivePubKey(b.Context(), path) + require.NoError(b, err) + } + }) + } +} From 903d6222edada253e5798b442b03c1a36685496f Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:21:13 +0000 Subject: [PATCH 173/691] wallet: benchmark `ECDH` --- wallet/signer_benchmark_test.go | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index 4f78358df6..bf3fc90e35 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/stretchr/testify/require" ) @@ -90,3 +91,86 @@ func BenchmarkDerivePubKey(b *testing.B) { }) } } + +// BenchmarkECDH benchmarks the ECDH method across different wallet sizes. +// The benchmark measures the performance of performing an ECDH operation +// between a wallet key and a remote public key. +func BenchmarkECDH(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + // accountGrowth uses linearGrowth to test scaling with wallet + // size. ECDH derives the wallet's private key using the account + // index in the BIP-32 path for the scalar multiplication. + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // the ECDH operation's time complexity. It uses an explicit + // path. + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // utxoGrowth uses constantGrowth since UTXO count doesn't + // affect the cryptographic operation's time complexity. + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + // Generate a remote public key for ECDH. + remotePrivKey, err := btcec.NewPrivateKey() + require.NoError(b, err) + + remotePubKey := remotePrivKey.PubKey() + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name, func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + accountIndex := uint32(accountGrowth[i] / 2) + path := BIP32Path{ + KeyScope: scopes[0], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: accountIndex, + Branch: 0, + Index: 0, + }, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _, err := w.ECDH( + b.Context(), path, remotePubKey, + ) + require.NoError(b, err) + } + }) + } +} From 53cbcbca0e8cc1987b1f54469b8922e6681d1e66 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:22:52 +0000 Subject: [PATCH 174/691] wallet: benchmark `SignDigestECDSA` --- wallet/signer_benchmark_test.go | 87 +++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index bf3fc90e35..f2164ee43d 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/stretchr/testify/require" ) @@ -174,3 +175,89 @@ func BenchmarkECDH(b *testing.B) { }) } } + +// BenchmarkSignDigestECDSA benchmarks the SignDigest method for ECDSA +// signatures across different wallet sizes. The benchmark measures the +// performance of signing a digest with ECDSA. +func BenchmarkSignDigestECDSA(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + // accountGrowth uses linearGrowth to test scaling with wallet + // size. Signature operations derive keys using the account + // index in the BIP-32 path. + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // affect the signature generation's time complexity when using + // an explicit path. + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // utxoGrowth uses constantGrowth since UTXO count doesn't + // affect the signature generation's time complexity. + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + // Create a test digest to sign. + digest := chainhash.HashB([]byte("test message")) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name, func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + accountIndex := uint32(accountGrowth[i] / 2) + path := BIP32Path{ + KeyScope: scopes[0], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: accountIndex, + Branch: 0, + Index: 0, + }, + } + + intent := &SignDigestIntent{ + Digest: digest, + SigType: SigTypeECDSA, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + sig, err := w.SignDigest( + b.Context(), path, intent, + ) + require.NoError(b, err) + require.NotNil(b, sig) + } + }) + } +} From 4f30af852e2e96b6835a51ec52e2aa28d538131a Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:34:03 +0000 Subject: [PATCH 175/691] wallet: benchmark `SignDigestECDSACompact` --- wallet/signer_benchmark_test.go | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index f2164ee43d..0930db39a1 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -261,3 +261,89 @@ func BenchmarkSignDigestECDSA(b *testing.B) { }) } } + +// BenchmarkSignDigestECDSACompact benchmarks the SignDigest method for +// compact ECDSA signatures across different wallet sizes. +func BenchmarkSignDigestECDSACompact(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + // accountGrowth uses linearGrowth to test scaling with wallet + // size. Signature operations derive keys using the account + // index in the BIP-32 path. + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // affect the signature generation's time complexity when using + // an explicit path. + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // utxoGrowth uses constantGrowth since UTXO count doesn't + // affect the signature generation's time complexity. + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + digest := chainhash.DoubleHashB([]byte("test message")) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name, func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + accountIndex := uint32(accountGrowth[i] / 2) + + path := BIP32Path{ + KeyScope: scopes[0], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: accountIndex, + Branch: 0, + Index: 0, + }, + } + + intent := &SignDigestIntent{ + Digest: digest, + SigType: SigTypeECDSA, + CompactSig: true, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + sig, err := w.SignDigest( + b.Context(), path, intent, + ) + require.NoError(b, err) + require.NotNil(b, sig) + } + }) + } +} From 9277aa59c7545865c5f737ecfc3e109efb278356 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:35:46 +0000 Subject: [PATCH 176/691] wallet: benchmark `SignDigestSchnorr` --- wallet/signer_benchmark_test.go | 79 +++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index 0930db39a1..63597c9624 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -347,3 +347,82 @@ func BenchmarkSignDigestECDSACompact(b *testing.B) { }) } } + +// BenchmarkSignDigestSchnorr benchmarks the SignDigest method for Schnorr +// signatures across different wallet sizes. The benchmark measures the +// performance of signing a digest with Schnorr signatures. +func BenchmarkSignDigestSchnorr(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0086} + ) + + digest := chainhash.TaggedHash( + []byte("BIP0340/challenge"), []byte("test message"), + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name, func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + accountIndx := uint32(accountGrowth[i] / 2) + path := BIP32Path{ + KeyScope: scopes[0], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: accountIndx, + Branch: 0, + Index: 0, + }, + } + + intent := &SignDigestIntent{ + Digest: digest[:], + SigType: SigTypeSchnorr, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + sig, err := w.SignDigest( + b.Context(), path, intent, + ) + require.NoError(b, err) + require.NotNil(b, sig) + } + }) + } +} From 19154d8e4f3cf0cbc11768e78fd5d3fcf0ea0439 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:42:04 +0000 Subject: [PATCH 177/691] wallet: benchmark `ComputeUnlockingScriptP2WKH` --- wallet/signer_benchmark_test.go | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index 63597c9624..62316c469a 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -6,6 +6,8 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/stretchr/testify/require" ) @@ -426,3 +428,105 @@ func BenchmarkSignDigestSchnorr(b *testing.B) { }) } } + +// BenchmarkComputeUnlockingScriptP2WKH benchmarks the ComputeUnlockingScript +// method for P2WKH outputs across different wallet sizes and UTXO counts. +func BenchmarkComputeUnlockingScriptP2WKH(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + exponentialGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + utxoGrowthPadding = decimalWidth( + utxoGrowth[len(utxoGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d/UTXOs-%0*d", + accountGrowthPadding, accountGrowth[i], + utxoGrowthPadding, utxoGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + // Get a test address and create a P2WKH output. + testAddr := getTestAddress( + b, bw.Wallet, accountGrowth[i], + ) + pkScript, err := txscript.PayToAddrScript(testAddr) + require.NoError(b, err) + + prevOut := &wire.TxOut{ + Value: 100000, + PkScript: pkScript, + } + + // Create a spending transaction. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{}, + Index: 0, + }, + }) + tx.AddTxOut(&wire.TxOut{ + Value: 50000, + PkScript: pkScript, + }) + + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + params := &UnlockingScriptParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + unlockScript, err := bw.ComputeUnlockingScript( + b.Context(), params, + ) + require.NoError(b, err) + require.NotNil(b, unlockScript) + } + }) + } +} From 0f1a4e81286f2a831c48409ae28d819e901e0484 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:42:41 +0000 Subject: [PATCH 178/691] wallet: benchmark `ComputeUnlockingScriptP2TR` --- wallet/signer_benchmark_test.go | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index 62316c469a..e5e4a3777c 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -530,3 +530,107 @@ func BenchmarkComputeUnlockingScriptP2WKH(b *testing.B) { }) } } + +// BenchmarkComputeUnlockingScriptP2TR benchmarks the ComputeUnlockingScript +// method for P2TR (Taproot) key-path spends across different wallet sizes. +func BenchmarkComputeUnlockingScriptP2TR(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + // accountGrowth uses linearGrowth to test scaling with wallet + // size. ComputeUnlockingScript derives the signing key from the + // output's address, which requires account lookup. + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + // addressGrowth uses constantGrowth since we're testing with a + // single specific output address. + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // utxoGrowth uses constantGrowth since UTXO count doesn't + // affect signing a single input. + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0086} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + // Get a test Taproot address. + testAddr := getTestAddress( + b, bw.Wallet, accountGrowth[i], + ) + pkScript, err := txscript.PayToAddrScript(testAddr) + require.NoError(b, err) + + prevOut := &wire.TxOut{ + Value: 100000, + PkScript: pkScript, + } + + // Create a spending transaction. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{}, + Index: 0, + }, + }) + tx.AddTxOut(&wire.TxOut{ + Value: 50000, + PkScript: pkScript, + }) + + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + params := &UnlockingScriptParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashDefault, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + unlockScript, err := bw.ComputeUnlockingScript( + b.Context(), params, + ) + require.NoError(b, err) + require.NotNil(b, unlockScript) + } + }) + } +} From 69997c2d3b53423c533c86729bb3fb47ee5eef30 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:43:20 +0000 Subject: [PATCH 179/691] wallet: benchmark `ComputeRawSigSegwitV0` --- wallet/signer_benchmark_test.go | 131 ++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index e5e4a3777c..e829497657 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -634,3 +634,134 @@ func BenchmarkComputeUnlockingScriptP2TR(b *testing.B) { }) } } + +// BenchmarkComputeRawSigSegwitV0 benchmarks the ComputeRawSig method for +// SegWit v0 inputs. +func BenchmarkComputeRawSigSegwitV0(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + // accountGrowth uses constantGrowth to verify that wallet size + // doesn't affect performance. ComputeRawSig uses an explicit + // BIP-32 path, so performance should be constant regardless of + // account count. + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // affect the ComputeRawSig operation's time complexity - it + // uses an explicit BIP-32 path. + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // utxoGrowth uses constantGrowth since UTXO count doesn't + // affect the ComputeRawSig operation's time complexity. + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + // Get a test address and create witness script. + testAddr := getTestAddress( + b, bw.Wallet, accountGrowth[i], + ) + witnessPubKeyHash := testAddr.ScriptAddress() + witnessScript, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_DUP). + AddOp(txscript.OP_HASH160). + AddData(witnessPubKeyHash). + AddOp(txscript.OP_EQUALVERIFY). + AddOp(txscript.OP_CHECKSIG). + Script() + require.NoError(b, err) + + pkScript, err := txscript.PayToAddrScript(testAddr) + require.NoError(b, err) + + prevOut := &wire.TxOut{ + Value: 100000, + PkScript: pkScript, + } + + // Create a spending transaction. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{}, + Index: 0, + }, + }) + tx.AddTxOut(&wire.TxOut{ + Value: 50000, + PkScript: pkScript, + }) + + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + accountIndex := uint32(accountGrowth[i] / 2) + addressIndex := uint32(addressGrowth[i] / 2) + path := BIP32Path{ + KeyScope: scopes[0], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: accountIndex, + Branch: 0, + Index: addressIndex, + }, + } + + params := &RawSigParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Path: path, + Details: SegwitV0SpendDetails{ + WitnessScript: witnessScript, + }, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + sig, err := bw.ComputeRawSig( + b.Context(), params, + ) + require.NoError(b, err) + require.NotNil(b, sig) + } + }) + } +} From 6c3afe18ece0590407621f345e1a438e11d9eb52 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:43:54 +0000 Subject: [PATCH 180/691] wallet: benchmark `ComputeRawSigTaproot` --- wallet/signer_benchmark_test.go | 115 ++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index e829497657..faef903bc5 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -765,3 +765,118 @@ func BenchmarkComputeRawSigSegwitV0(b *testing.B) { }) } } + +// BenchmarkComputeRawSigTaproot benchmarks the ComputeRawSig method for +// Taproot key-path spends. Since ComputeRawSig uses an explicit path and +// doesn't search through accounts, wallet size shouldn't affect performance - +// we use constantGrowth to verify performance remains constant regardless of +// wallet size. +func BenchmarkComputeRawSigTaproot(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 10 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0086} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + // Get a test Taproot address. + testAddr := getTestAddress( + b, bw.Wallet, accountGrowth[i], + ) + pkScript, err := txscript.PayToAddrScript(testAddr) + require.NoError(b, err) + + prevOut := &wire.TxOut{ + Value: 100000, + PkScript: pkScript, + } + + // Create a spending transaction. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{}, + Index: 0, + }, + }) + tx.AddTxOut(&wire.TxOut{ + Value: 50000, + PkScript: pkScript, + }) + + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + accountIndex := uint32(accountGrowth[i] / 2) + addressIndex := uint32(addressGrowth[i] / 2) + path := BIP32Path{ + KeyScope: scopes[0], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: accountIndex, + Branch: 0, + Index: addressIndex, + }, + } + + params := &RawSigParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashDefault, + Path: path, + Details: TaprootSpendDetails{ + SpendPath: KeyPathSpend, + }, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + sig, err := bw.ComputeRawSig( + b.Context(), params, + ) + require.NoError(b, err) + require.NotNil(b, sig) + } + }) + } +} From 4a85a2d974d14219432bba6419cca6906dc4a52f Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:45:47 +0000 Subject: [PATCH 181/691] wallet: benchmark `DerivePrivKey` --- wallet/signer_benchmark_test.go | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index faef903bc5..d8a294e282 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -880,3 +880,83 @@ func BenchmarkComputeRawSigTaproot(b *testing.B) { }) } } + +// BenchmarkDerivePrivKey benchmarks the DerivePrivKey method (UnsafeSigner) +// across different wallet sizes. This benchmark measures the performance of +// deriving a private key from a BIP-32 path. +func BenchmarkDerivePrivKey(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 10 + ) + + var ( + // accountGrowth uses linearGrowth to test scaling with wallet + // size. Signature operations derive keys using the account + // index in the BIP-32 path. + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + // addressGrowth uses constantGrowth since address count doesn't + // affect the signature generation's time complexity when using + // an explicit path. + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // utxoGrowth uses constantGrowth since UTXO count doesn't + // affect the signature generation's time complexity. + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d", accountGrowthPadding, + accountGrowth[i]) + + b.Run(name, func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + accountIndex := uint32(accountGrowth[i] / 2) + + path := BIP32Path{ + KeyScope: scopes[0], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: accountIndex, + Branch: 0, + Index: 0, + }, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + privKey, err := w.DerivePrivKey( + b.Context(), path, + ) + require.NoError(b, err) + require.NotNil(b, privKey) + privKey.Zero() + } + }) + } +} From 42063b21fadbbcfd221ec3c73e45211fa5f2cc3d Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:46:35 +0000 Subject: [PATCH 182/691] wallet: benchmark `GetPrivKeyForAddress` --- wallet/signer_benchmark_test.go | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index d8a294e282..70ce603ac1 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -960,3 +960,80 @@ func BenchmarkDerivePrivKey(b *testing.B) { }) } } + +// BenchmarkGetPrivKeyForAddress benchmarks the GetPrivKeyForAddress method +// (UnsafeSigner) across different wallet sizes and address counts. +func BenchmarkGetPrivKeyForAddress(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 10 + ) + + var ( + // accountGrowth uses linearGrowth since the account is part of + // the address search space. + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + // addressGrowth uses linearGrowth to test how address lookup + // performance scales. GetPrivKeyForAddress searches through + // the address manager to find the matching address, so + // performance scales with total address count. + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + // utxoGrowth uses constantGrowth since UTXO count doesn't + // affect the address lookup's time complexity. + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + accountGrowthPadding = decimalWidth( + accountGrowth[len(accountGrowth)-1], + ) + + addressGrowthPadding = decimalWidth( + addressGrowth[len(addressGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + name := fmt.Sprintf("Accounts-%0*d/Addresses-%0*d", + accountGrowthPadding, accountGrowth[i], + addressGrowthPadding, addressGrowth[i]) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + testAddr := getTestAddress( + b, bw.Wallet, accountGrowth[i], + ) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + privKey, err := bw.GetPrivKeyForAddress( + b.Context(), testAddr, + ) + require.NoError(b, err) + require.NotNil(b, privKey) + privKey.Zero() + } + }) + } +} From cf422fa60091c55890e5cef8384d6644cb0e0d40 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:47:17 +0000 Subject: [PATCH 183/691] wallet: benchmark `SignDigestComparisonECDSAvsSchnorr` --- wallet/signer_benchmark_test.go | 85 +++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index 70ce603ac1..e006f4d547 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -1037,3 +1037,88 @@ func BenchmarkGetPrivKeyForAddress(b *testing.B) { }) } } + +// BenchmarkSignDigestComparisonECDSAvsSchnorr compares ECDSA and Schnorr +// signature performance side-by-side. This benchmark helps understand the +// performance characteristics of different signature algorithms. +func BenchmarkSignDigestComparisonECDSAvsSchnorr(b *testing.B) { + const numAccounts = 5 + + scopes := []waddrmgr.KeyScope{ + waddrmgr.KeyScopeBIP0084, // For ECDSA + waddrmgr.KeyScopeBIP0086, // For Schnorr + } + + ecdsaDigest := chainhash.HashB([]byte("test message")) + schnorrDigest := chainhash.TaggedHash( + []byte("BIP0340/challenge"), []byte("test message"), + ) + + b.Run("ECDSA", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: []waddrmgr.KeyScope{scopes[0]}, + numAccounts: numAccounts, + numAddresses: 5, + numWalletTxs: 0, + }, + ) + + path := BIP32Path{ + KeyScope: scopes[0], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: 0, + Branch: 0, + Index: 0, + }, + } + + intent := &SignDigestIntent{ + Digest: ecdsaDigest, + SigType: SigTypeECDSA, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + sig, err := w.SignDigest(b.Context(), path, intent) + require.NoError(b, err) + require.NotNil(b, sig) + } + }) + + b.Run("Schnorr", func(b *testing.B) { + w := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: []waddrmgr.KeyScope{scopes[1]}, + numAccounts: numAccounts, + numAddresses: 5, + numWalletTxs: 0, + }, + ) + + path := BIP32Path{ + KeyScope: scopes[1], + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: 0, + Branch: 0, + Index: 0, + }, + } + + intent := &SignDigestIntent{ + Digest: schnorrDigest[:], + SigType: SigTypeSchnorr, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + sig, err := w.SignDigest(b.Context(), path, intent) + require.NoError(b, err) + require.NotNil(b, sig) + } + }) +} From ce341e80a77d2f9b0456a16af47a08c619ee42c3 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:50:22 +0000 Subject: [PATCH 184/691] wallet: benchmark `MultiInputTransaction` --- wallet/benchmark_helpers_test.go | 50 ++++++++++++++ wallet/signer_benchmark_test.go | 108 +++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index bb7d91c4e5..d52a5242d2 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -845,6 +845,56 @@ func leaseAllOutputs(tb testing.TB, w *Wallet, outpoints []wire.OutPoint, } } +// signMultipleInputs is a helper function that signs multiple transaction +// inputs using ComputeUnlockingScript. This is useful for benchmarks that test +// multi-input transaction signing performance. +// +// IMPORTANT: sigHashes should be pre-computed outside the benchmark loop to +// avoid measuring setup time. The caller should create a single sigHashes +// instance using all prevOuts before the benchmark loop, then pass it here. +func signMultipleInputs(tb testing.TB, w *Wallet, tx *wire.MsgTx, + prevOuts []*wire.TxOut, sigHashes *txscript.TxSigHashes, + hashType txscript.SigHashType) { + + tb.Helper() + + signMultipleInputsWithTweaker( + tb, w, tx, prevOuts, sigHashes, hashType, nil, + ) +} + +// signMultipleInputsWithTweaker is a helper function that signs multiple +// transaction inputs using ComputeUnlockingScript with an optional tweaker +// function. This is useful for benchmarks that test multi-input transaction +// signing performance with custom key tweaking. +// +// IMPORTANT: sigHashes should be pre-computed outside the benchmark loop to +// avoid measuring setup time. The caller should create a single sigHashes +// instance using all prevOuts before the benchmark loop, then pass it here. +func signMultipleInputsWithTweaker(tb testing.TB, w *Wallet, tx *wire.MsgTx, + prevOuts []*wire.TxOut, sigHashes *txscript.TxSigHashes, + hashType txscript.SigHashType, tweaker PrivKeyTweaker) { + + tb.Helper() + + for j := range prevOuts { + params := &UnlockingScriptParams{ + Tx: tx, + InputIndex: j, + Output: prevOuts[j], + SigHashes: sigHashes, + HashType: hashType, + Tweaker: tweaker, + } + + unlockingScript, err := w.ComputeUnlockingScript( + tb.Context(), params, + ) + require.NoError(tb, err) + require.NotNil(tb, unlockingScript) + } +} + // listAccountsDeprecated wraps the deprecated Accounts API to satisfy the same // contract as ListAccounts by calling Accounts API across all active key scopes // and aggregating the results. diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index e006f4d547..38b471f8ff 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -1122,3 +1122,111 @@ func BenchmarkSignDigestComparisonECDSAvsSchnorr(b *testing.B) { } }) } + +// BenchmarkMultiInputTransaction benchmarks signing a transaction with +// multiple inputs. +func BenchmarkMultiInputTransaction(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // Test with growing number of inputs using linear growth. + inputGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + inputGrowthPadding = decimalWidth( + inputGrowth[len(inputGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + for i := 0; i <= maxGrowthIteration; i++ { + numInputs := inputGrowth[i] + + name := fmt.Sprintf("Inputs-%0*d", inputGrowthPadding, + numInputs) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + // Get test addresses for outputs. + testAddr := getTestAddress( + b, bw.Wallet, accountGrowth[i], + ) + pkScript, err := txscript.PayToAddrScript(testAddr) + require.NoError(b, err) + + tx := wire.NewMsgTx(2) + + // Create previous outputs for each input. + prevOuts := make([]*wire.TxOut, numInputs) + for j := range numInputs { + prevOuts[j] = &wire.TxOut{ + Value: 100000, + PkScript: pkScript, + } + + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{byte(j)}, + Index: 0, + }, + }) + } + + // Add a single output. + tx.AddTxOut(&wire.TxOut{ + Value: int64(numInputs) * 100000, + PkScript: pkScript, + }) + + // Pre-compute sigHashes. + fetcher := txscript.NewMultiPrevOutFetcher(nil) + for j, prevOut := range prevOuts { + fetcher.AddPrevOut(wire.OutPoint{ + Hash: chainhash.Hash{byte(j)}, + Index: 0, + }, prevOut) + } + + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + signMultipleInputs( + b, bw.Wallet, tx, prevOuts, sigHashes, + txscript.SigHashAll, + ) + } + }) + } +} From 7bcd2939121fedfcc47af118521393ea7df10d36 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:51:33 +0000 Subject: [PATCH 185/691] wallet: benchmark `ComputeUnlockingScriptWithTweaker` --- wallet/signer_benchmark_test.go | 129 ++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index 38b471f8ff..e49ee6e022 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -1230,3 +1230,132 @@ func BenchmarkMultiInputTransaction(b *testing.B) { }) } } + +// BenchmarkComputeUnlockingScriptWithTweaker benchmarks the +// ComputeUnlockingScript method with a custom private key tweaker function +// across different numbers of inputs. +func BenchmarkComputeUnlockingScriptWithTweaker(b *testing.B) { + const ( + startGrowthIteration = 0 + maxGrowthIteration = 5 + ) + + var ( + accountGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + addressGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + utxoGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + constantGrowth, + ) + + // Test with growing number of inputs to see tweaker overhead + // with multiple inputs. + inputGrowth = mapRange( + startGrowthIteration, maxGrowthIteration, + linearGrowth, + ) + + inputGrowthPadding = decimalWidth( + inputGrowth[len(inputGrowth)-1], + ) + + scopes = []waddrmgr.KeyScope{waddrmgr.KeyScopeBIP0084} + ) + + // Create a tweaker function that adds a scalar to the key. + tweakScalar := new(btcec.ModNScalar) + tweakScalar.SetByteSlice([]byte{0x01, 0x02, 0x03}) + + // Without that ignore linting directive getting result 1 (error) is + // always nil. This is acceptable since it is used for convenient + // testing purposes. + // + //nolint:unparam + tweaker := func(privKey *btcec.PrivateKey) (*btcec.PrivateKey, error) { + // Add the tweak to the private key scalar. + var privKeyScalar btcec.ModNScalar + privKeyScalar.Set(&privKey.Key) + privKeyScalar.Add(tweakScalar) + + return btcec.PrivKeyFromScalar(&privKeyScalar), nil + } + + for i := 0; i <= maxGrowthIteration; i++ { + numInputs := inputGrowth[i] + + name := fmt.Sprintf("Inputs-%0*d", inputGrowthPadding, + numInputs) + + b.Run(name, func(b *testing.B) { + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: accountGrowth[i], + numAddresses: addressGrowth[i], + numWalletTxs: utxoGrowth[i], + }, + ) + + // Get a test address and create P2WKH outputs. + testAddr := getTestAddress( + b, bw.Wallet, accountGrowth[i], + ) + pkScript, err := txscript.PayToAddrScript(testAddr) + require.NoError(b, err) + + // Create multiple previous outputs. + prevOuts := make([]*wire.TxOut, numInputs) + for j := range numInputs { + prevOuts[j] = &wire.TxOut{ + Value: 100000, + PkScript: pkScript, + } + } + + // Create a spending transaction with multiple inputs. + tx := wire.NewMsgTx(2) + for j := range numInputs { + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{byte(j)}, + Index: 0, + }, + }) + } + + tx.AddTxOut(&wire.TxOut{ + Value: int64(numInputs) * 50000, + PkScript: pkScript, + }) + + // Pre-compute sigHashes. + fetcher := txscript.NewMultiPrevOutFetcher(nil) + for j, prevOut := range prevOuts { + fetcher.AddPrevOut(wire.OutPoint{ + Hash: chainhash.Hash{byte(j)}, + Index: 0, + }, prevOut) + } + + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + signMultipleInputsWithTweaker( + b, bw.Wallet, tx, prevOuts, sigHashes, + txscript.SigHashAll, tweaker, + ) + } + }) + } +} From 749f56a3e0d8d4caa6e7ab9cfcb2e8bf92f68d0f Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 11:52:13 +0000 Subject: [PATCH 186/691] wallet: benchmark `DifferentAddressTypes` --- wallet/signer_benchmark_test.go | 99 +++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/wallet/signer_benchmark_test.go b/wallet/signer_benchmark_test.go index e49ee6e022..9c40859ce7 100644 --- a/wallet/signer_benchmark_test.go +++ b/wallet/signer_benchmark_test.go @@ -1359,3 +1359,102 @@ func BenchmarkComputeUnlockingScriptWithTweaker(b *testing.B) { }) } } + +// BenchmarkDifferentAddressTypes compares signing performance across +// different address types (P2PKH, P2WKH, P2TR). +func BenchmarkDifferentAddressTypes(b *testing.B) { + const ( + numAccounts = 5 + numAddresses = 10 + ) + + testCases := []struct { + name string + scope waddrmgr.KeyScope + hashType txscript.SigHashType + }{ + { + name: "P2PKH-Legacy", + scope: waddrmgr.KeyScopeBIP0044, + hashType: txscript.SigHashAll, + }, + { + name: "P2WKH-SegWit", + scope: waddrmgr.KeyScopeBIP0084, + hashType: txscript.SigHashAll, + }, + { + name: "P2TR-Taproot", + scope: waddrmgr.KeyScopeBIP0086, + hashType: txscript.SigHashDefault, + }, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + scopes := []waddrmgr.KeyScope{tc.scope} + bw := setupBenchmarkWallet( + b, benchmarkWalletConfig{ + scopes: scopes, + numAccounts: numAccounts, + numAddresses: numAddresses, + numWalletTxs: 0, + }, + ) + + // Get a test address for this scope. + testAddr, err := bw.CurrentAddress(0, tc.scope) + if err != nil { + // Fallback to getting any address. + testAddr = getTestAddress( + b, bw.Wallet, numAccounts, + ) + } + + pkScript, err := txscript.PayToAddrScript(testAddr) + require.NoError(b, err) + + prevOut := &wire.TxOut{ + Value: 100000, + PkScript: pkScript, + } + + // Create a spending transaction. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{}, + Index: 0, + }, + }) + tx.AddTxOut(&wire.TxOut{ + Value: 50000, + PkScript: pkScript, + }) + + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + params := &UnlockingScriptParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: tc.hashType, + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + unlockScript, err := bw.ComputeUnlockingScript( + b.Context(), params, + ) + require.NoError(b, err) + require.NotNil(b, unlockScript) + } + }) + } +} From 470291e89871ec73449675a45f3f9c14bc18a69c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 27 Nov 2025 23:54:21 +0800 Subject: [PATCH 187/691] wallet: fix data race in TestComputeUnlockingScriptFail --- wallet/signer_test.go | 248 +++++++++++++++++++++++------------------- 1 file changed, 137 insertions(+), 111 deletions(-) diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 9287bb6edf..35b1c1a8fc 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -925,164 +925,190 @@ func TestComputeUnlockingScriptP2TR(t *testing.T) { require.Equal(t, byte(0), privKeyCopy.Serialize()[0]) } -// TestComputeUnlockingScriptFail tests various failure modes of -// ComputeUnlockingScript. -func TestComputeUnlockingScriptFail(t *testing.T) { +// TestComputeUnlockingScriptFail_ScriptForOutput tests failure when +// ScriptForOutput returns an error. +func TestComputeUnlockingScriptFail_ScriptForOutput(t *testing.T) { t.Parallel() - // Arrange: Set up common test data (keys, address, transaction) used - // across subtests. - privKey, pubKey := deterministicPrivKey(t) - - // Create P2PKH for testing. + // Arrange: Set up keys, address, and transaction. + _, pubKey := deterministicPrivKey(t) addr, err := btcutil.NewAddressPubKeyHash( btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, ) require.NoError(t, err) - pkScript, err := txscript.PayToAddrScript(addr) require.NoError(t, err) + // Create fresh mutable state. prevOut, tx := createDummyTestTx(pkScript) fetcher := txscript.NewCannedPrevOutputFetcher( prevOut.PkScript, prevOut.Value, ) - sigHashes := txscript.NewTxSigHashes(tx, fetcher) - t.Run("ScriptForOutput Fail", func(t *testing.T) { - t.Parallel() + // Arrange: Set up the wallet and mocks. + w, mocks := testWalletWithMocks(t) - // Arrange: Set up the wallet and mocks. - w, mocks := testWalletWithMocks(t) + // Mock the address store to return an error. + mocks.addrStore.On("Address", mock.Anything, addr). + Return((*mockManagedAddress)(nil), errManagerNotFound).Once() - // Mock the address store to return an error when looking up - // the address info. This simulates a case where the address is - // not found or there is a database error. - mocks.addrStore.On("Address", mock.Anything, addr). - Return((*mockManagedAddress)(nil), - errManagerNotFound).Once() + params := &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } - params := &UnlockingScriptParams{ - Tx: tx, - Output: prevOut, - SigHashes: sigHashes, - HashType: txscript.SigHashAll, - } + // Act: Attempt to compute the unlocking script. + _, err = w.ComputeUnlockingScript(t.Context(), params) - // Act: Attempt to compute the unlocking script. - _, err = w.ComputeUnlockingScript(t.Context(), params) + // Assert: Verify error. + require.ErrorContains(t, err, "unable to get address info") +} - // Assert: Verify that the error from the address store is - // wrapped and returned. - require.ErrorContains(t, err, "unable to get address info") - }) +// TestComputeUnlockingScriptFail_PrivKey tests failure when private key +// retrieval fails. +func TestComputeUnlockingScriptFail_PrivKey(t *testing.T) { + t.Parallel() - t.Run("PrivKey Fail", func(t *testing.T) { - t.Parallel() + // Arrange: Set up keys, address, and transaction. + _, pubKey := deterministicPrivKey(t) + addr, err := btcutil.NewAddressPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) - // Arrange: Set up the wallet and mocks. - w, mocks := testWalletWithMocks(t) + // Create fresh mutable state. + prevOut, tx := createDummyTestTx(pkScript) + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) - // Mock the address store to return a valid managed address. - mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + // Arrange: Set up the wallet and mocks. + w, mocks := testWalletWithMocks(t) - // Mock the managed address to return the correct type (P2PKH). - mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) + // Mock address store and managed address. + mocks.addrStore.On("Address", mock.Anything, addr). + Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) - // Mock the private key retrieval to fail. This simulates a - // case where the private key cannot be decrypted or found. - mocks.pubKeyAddr.On("PrivKey").Return((*btcec.PrivateKey)(nil), - errPrivKeyMock).Once() + // Mock private key retrieval failure. + mocks.pubKeyAddr.On("PrivKey").Return((*btcec.PrivateKey)(nil), + errPrivKeyMock).Once() - params := &UnlockingScriptParams{ - Tx: tx, - Output: prevOut, - SigHashes: sigHashes, - HashType: txscript.SigHashAll, - } + params := &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } - // Act: Attempt to compute the unlocking script. - _, err = w.ComputeUnlockingScript(t.Context(), params) + // Act: Attempt to compute the unlocking script. + _, err = w.ComputeUnlockingScript(t.Context(), params) - // Assert: Verify that the private key retrieval error is - // returned. - require.ErrorContains(t, err, "privkey error") - }) + // Assert: Verify error. + require.ErrorContains(t, err, "privkey error") +} - t.Run("Tweak Fail", func(t *testing.T) { - t.Parallel() +// TestComputeUnlockingScriptFail_Tweak tests failure when the tweaker fails. +func TestComputeUnlockingScriptFail_Tweak(t *testing.T) { + t.Parallel() - // Arrange: Set up the wallet and mocks. - w, mocks := testWalletWithMocks(t) + // Arrange: Set up keys, address, and transaction. + privKey, pubKey := deterministicPrivKey(t) + addr, err := btcutil.NewAddressPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) - // Mock the address store to return a valid managed address. - mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + // Create fresh mutable state. + prevOut, tx := createDummyTestTx(pkScript) + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) - // Mock the managed address to return the correct type (P2PKH). - mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) + // Arrange: Set up the wallet and mocks. + w, mocks := testWalletWithMocks(t) - privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + // Mock address store and managed address. + mocks.addrStore.On("Address", mock.Anything, addr). + Return(mocks.pubKeyAddr, nil).Once() + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) - // Mock the private key retrieval to succeed. - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() - // Define a custom tweaker function that always fails. - params := &UnlockingScriptParams{ - Tx: tx, - Output: prevOut, - SigHashes: sigHashes, - HashType: txscript.SigHashAll, - Tweaker: func(*btcec.PrivateKey) ( - *btcec.PrivateKey, error) { + // Define failing tweaker. + params := &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Tweaker: func(*btcec.PrivateKey) (*btcec.PrivateKey, error) { + return nil, errTweakMock + }, + } - return nil, errTweakMock - }, - } + // Act: Attempt to compute the unlocking script. + _, err = w.ComputeUnlockingScript(t.Context(), params) - // Act: Attempt to compute the unlocking script with the - // failing tweaker. - _, err = w.ComputeUnlockingScript(t.Context(), params) + // Assert: Verify error. + require.ErrorContains(t, err, "tweak error") +} - // Assert: Verify that the tweaker error is returned. - require.ErrorContains(t, err, "tweak error") - }) +// TestComputeUnlockingScriptFail_UnsupportedAddr tests failure when the +// address type is unsupported. +func TestComputeUnlockingScriptFail_UnsupportedAddr(t *testing.T) { + t.Parallel() - t.Run("Unsupported Address Type", func(t *testing.T) { - t.Parallel() + // Arrange: Set up keys, address, and transaction. + privKey, pubKey := deterministicPrivKey(t) + addr, err := btcutil.NewAddressPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) - // Arrange: Set up the wallet and mocks. - w, mocks := testWalletWithMocks(t) + // Create fresh mutable state. + prevOut, tx := createDummyTestTx(pkScript) + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) - // Mock the address store to return a valid managed address. - mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + // Arrange: Set up the wallet and mocks. + w, mocks := testWalletWithMocks(t) - privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + // Mock address store and managed address. + mocks.addrStore.On("Address", mock.Anything, addr). + Return(mocks.pubKeyAddr, nil).Once() - // Mock the private key retrieval to succeed. - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() - // Mock the address type to be one that is not supported for - // unlocking script generation (e.g., RawPubKey). - mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.RawPubKey) + // Mock unsupported address type. + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.RawPubKey) - params := &UnlockingScriptParams{ - Tx: tx, - Output: prevOut, - SigHashes: sigHashes, - HashType: txscript.SigHashAll, - } + params := &UnlockingScriptParams{ + Tx: tx, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } - // Act: Attempt to compute the unlocking script. - _, err = w.ComputeUnlockingScript(t.Context(), params) + // Act: Attempt to compute the unlocking script. + _, err = w.ComputeUnlockingScript(t.Context(), params) - // Assert: Verify that the unsupported address type error is - // returned. - require.ErrorIs(t, err, ErrUnsupportedAddressType) - }) + // Assert: Verify error. + require.ErrorIs(t, err, ErrUnsupportedAddressType) } // TestComputeUnlockingScriptUnknownAddrType tests the default case in From b7bf98ea916fc54cbd5c39b687b7eed02044f29d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 26 Nov 2025 20:29:54 +0800 Subject: [PATCH 188/691] wallet: add `PsbtManager` interface --- wallet/psbt_manager.go | 209 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 wallet/psbt_manager.go diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go new file mode 100644 index 0000000000..86676b52bc --- /dev/null +++ b/wallet/psbt_manager.go @@ -0,0 +1,209 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "context" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcwallet/pkg/btcunit" +) + +// FundIntent represents the user's intent for funding a PSBT. It serves as a +// blueprint for the FundPsbt method, bundling all the parameters required to +// construct a funded transaction into a single, coherent structure. +type FundIntent struct { + // Packet is the PSBT to be funded. It must contain the outputs to be + // funded. If inputs are also specified, the wallet will detect this and + // enter a "completion" mode, where it only adds a change output if + // necessary, rather than performing full coin selection. + Packet *psbt.Packet + + // Policy specifies the coin selection policy to use when funding the + // PSBT. This field is only used when the `Packet` has no inputs, + // indicating that automatic coin selection should be performed. If this + // policy is used, the `Source` (`*ScopedAccount`) must be fully + // specified with both `AccountName` and `KeyScope`, as the wallet will + // not perform any searches or guesses. If the `Packet` already + // contains inputs, this field is ignored. + Policy *InputsPolicy + + // FeeRate specifies the desired fee rate for the transaction, expressed + // in satoshis per kilo-virtual-byte (sat/kvb). This field is always + // required, regardless of whether coin selection is performed. + FeeRate btcunit.SatPerKVByte + + // ChangeSource specifies the account and key scope to use for the + // change output. If this field is nil, the wallet will use a default + // change source based on the account and scope of the inputs. + ChangeSource *ScopedAccount + + // Label is an optional, human-readable label for the transaction. This + // can be used to associate a memo with the transaction for later + // reference. + Label string +} + +// SignPsbtParams encapsulates the arguments for signing a PSBT. +type SignPsbtParams struct { + // Packet is the PSBT to be signed. + Packet *psbt.Packet + + // InputTweakers is a map of input indices to a private key tweaker. + // This allows the caller to define a specific tweaker for each input + // index. + // + // NOTE: The ideal implementation would be to add a new field to + // psbt.PInput that holds the tweaker, but that would require a change + // to the core psbt package in btcd. To keep btcwallet generic and avoid + // that dependency, we allow the caller (e.g. lnd) to inspect the PSBT + // beforehand, determine the necessary tweaks (e.g. based on custom + // fields like PsbtKeyTypeInputSignatureTweakSingle), and pass them in + // via this map. + InputTweakers map[int]PrivKeyTweaker +} + +// SignPsbtResult encapsulates the result of a PSBT signing operation. +type SignPsbtResult struct { + // SignedInputs contains the indices of the inputs that were + // successfully signed. + SignedInputs []uint32 + + // Packet is the modified PSBT packet. This is the same pointer as + // passed in the params, returned for convenience. + Packet *psbt.Packet +} + +// PsbtManager provides a cohesive, high-level interface for creating and +// managing Partially Signed Bitcoin Transactions (PSBTs). It encapsulates the +// entire workflow, from funding and decorating to signing and finalization, +// allowing users to construct complex transactions in a safe and predictable +// manner. +// +// The typical workflow for a single-signer transaction is as follows: +// +// 1. Create a bare PSBT: +// A stateless helper function, CreatePsbt, is used to construct a PSBT +// packet from a list of desired inputs and outputs. +// +// // The user specifies their desired outputs. +// outputs := []*wire.TxOut{{Value: 100000, PkScript: carolPkScript}} +// +// // A bare PSBT is created, representing the transaction template. +// barePacket, err := wallet.CreatePsbt(nil, outputs) +// +// 2. Fund the PSBT: +// The FundPsbt method is called to perform coin selection. The wallet selects +// UTXOs to cover the output value and fee, adds them as inputs, and adds a +// change output if necessary. +// +// fundIntent := &wallet.FundIntent{ +// Packet: barePacket, +// Policy: &wallet.InputsPolicy{ +// Source: &wallet.ScopedAccount{ +// AccountName: "default", +// KeyScope: waddrmgr.KeyScopeBIP0086, +// }, +// MinConfs: 1, +// }, +// FeeRate: btcunit.NewSatPerKVByte(250), +// } +// fundedPacket, changeIndex, err := psbtManager.FundPsbt( +// ctx, fundIntent, +// ) +// +// The `fundedPacket` now contains the necessary inputs (fully decorated) +// and a change output. The `changeIndex` indicates the index of the +// change output in the `fundedPacket.UnsignedTx.TxOut` slice, or -1 if +// no change output was added. +// +// 3. Sign the PSBT: +// The wallet signs all inputs it has the keys for. +// +// signParams := &wallet.SignPsbtParams{Packet: barePacket} +// result, err := psbtManager.SignPsbt(ctx, signParams) +// +// 4. Finalize the PSBT: +// The final scriptSig and/or witness for each input is constructed. +// +// err = psbtManager.FinalizePsbt(ctx, barePacket) +// +// 5. Extract and Broadcast: +// The final, network-ready transaction is extracted and broadcast. +// +// finalTx, err := psbt.Extract(barePacket) +// err = broadcaster.Broadcast(ctx, finalTx, "payment") +// +// For more detailed examples, including multi-party collaborative workflows, +// see the documentation in the `wallet/docs/psbt_workflows.md` file. +type PsbtManager interface { + // DecorateInputs enriches a PSBT's inputs with UTXO and derivation + // information known to the wallet. + // + // This is useful when importing a PSBT created externally (e.g., by a + // coordinator or another wallet) that only contains references to + // inputs (txids/indices) but lacks the necessary witness data and key + // derivation paths required for signing. + // + // If `skipUnknown` is true, the wallet will skip inputs it does not + // recognize; otherwise, it will return an error if any input is not + // found in the wallet's transaction store. + DecorateInputs(ctx context.Context, packet *psbt.Packet, + skipUnknown bool) (*psbt.Packet, error) + + // FundPsbt performs coin selection and adds the selected inputs (and a + // change output, if necessary) to the PSBT. + // + // It inspects the provided `FundIntent` to determine whether to + // perform automatic coin selection (if no inputs are present) or to + // validate and fund a specific set of manual inputs. + // + // The returned PSBT is a fully funded transaction template, ready for + // signing. The change output index is also returned (-1 if no change + // was added). + FundPsbt(ctx context.Context, intent *FundIntent) (*psbt.Packet, + int32, error) + + // SignPsbt adds partial signatures to the PSBT for all inputs + // controlled by the wallet. + // + // It iterates through the inputs, identifying those for which the + // wallet possesses the private key (based on derivation information), + // and appends a valid signature to the partial signature field. + // + // Note: This method is non-destructive; it adds signatures without + // finalizing the inputs, allowing for further signing in multi-party + // scenarios. It enforces a strict policy of one signature per input + // per call to avoid ambiguity in complex derivation paths. + SignPsbt(ctx context.Context, params *SignPsbtParams) ( + *SignPsbtResult, error) + + // FinalizePsbt attempts to finalize the PSBT, transitioning it from a + // partially signed state to a complete, network-ready transaction. + // + // It validates that all inputs have sufficient signatures to satisfy + // their spending scripts. If valid, it constructs the final + // `scriptSig` and `witness` fields and removes the partial signature + // data. + // + // Note: This implementation is "smart": if it detects an input owned + // by the wallet that is not yet signed, it will attempt to sign it + // internally before finalization. + FinalizePsbt(ctx context.Context, packet *psbt.Packet) error + + // CombinePsbt acts as the "Combiner" role in BIP 174, merging multiple + // Partially Signed Bitcoin Transactions (PSBTs) into a single packet. + // + // This is distinct from FinalizePsbt: CombinePsbt aggregates partial + // signatures and metadata from different signers (who signed copies of + // the same transaction in parallel), whereas FinalizePsbt uses those + // aggregated signatures to construct the final valid network + // transaction. + // + // This method is essential for collaborative workflows (e.g. Multisig, + // CoinJoin) where no single party holds all necessary keys. + CombinePsbt(ctx context.Context, psbts ...*psbt.Packet) ( + *psbt.Packet, error) +} From 300d3e3e1528faaf0d7581e215ffc043a350f041 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 15 Nov 2025 22:49:59 +0800 Subject: [PATCH 189/691] wallet: move deprecated code in `deprecated.go` A missing step from the previous utxo manager and tx creator refactor. --- wallet/deprecated.go | 296 ++++++++++++++++++++++++++++++++++++++++++- wallet/utxos.go | 257 ------------------------------------- wallet/utxos_test.go | 240 ----------------------------------- wallet/wallet.go | 51 -------- 4 files changed, 295 insertions(+), 549 deletions(-) delete mode 100644 wallet/utxos.go delete mode 100644 wallet/utxos_test.go diff --git a/wallet/deprecated.go b/wallet/deprecated.go index eeba33077f..595a640358 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -1,16 +1,21 @@ -//nolint:ll +//nolint:lll package wallet import ( "context" + "errors" "fmt" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" ) // NextAccount creates the next account and returns its account number. The @@ -235,3 +240,292 @@ func (w *Wallet) ComputeInputScript(tx *wire.MsgTx, output *wire.TxOut, return witnessScript, sigScript, nil } + +var ( + // ErrNotMine is an error denoting that a Wallet instance is unable to + // spend a specified output. + ErrNotMine = errors.New("the passed output does not belong to the " + + "wallet") +) + +// OutputSelectionPolicy describes the rules for selecting an output from the +// wallet. +type OutputSelectionPolicy struct { + Account uint32 + RequiredConfirmations int32 +} + +func (p *OutputSelectionPolicy) meetsRequiredConfs(txHeight, + curHeight int32) bool { + + return hasMinConfs( + //nolint:gosec + uint32(p.RequiredConfirmations), txHeight, curHeight, + ) +} + +// UnspentOutputs fetches all unspent outputs from the wallet that match rules +// described in the passed policy. +func (w *Wallet) UnspentOutputs(policy OutputSelectionPolicy) ([]*TransactionOutput, error) { + var outputResults []*TransactionOutput + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + syncBlock := w.addrStore.SyncedTo() + + // TODO: actually stream outputs from the db instead of fetching + // all of them at once. + outputs, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + + for _, output := range outputs { + // Ignore outputs that haven't reached the required + // number of confirmations. + if !policy.meetsRequiredConfs(output.Height, syncBlock.Height) { + continue + } + + // Ignore outputs that are not controlled by the account. + _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, + w.chainParams) + if err != nil || len(addrs) == 0 { + // Cannot determine which account this belongs + // to without a valid address. TODO: Fix this + // by saving outputs per account, or accounts + // per output. + continue + } + + _, outputAcct, err := w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) + if err != nil { + return err + } + if outputAcct != policy.Account { + continue + } + + // Stakebase isn't exposed by wtxmgr so those will be + // OutputKindNormal for now. + outputSource := OutputKindNormal + if output.FromCoinBase { + outputSource = OutputKindCoinbase + } + + result := &TransactionOutput{ + OutPoint: output.OutPoint, + Output: wire.TxOut{ + Value: int64(output.Amount), + PkScript: output.PkScript, + }, + OutputKind: outputSource, + ContainingBlock: BlockIdentity(output.Block), + ReceiveTime: output.Received, + } + outputResults = append(outputResults, result) + } + + return nil + }) + return outputResults, err +} + +// FetchInputInfo queries for the wallet's knowledge of the passed outpoint. If +// the wallet determines this output is under its control, then the original +// full transaction, the target txout, the derivation info and the number of +// confirmations are returned. Otherwise, a non-nil error value of ErrNotMine +// is returned instead. +// +// NOTE: This method is kept for compatibility. +func (w *Wallet) FetchInputInfo(prevOut *wire.OutPoint) (*wire.MsgTx, + *wire.TxOut, *psbt.Bip32Derivation, int64, error) { + + tx, txOut, confs, err := w.FetchOutpointInfo(prevOut) + if err != nil { + return nil, nil, nil, 0, err + } + + derivation, err := w.FetchDerivationInfo(txOut.PkScript) + if err != nil { + return nil, nil, nil, 0, err + } + + return tx, txOut, derivation, confs, nil +} + +// fetchOutputAddr attempts to fetch the managed address corresponding to the +// passed output script. This function is used to look up the proper key which +// should be used to sign a specified input. +func (w *Wallet) fetchOutputAddr(script []byte) (waddrmgr.ManagedAddress, error) { + _, addrs, _, err := txscript.ExtractPkScriptAddrs(script, w.chainParams) + if err != nil { + return nil, err + } + + // If the case of a multi-sig output, several address may be extracted. + // Therefore, we simply select the key for the first address we know + // of. + for _, addr := range addrs { + addr, err := w.AddressInfoDeprecated(addr) + if err == nil { + return addr, nil + } + } + + return nil, ErrNotMine +} + +// FetchOutpointInfo queries for the wallet's knowledge of the passed outpoint. +// If the wallet determines this output is under its control, the original full +// transaction, the target txout and the number of confirmations are returned. +// Otherwise, a non-nil error value of ErrNotMine is returned instead. +func (w *Wallet) FetchOutpointInfo(prevOut *wire.OutPoint) (*wire.MsgTx, + *wire.TxOut, int64, error) { + + // We manually look up the output within the tx store. + txid := &prevOut.Hash + txDetail, err := UnstableAPI(w).TxDetails(txid) + if err != nil { + return nil, nil, 0, err + } else if txDetail == nil { + return nil, nil, 0, ErrNotMine + } + + // With the output retrieved, we'll make an additional check to ensure + // we actually have control of this output. We do this because the + // check above only guarantees that the transaction is somehow relevant + // to us, like in the event of us being the sender of the transaction. + numOutputs := uint32(len(txDetail.TxRecord.MsgTx.TxOut)) + if prevOut.Index >= numOutputs { + return nil, nil, 0, fmt.Errorf("invalid output index %v for "+ + "transaction with %v outputs", prevOut.Index, + numOutputs) + } + + // Exit early if the output doesn't belong to our wallet. We know it's + // our UTXO iff the `TxDetails` has a credit record on this output. + if !hasOutput(txDetail, prevOut.Index) { + return nil, nil, 0, ErrNotMine + } + + pkScript := txDetail.TxRecord.MsgTx.TxOut[prevOut.Index].PkScript + + // Determine the number of confirmations the output currently has. + _, currentHeight, err := w.chainClient.GetBestBlock() + if err != nil { + return nil, nil, 0, fmt.Errorf("unable to retrieve current "+ + "height: %w", err) + } + + confs := int64(0) + if txDetail.Block.Height != -1 { + confs = int64(currentHeight - txDetail.Block.Height) + } + + return &txDetail.TxRecord.MsgTx, &wire.TxOut{ + Value: txDetail.TxRecord.MsgTx.TxOut[prevOut.Index].Value, + PkScript: pkScript, + }, confs, nil +} + +// FetchDerivationInfo queries for the wallet's knowledge of the passed +// pkScript and constructs the derivation info and returns it. +func (w *Wallet) FetchDerivationInfo(pkScript []byte) (*psbt.Bip32Derivation, + error) { + + addr, err := w.fetchOutputAddr(pkScript) + if err != nil { + return nil, err + } + + pubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil, ErrNotMine + } + keyScope, derivationPath, _ := pubKeyAddr.DerivationInfo() + + derivation := &psbt.Bip32Derivation{ + PubKey: pubKeyAddr.PubKey().SerializeCompressed(), + MasterKeyFingerprint: derivationPath.MasterKeyFingerprint, + Bip32Path: []uint32{ + keyScope.Purpose + hdkeychain.HardenedKeyStart, + keyScope.Coin + hdkeychain.HardenedKeyStart, + derivationPath.Account, + derivationPath.Branch, + derivationPath.Index, + }, + } + + return derivation, nil +} + +// hasOutpoint takes an output identified by its output index and determines +// whether the TxDetails contains this output. If the TxDetails doesn't have +// this output, it means this output doesn't belong to our wallet. +// +// TODO(yy): implement this method on `TxDetails` and update the package +// `wtxmgr` instead. +func hasOutput(t *wtxmgr.TxDetails, outputIndex uint32) bool { + for _, cred := range t.Credits { + if outputIndex == cred.Index { + return true + } + } + + return false +} + +// CreateSimpleTx creates a new signed transaction spending unspent outputs with +// at least minconf confirmations spending to any number of address/amount +// pairs. Only unspent outputs belonging to the given key scope and account will +// be selected, unless a key scope is not specified. In that case, inputs from all +// accounts may be selected, no matter what key scope they belong to. This is +// done to handle the default account case, where a user wants to fund a PSBT +// with inputs regardless of their type (NP2WKH, P2WKH, etc.). Change and an +// appropriate transaction fee are automatically included, if necessary. All +// transaction creation through this function is serialized to prevent the +// creation of many transactions which spend the same outputs. +// +// A set of functional options can be passed in to apply modifications to the +// tx creation process such as using a custom change scope, which otherwise +// defaults to the same as the specified coin selection scope. +// +// NOTE: The dryRun argument can be set true to create a tx that doesn't alter +// the database. A tx created with this set to true SHOULD NOT be broadcast. +func (w *Wallet) CreateSimpleTx(coinSelectKeyScope *waddrmgr.KeyScope, + account uint32, outputs []*wire.TxOut, minconf int32, + satPerKb btcutil.Amount, coinSelectionStrategy CoinSelectionStrategy, + dryRun bool, optFuncs ...TxCreateOption) (*txauthor.AuthoredTx, error) { + + opts := defaultTxCreateOptions() + for _, optFunc := range optFuncs { + optFunc(opts) + } + + // If the change scope isn't set, then it should be the same as the + // coin selection scope in order to match existing behavior. + if opts.changeKeyScope == nil { + opts.changeKeyScope = coinSelectKeyScope + } + + req := createTxRequest{ + coinSelectKeyScope: coinSelectKeyScope, + changeKeyScope: opts.changeKeyScope, + account: account, + outputs: outputs, + minconf: minconf, + feeSatPerKB: satPerKb, + coinSelectionStrategy: coinSelectionStrategy, + dryRun: dryRun, + resp: make(chan createTxResponse), + selectUtxos: opts.selectUtxos, + allowUtxo: opts.allowUtxo, + } + w.createTxRequests <- req + resp := <-req.resp + return resp.tx, resp.err +} diff --git a/wallet/utxos.go b/wallet/utxos.go deleted file mode 100644 index 806442a2b2..0000000000 --- a/wallet/utxos.go +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright (c) 2016 The Decred developers -// Copyright (c) 2017 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "errors" - "fmt" - - "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/walletdb" - "github.com/btcsuite/btcwallet/wtxmgr" -) - -var ( - // ErrNotMine is an error denoting that a Wallet instance is unable to - // spend a specified output. - ErrNotMine = errors.New("the passed output does not belong to the " + - "wallet") -) - -// OutputSelectionPolicy describes the rules for selecting an output from the -// wallet. -type OutputSelectionPolicy struct { - Account uint32 - RequiredConfirmations int32 -} - -func (p *OutputSelectionPolicy) meetsRequiredConfs(txHeight, - curHeight int32) bool { - - return hasMinConfs( - //nolint:gosec - uint32(p.RequiredConfirmations), txHeight, curHeight, - ) -} - -// UnspentOutputs fetches all unspent outputs from the wallet that match rules -// described in the passed policy. -func (w *Wallet) UnspentOutputs(policy OutputSelectionPolicy) ([]*TransactionOutput, error) { - var outputResults []*TransactionOutput - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - syncBlock := w.addrStore.SyncedTo() - - // TODO: actually stream outputs from the db instead of fetching - // all of them at once. - outputs, err := w.txStore.UnspentOutputs(txmgrNs) - if err != nil { - return err - } - - for _, output := range outputs { - // Ignore outputs that haven't reached the required - // number of confirmations. - if !policy.meetsRequiredConfs(output.Height, syncBlock.Height) { - continue - } - - // Ignore outputs that are not controlled by the account. - _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, - w.chainParams) - if err != nil || len(addrs) == 0 { - // Cannot determine which account this belongs - // to without a valid address. TODO: Fix this - // by saving outputs per account, or accounts - // per output. - continue - } - - _, outputAcct, err := w.addrStore.AddrAccount( - addrmgrNs, addrs[0], - ) - if err != nil { - return err - } - if outputAcct != policy.Account { - continue - } - - // Stakebase isn't exposed by wtxmgr so those will be - // OutputKindNormal for now. - outputSource := OutputKindNormal - if output.FromCoinBase { - outputSource = OutputKindCoinbase - } - - result := &TransactionOutput{ - OutPoint: output.OutPoint, - Output: wire.TxOut{ - Value: int64(output.Amount), - PkScript: output.PkScript, - }, - OutputKind: outputSource, - ContainingBlock: BlockIdentity(output.Block), - ReceiveTime: output.Received, - } - outputResults = append(outputResults, result) - } - - return nil - }) - return outputResults, err -} - -// FetchInputInfo queries for the wallet's knowledge of the passed outpoint. If -// the wallet determines this output is under its control, then the original -// full transaction, the target txout, the derivation info and the number of -// confirmations are returned. Otherwise, a non-nil error value of ErrNotMine -// is returned instead. -// -// NOTE: This method is kept for compatibility. -func (w *Wallet) FetchInputInfo(prevOut *wire.OutPoint) (*wire.MsgTx, - *wire.TxOut, *psbt.Bip32Derivation, int64, error) { - - tx, txOut, confs, err := w.FetchOutpointInfo(prevOut) - if err != nil { - return nil, nil, nil, 0, err - } - - derivation, err := w.FetchDerivationInfo(txOut.PkScript) - if err != nil { - return nil, nil, nil, 0, err - } - - return tx, txOut, derivation, confs, nil -} - -// fetchOutputAddr attempts to fetch the managed address corresponding to the -// passed output script. This function is used to look up the proper key which -// should be used to sign a specified input. -func (w *Wallet) fetchOutputAddr(script []byte) (waddrmgr.ManagedAddress, error) { - _, addrs, _, err := txscript.ExtractPkScriptAddrs(script, w.chainParams) - if err != nil { - return nil, err - } - - // If the case of a multi-sig output, several address may be extracted. - // Therefore, we simply select the key for the first address we know - // of. - for _, addr := range addrs { - addr, err := w.AddressInfoDeprecated(addr) - if err == nil { - return addr, nil - } - } - - return nil, ErrNotMine -} - -// FetchOutpointInfo queries for the wallet's knowledge of the passed outpoint. -// If the wallet determines this output is under its control, the original full -// transaction, the target txout and the number of confirmations are returned. -// Otherwise, a non-nil error value of ErrNotMine is returned instead. -func (w *Wallet) FetchOutpointInfo(prevOut *wire.OutPoint) (*wire.MsgTx, - *wire.TxOut, int64, error) { - - // We manually look up the output within the tx store. - txid := &prevOut.Hash - txDetail, err := UnstableAPI(w).TxDetails(txid) - if err != nil { - return nil, nil, 0, err - } else if txDetail == nil { - return nil, nil, 0, ErrNotMine - } - - // With the output retrieved, we'll make an additional check to ensure - // we actually have control of this output. We do this because the - // check above only guarantees that the transaction is somehow relevant - // to us, like in the event of us being the sender of the transaction. - numOutputs := uint32(len(txDetail.TxRecord.MsgTx.TxOut)) - if prevOut.Index >= numOutputs { - return nil, nil, 0, fmt.Errorf("invalid output index %v for "+ - "transaction with %v outputs", prevOut.Index, - numOutputs) - } - - // Exit early if the output doesn't belong to our wallet. We know it's - // our UTXO iff the `TxDetails` has a credit record on this output. - if !hasOutput(txDetail, prevOut.Index) { - return nil, nil, 0, ErrNotMine - } - - pkScript := txDetail.TxRecord.MsgTx.TxOut[prevOut.Index].PkScript - - // Determine the number of confirmations the output currently has. - _, currentHeight, err := w.chainClient.GetBestBlock() - if err != nil { - return nil, nil, 0, fmt.Errorf("unable to retrieve current "+ - "height: %w", err) - } - - confs := int64(0) - if txDetail.Block.Height != -1 { - confs = int64(currentHeight - txDetail.Block.Height) - } - - return &txDetail.TxRecord.MsgTx, &wire.TxOut{ - Value: txDetail.TxRecord.MsgTx.TxOut[prevOut.Index].Value, - PkScript: pkScript, - }, confs, nil -} - -// FetchDerivationInfo queries for the wallet's knowledge of the passed -// pkScript and constructs the derivation info and returns it. -func (w *Wallet) FetchDerivationInfo(pkScript []byte) (*psbt.Bip32Derivation, - error) { - - addr, err := w.fetchOutputAddr(pkScript) - if err != nil { - return nil, err - } - - pubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return nil, ErrNotMine - } - keyScope, derivationPath, _ := pubKeyAddr.DerivationInfo() - - derivation := &psbt.Bip32Derivation{ - PubKey: pubKeyAddr.PubKey().SerializeCompressed(), - MasterKeyFingerprint: derivationPath.MasterKeyFingerprint, - Bip32Path: []uint32{ - keyScope.Purpose + hdkeychain.HardenedKeyStart, - keyScope.Coin + hdkeychain.HardenedKeyStart, - derivationPath.Account, - derivationPath.Branch, - derivationPath.Index, - }, - } - - return derivation, nil -} - -// hasOutpoint takes an output identified by its output index and determines -// whether the TxDetails contains this output. If the TxDetails doesn't have -// this output, it means this output doesn't belong to our wallet. -// -// TODO(yy): implement this method on `TxDetails` and update the package -// `wtxmgr` instead. -func hasOutput(t *wtxmgr.TxDetails, outputIndex uint32) bool { - for _, cred := range t.Credits { - if outputIndex == cred.Index { - return true - } - } - - return false -} diff --git a/wallet/utxos_test.go b/wallet/utxos_test.go deleted file mode 100644 index 17451ddf49..0000000000 --- a/wallet/utxos_test.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) 2020 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "bytes" - "testing" - - "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/stretchr/testify/require" -) - -// TestFetchInputInfo checks that the wallet can gather information about an -// output based on the address. -func TestFetchInputInfo(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // Create an address we can use to send some coins to. - addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) - if err != nil { - t.Fatalf("unable to get current address: %v", addr) - } - p2shAddr, err := txscript.PayToAddrScript(addr) - if err != nil { - t.Fatalf("unable to convert wallet address to p2sh: %v", err) - } - - // Add an output paying to the wallet's address to the database. - utxOut := wire.NewTxOut(100000, p2shAddr) - incomingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{{}}, - TxOut: []*wire.TxOut{utxOut}, - } - addUtxo(t, w, incomingTx) - - // Look up the UTXO for the outpoint now and compare it to our - // expectations. - prevOut := &wire.OutPoint{ - Hash: incomingTx.TxHash(), - Index: 0, - } - tx, out, derivationPath, confirmations, err := w.FetchInputInfo(prevOut) - if err != nil { - t.Fatalf("error fetching input info: %v", err) - } - if !bytes.Equal(out.PkScript, utxOut.PkScript) || out.Value != utxOut.Value { - t.Fatalf("unexpected TX out, got %v wanted %v", out, utxOut) - } - if !bytes.Equal(tx.TxOut[prevOut.Index].PkScript, utxOut.PkScript) { - t.Fatalf("unexpected TX out, got %v wanted %v", - tx.TxOut[prevOut.Index].PkScript, utxOut) - } - if len(derivationPath.Bip32Path) != 5 { - t.Fatalf("expected derivation path of length %v, got %v", 3, - len(derivationPath.Bip32Path)) - } - if derivationPath.Bip32Path[0] != - waddrmgr.KeyScopeBIP0084.Purpose+hdkeychain.HardenedKeyStart { - t.Fatalf("expected purpose %v, got %v", - waddrmgr.KeyScopeBIP0084.Purpose, - derivationPath.Bip32Path[0]) - } - if derivationPath.Bip32Path[1] != - waddrmgr.KeyScopeBIP0084.Coin+hdkeychain.HardenedKeyStart { - t.Fatalf("expected coin type %v, got %v", - waddrmgr.KeyScopeBIP0084.Coin, - derivationPath.Bip32Path[1]) - } - if derivationPath.Bip32Path[2] != hdkeychain.HardenedKeyStart { - t.Fatalf("expected account %v, got %v", - hdkeychain.HardenedKeyStart, derivationPath.Bip32Path[2]) - } - if derivationPath.Bip32Path[3] != 0 { - t.Fatalf("expected branch %v, got %v", 0, - derivationPath.Bip32Path[3]) - } - if derivationPath.Bip32Path[4] != 0 { - t.Fatalf("expected index %v, got %v", 0, - derivationPath.Bip32Path[4]) - } - if confirmations != int64(0-testBlockHeight) { - t.Fatalf("unexpected number of confirmations, got %d wanted %d", - confirmations, 0-testBlockHeight) - } -} - -// TestFetchOutpointInfo checks that the wallet can gather information about an -// output based on the outpoint. -func TestFetchOutpointInfo(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // Create an address we can use to send some coins to. - addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) - require.NoError(t, err) - p2shAddr, err := txscript.PayToAddrScript(addr) - require.NoError(t, err) - - // Add an output paying to the wallet's address to the database. - utxOut := wire.NewTxOut(100000, p2shAddr) - incomingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{{}}, - TxOut: []*wire.TxOut{utxOut}, - } - addUtxo(t, w, incomingTx) - - // Look up the UTXO for the outpoint now and compare it to our - // expectations. - prevOut := &wire.OutPoint{ - Hash: incomingTx.TxHash(), - Index: 0, - } - tx, out, confirmations, err := w.FetchOutpointInfo(prevOut) - require.NoError(t, err) - - require.Equal(t, utxOut.PkScript, out.PkScript) - require.Equal(t, utxOut.Value, out.Value) - require.Equal(t, utxOut.PkScript, tx.TxOut[prevOut.Index].PkScript) - require.Equal(t, int64(0-testBlockHeight), confirmations) -} - -// TestFetchOutpointInfoErr checks when the wallet cannot find an output, a -// proper error is returned. -func TestFetchOutpointInfoErr(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // Create an address we can use to send some coins to. - addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) - require.NoError(t, err) - p2shAddr, err := txscript.PayToAddrScript(addr) - require.NoError(t, err) - - // Create a tx that has two outputs - output1 belongs to the wallet, - // output2 is external. - output1 := wire.NewTxOut(100000, p2shAddr) - output2 := wire.NewTxOut(100000, p2shAddr) - tx := &wire.MsgTx{ - TxIn: []*wire.TxIn{{}}, - TxOut: []*wire.TxOut{ - output1, - output2, - }, - } - - // Add the tx and its first output as the credit. - addTxAndCredit(t, w, tx, 0) - - testCases := []struct { - name string - prevOut *wire.OutPoint - - // TODO(yy): refator `FetchOutpointInfo` to return wrapped - // errors. - errExpected string - }{ - { - name: "no tx details", - prevOut: &wire.OutPoint{ - Hash: chainhash.Hash{1, 2, 3}, - Index: 0, - }, - errExpected: "does not belong to the wallet", - }, - { - name: "invalid output index", - prevOut: &wire.OutPoint{ - Hash: tx.TxHash(), - Index: 1000, - }, - errExpected: "invalid output index", - }, - { - name: "no credit found", - prevOut: &wire.OutPoint{ - Hash: tx.TxHash(), - Index: 1, - }, - errExpected: "does not belong to the wallet", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Look up the UTXO for the outpoint now and compare it - // to the expected error. - tx, out, conf, err := w.FetchOutpointInfo(tc.prevOut) - require.ErrorContains(t, err, tc.errExpected) - require.Nil(t, tx) - require.Nil(t, out) - require.Zero(t, conf) - }) - } -} - -// TestFetchDerivationInfo checks that the wallet can gather the derivation -// info about an output based on the pkScript. -func TestFetchDerivationInfo(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // Create an address we can use to send some coins to. - addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) - require.NoError(t, err) - p2shAddr, err := txscript.PayToAddrScript(addr) - require.NoError(t, err) - - // Add an output paying to the wallet's address to the database. - utxOut := wire.NewTxOut(100000, p2shAddr) - incomingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{{}}, - TxOut: []*wire.TxOut{utxOut}, - } - addUtxo(t, w, incomingTx) - - info, err := w.FetchDerivationInfo(utxOut.PkScript) - require.NoError(t, err) - - require.Len(t, info.Bip32Path, 5) - require.Equal(t, waddrmgr.KeyScopeBIP0084.Purpose+ - hdkeychain.HardenedKeyStart, info.Bip32Path[0]) - require.Equal(t, waddrmgr.KeyScopeBIP0084.Coin+ - hdkeychain.HardenedKeyStart, info.Bip32Path[1]) - require.EqualValues(t, hdkeychain.HardenedKeyStart, info.Bip32Path[2]) - require.Equal(t, uint32(0), info.Bip32Path[3]) - require.Equal(t, uint32(0), info.Bip32Path[4]) -} diff --git a/wallet/wallet.go b/wallet/wallet.go index 559efc25f8..d972a9bac3 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1334,57 +1334,6 @@ func WithUtxoFilter(allowUtxo func(utxo wtxmgr.Credit) bool) TxCreateOption { } } -// CreateSimpleTx creates a new signed transaction spending unspent outputs with -// at least minconf confirmations spending to any number of address/amount -// pairs. Only unspent outputs belonging to the given key scope and account will -// be selected, unless a key scope is not specified. In that case, inputs from all -// accounts may be selected, no matter what key scope they belong to. This is -// done to handle the default account case, where a user wants to fund a PSBT -// with inputs regardless of their type (NP2WKH, P2WKH, etc.). Change and an -// appropriate transaction fee are automatically included, if necessary. All -// transaction creation through this function is serialized to prevent the -// creation of many transactions which spend the same outputs. -// -// A set of functional options can be passed in to apply modifications to the -// tx creation process such as using a custom change scope, which otherwise -// defaults to the same as the specified coin selection scope. -// -// NOTE: The dryRun argument can be set true to create a tx that doesn't alter -// the database. A tx created with this set to true SHOULD NOT be broadcast. -func (w *Wallet) CreateSimpleTx(coinSelectKeyScope *waddrmgr.KeyScope, - account uint32, outputs []*wire.TxOut, minconf int32, - satPerKb btcutil.Amount, coinSelectionStrategy CoinSelectionStrategy, - dryRun bool, optFuncs ...TxCreateOption) (*txauthor.AuthoredTx, error) { - - opts := defaultTxCreateOptions() - for _, optFunc := range optFuncs { - optFunc(opts) - } - - // If the change scope isn't set, then it should be the same as the - // coin selection scope in order to match existing behavior. - if opts.changeKeyScope == nil { - opts.changeKeyScope = coinSelectKeyScope - } - - req := createTxRequest{ - coinSelectKeyScope: coinSelectKeyScope, - changeKeyScope: opts.changeKeyScope, - account: account, - outputs: outputs, - minconf: minconf, - feeSatPerKB: satPerKb, - coinSelectionStrategy: coinSelectionStrategy, - dryRun: dryRun, - resp: make(chan createTxResponse), - selectUtxos: opts.selectUtxos, - allowUtxo: opts.allowUtxo, - } - w.createTxRequests <- req - resp := <-req.resp - return resp.tx, resp.err -} - type ( unlockRequest struct { passphrase []byte From fae67187e9affc2e5b48feed05b32e637b8d862d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 17 Nov 2025 20:09:28 +0800 Subject: [PATCH 190/691] wallet: deprecate existing PSBT methods --- wallet/interface.go | 18 +++++++++--------- wallet/psbt.go | 24 ++++++++++++------------ wallet/psbt_test.go | 4 ++-- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/wallet/interface.go b/wallet/interface.go index a45be9d6b7..7b0216b4d3 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -293,21 +293,21 @@ type Interface interface { // PublishTransaction broadcasts a transaction to the network. PublishTransaction(tx *wire.MsgTx, label string) error - // FundPsbt creates a PSBT with enough inputs to fund the specified - // outputs, adding a change output if necessary. - FundPsbt(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, + // FundPsbtDeprecated creates a PSBT with enough inputs to fund the + // specified outputs, adding a change output if necessary. + FundPsbtDeprecated(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, minConfs int32, account uint32, feeSatPerKB btcutil.Amount, strategy CoinSelectionStrategy, optFuncs ...TxCreateOption) (int32, error) - // FinalizePsbt signs and finalizes a PSBT, making it ready for - // broadcast. The wallet must be the last signer. - FinalizePsbt(keyScope *waddrmgr.KeyScope, account uint32, + // FinalizePsbtDeprecated signs and finalizes a PSBT, making it ready + // for broadcast. The wallet must be the last signer. + FinalizePsbtDeprecated(keyScope *waddrmgr.KeyScope, account uint32, packet *psbt.Packet) error - // DecorateInputs decorates the inputs of a PSBT with the necessary - // information to sign it. - DecorateInputs(packet *psbt.Packet, failOnUnknown bool) error + // DecorateInputsDeprecated decorates the inputs of a PSBT with the + // necessary information to sign it. + DecorateInputsDeprecated(packet *psbt.Packet, failOnUnknown bool) error // GetTransaction returns the details for a transaction given its hash. GetTransaction(txHash chainhash.Hash) (*GetTransactionResult, error) diff --git a/wallet/psbt.go b/wallet/psbt.go index b51d0deb1b..f1480ab696 100644 --- a/wallet/psbt.go +++ b/wallet/psbt.go @@ -21,12 +21,12 @@ import ( "github.com/btcsuite/btcwallet/wtxmgr" ) -// FundPsbt creates a fully populated PSBT packet that contains enough inputs to -// fund the outputs specified in the passed in packet with the specified fee -// rate. If there is change left, a change output from the wallet is added and -// the index of the change output is returned. If no custom change scope is -// specified, we will use the coin selection scope (if not nil) or the BIP0086 -// scope by default. Otherwise, no additional output is created and the +// FundPsbtDeprecated creates a fully populated PSBT packet that contains +// enough inputs to fund the outputs specified in the passed in packet with the +// specified fee rate. If there is change left, a change output from the wallet +// is added and the index of the change output is returned. If no custom change +// scope is specified, we will use the coin selection scope (if not nil) or the +// BIP0086 scope by default. Otherwise, no additional output is created and the // index -1 is returned. // // NOTE: If the packet doesn't contain any inputs, coin selection is performed @@ -44,7 +44,7 @@ import ( // the wallet. However, no UTXO specific lock lease is acquired for any of the // selected/validated inputs by this method. It is in the caller's // responsibility to lock the inputs before handing the partial transaction out. -func (w *Wallet) FundPsbt(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, +func (w *Wallet) FundPsbtDeprecated(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, minConfs int32, account uint32, feeSatPerKB btcutil.Amount, coinSelectionStrategy CoinSelectionStrategy, optFuncs ...TxCreateOption) (int32, error) { @@ -114,7 +114,7 @@ func (w *Wallet) FundPsbt(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, packet.UnsignedTx.TxIn[idx].SignatureScript = nil } - err := w.DecorateInputs(packet, true) + err := w.DecorateInputsDeprecated(packet, true) if err != nil { return 0, err } @@ -132,7 +132,7 @@ func (w *Wallet) FundPsbt(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, packet.UnsignedTx.TxIn[idx].SignatureScript = nil } - err := w.DecorateInputs(packet, true) + err := w.DecorateInputsDeprecated(packet, true) if err != nil { return 0, err } @@ -255,11 +255,11 @@ func (w *Wallet) FundPsbt(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, return changeIndex, nil } -// DecorateInputs fetches the UTXO information of all inputs it can identify and +// DecorateInputsDeprecated fetches the UTXO information of all inputs it can identify and // adds the required information to the package's inputs. The failOnUnknown // boolean controls whether the method should return an error if it cannot // identify an input or if it should just skip it. -func (w *Wallet) DecorateInputs(packet *psbt.Packet, failOnUnknown bool) error { +func (w *Wallet) DecorateInputsDeprecated(packet *psbt.Packet, failOnUnknown bool) error { for idx := range packet.Inputs { txIn := packet.UnsignedTx.TxIn[idx] @@ -417,7 +417,7 @@ func createOutputInfo(txOut *wire.TxOut, // // NOTE: This method does NOT publish the transaction after it's been finalized // successfully. -func (w *Wallet) FinalizePsbt(keyScope *waddrmgr.KeyScope, account uint32, +func (w *Wallet) FinalizePsbtDeprecated(keyScope *waddrmgr.KeyScope, account uint32, packet *psbt.Packet) error { // Let's check that this is actually something we can and want to sign. diff --git a/wallet/psbt_test.go b/wallet/psbt_test.go index 98b0255051..67994f99c7 100644 --- a/wallet/psbt_test.go +++ b/wallet/psbt_test.go @@ -277,7 +277,7 @@ func TestFundPsbt(t *testing.T) { for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { - changeIndex, err := w.FundPsbt( + changeIndex, err := w.FundPsbtDeprecated( tc.packet, nil, 1, 0, tc.feeRateSatPerKB, CoinSelectionLargest, WithCustomChangeScope(tc.changeKeyScope), @@ -496,7 +496,7 @@ func TestFinalizePsbt(t *testing.T) { } // Finalize it to add all witness data then extract the final TX. - err = w.FinalizePsbt(nil, 0, packet) + err = w.FinalizePsbtDeprecated(nil, 0, packet) if err != nil { t.Fatalf("error finalizing PSBT packet: %v", err) } From b8e5c63fb01384454370735362d6cd9fda903359 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 18 Nov 2025 05:06:26 +0800 Subject: [PATCH 191/691] wallet: refactor `AddressManager` to extract the common helpers These methods will be used in the `PsbtManager`. --- wallet/address_manager.go | 54 ++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 63f6bbd95c..163b0d6d9c 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -19,6 +19,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -719,15 +720,34 @@ func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( ErrNotPubKeyAddress, managedAddr.Address()) } + witnessProgram, redeemScript, err := buildScriptsForManagedAddress( + pubKeyAddr, output.PkScript, w.chainParams, + ) + if err != nil { + return Script{}, err + } + + return Script{ + Addr: managedAddr, + WitnessProgram: witnessProgram, + RedeemScript: redeemScript, + }, nil +} + +// buildScriptsForManagedAddress constructs the witness and redeem scripts for a +// given managed public key address and its corresponding pkScript. +func buildScriptsForManagedAddress(pubKeyAddr waddrmgr.ManagedPubKeyAddress, + pkScript []byte, chainParams *chaincfg.Params) ([]byte, []byte, error) { + var ( witnessProgram []byte - sigScript []byte + redeemScript []byte ) switch { // If we're spending p2wkh output nested within a p2sh output, then // we'll need to attach a sigScript in addition to witness data. - case managedAddr.AddrType() == waddrmgr.NestedWitnessPubKey: + case pubKeyAddr.AddrType() == waddrmgr.NestedWitnessPubKey: pubKey := pubKeyAddr.PubKey() pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) @@ -736,23 +756,23 @@ func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( // single push of the p2wkh witness program corresponding to // the matching public key of this address. p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( - pubKeyHash, w.chainParams, + pubKeyHash, chainParams, ) if err != nil { - return Script{}, err + return nil, nil, err } witnessProgram, err = txscript.PayToAddrScript(p2wkhAddr) if err != nil { - return Script{}, err + return nil, nil, err } bldr := txscript.NewScriptBuilder() bldr.AddData(witnessProgram) - sigScript, err = bldr.Script() + redeemScript, err = bldr.Script() if err != nil { - return Script{}, err + return nil, nil, err } // Otherwise, this is a regular p2wkh or p2tr output, so we include the @@ -761,14 +781,10 @@ func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( // p2wkh witness program will be expanded into a regular p2kh // script. default: - witnessProgram = output.PkScript + witnessProgram = pkScript } - return Script{ - Addr: managedAddr, - WitnessProgram: witnessProgram, - RedeemScript: sigScript, - }, nil + return witnessProgram, redeemScript, nil } // GetDerivationInfo returns the BIP-32 derivation path for a given address. @@ -789,17 +805,25 @@ func (w *Wallet) GetDerivationInfo(ctx context.Context, ErrDerivationPathNotFound, addr) } + return derivationForManagedAddress(pubKeyAddr) +} + +// derivationForManagedAddress constructs a PSBT Bip32Derivation struct from a +// managed public key address. +func derivationForManagedAddress(pubKeyAddr waddrmgr.ManagedPubKeyAddress) ( + *psbt.Bip32Derivation, error) { + // Imported addresses don't have derivation paths. if pubKeyAddr.Imported() { return nil, fmt.Errorf("%w: addr=%v is imported", - ErrDerivationPathNotFound, addr) + ErrDerivationPathNotFound, pubKeyAddr.Address()) } // Get the derivation info. keyScope, derivPath, ok := pubKeyAddr.DerivationInfo() if !ok { return nil, fmt.Errorf("%w: derivation info not found for %v", - ErrDerivationPathNotFound, addr) + ErrDerivationPathNotFound, pubKeyAddr.Address()) } // Get the public key. From 7091cfb4adf226345064fdc0479a4f0bdd8ccf60 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 18 Nov 2025 05:28:08 +0800 Subject: [PATCH 192/691] wallet: implement `DecorateInputs` This commit implements the `DecorateInputs` method of the `PsbtManager` interface. This method is responsible for adding UTXO and derivation information to a PSBT's inputs. --- wallet/psbt_manager.go | 198 +++++++++ wallet/psbt_manager_test.go | 837 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1035 insertions(+) create mode 100644 wallet/psbt_manager_test.go diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 86676b52bc..591a23508e 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -6,9 +6,20 @@ package wallet import ( "context" + "errors" + "fmt" "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/pkg/btcunit" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" +) + +var ( + // ErrUtxoLocked is returned when a UTXO is locked. + ErrUtxoLocked = errors.New("utxo is locked") ) // FundIntent represents the user's intent for funding a PSBT. It serves as a @@ -207,3 +218,190 @@ type PsbtManager interface { CombinePsbt(ctx context.Context, psbts ...*psbt.Packet) ( *psbt.Packet, error) } + +// DecorateInputs enriches a PSBT's inputs with UTXO and derivation information. +// +// It iterates through all inputs in the PSBT and: +// 1. Validates ownership: Calls `fetchAndValidateUtxo` to check if the input +// references a UTXO owned by the wallet. +// 2. Enriches: If owned, calls `decorateInput` to add the full previous +// transaction (`NonWitnessUtxo`) or output (`WitnessUtxo`), along with +// BIP32 derivation paths (`Bip32Derivation` or `TaprootBip32Derivation`) +// and script information. +func (w *Wallet) DecorateInputs(ctx context.Context, packet *psbt.Packet, + skipUnknown bool) (*psbt.Packet, error) { + + // We'll iterate through all the inputs of the PSBT and decorate them + // if they are owned by the wallet. The `skipUnknown` parameter + // determines whether an error is returned if an input is not owned + // by the wallet. + for i, txIn := range packet.UnsignedTx.TxIn { + // Attempt to fetch the transaction details for the current + // input from our transaction store and validate that we own + // the UTXO. The `fetchAndValidateUtxo` function will return an + // `ErrNotMine` error if the UTXO is not found or not owned by + // the wallet. + tx, utxo, err := w.fetchAndValidateUtxo(txIn) + if err != nil { + // If the error is `ErrNotMine` and `skipUnknown` is + // true, we'll simply continue to the next input, as we + // don't own it and are not required to fail. + if errors.Is(err, ErrNotMine) && skipUnknown { + continue + } + + // Otherwise, we'll return the error. This includes the + // case where the UTXO is locked. + return nil, err + } + + // If we own the UTXO, we'll proceed to decorate the + // corresponding PSBT input with detailed information from the + // wallet. + err = w.decorateInput(ctx, &packet.Inputs[i], tx, utxo) + if err != nil { + return nil, fmt.Errorf("error decorating input %d: %w", + i, err) + } + } + + return packet, nil +} + +// decorateInput is a helper function that decorates a single PSBT input with +// UTXO information from the wallet. +// +// NOTE: The `pInput` parameter is modified in-place by this function. +func (w *Wallet) decorateInput(ctx context.Context, pInput *psbt.PInput, + tx *wire.MsgTx, utxo *wire.TxOut) error { + + // We'll start by extracting the address from the UTXO's pkScript. + // This will be used to look up the managed address from the + // database. + addr := extractAddrFromPKScript(utxo.PkScript, w.chainParams) + if addr == nil { + return fmt.Errorf("%w: from pkscript %x", + ErrUnableToExtractAddress, utxo.PkScript) + } + + // We'll then use the address to look up the managed address from the + // database. This will give us access to the derivation information. + managedAddr, err := w.AddressInfo(ctx, addr) + if err != nil { + return fmt.Errorf("unable to get address info for %s: %w", + addr.String(), err) + } + + // We'll ensure that the managed address is a public key address, as + // we can only decorate inputs for which we have the private key. + pubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return fmt.Errorf("%w: addr %s", ErrNotPubKeyAddress, + managedAddr.Address()) + } + + // With the managed address, we can now get the derivation information + // for the address. + derivation, err := derivationForManagedAddress(pubKeyAddr) + if err != nil { + return err + } + + // With all the information gathered, we'll now populate the PSBT + // input based on its address type by calling the existing, non- + // deprecated helper functions. + switch { + // For SegWit v1 (Taproot) inputs, we'll use the SegWit v1 helper. + case txscript.IsPayToTaproot(utxo.PkScript): + addInputInfoSegWitV1(pInput, utxo, derivation) + + // For SegWit v0 inputs, we'll use the SegWit v0 helper. + default: + // We'll need to build the redeem script for the input. + _, redeemScript, err := buildScriptsForManagedAddress( + pubKeyAddr, utxo.PkScript, w.chainParams, + ) + if err != nil { + return err + } + + // With the redeem script, we can now populate the PSBT + // input. + addInputInfoSegWitV0( + pInput, tx, utxo, derivation, managedAddr, redeemScript, + ) + } + + return nil +} + +// fetchAndValidateUtxo fetches the transaction details for a given input, +// validates that the wallet owns the UTXO, and ensures it is not locked. +// +// This function serves as a crucial pre-check before decorating a PSBT input. +// It performs three key validation steps: +// 1. Transaction Lookup: It first attempts to fetch the full transaction +// details from the wallet's transaction store using the input's previous +// outpoint. If the transaction is not found, it returns an `ErrNotMine` +// error. +// 2. Ownership Verification: If the transaction is found, it verifies that the +// specific output index is a credit to the wallet. This ensures that the +// wallet actually owns the UTXO. If this check fails, it also returns +// `ErrNotMine`. +// 3. Lock Status Check: After confirming ownership, it checks if the UTXO has +// been locked. If the UTXO is locked, it returns an `ErrUtxoLocked` +// error. +// +// Only if all these checks pass, the function returns the full parent +// transaction (`*wire.MsgTx`) and the specific unspent transaction output +// (`*wire.TxOut`). +func (w *Wallet) fetchAndValidateUtxo(txIn *wire.TxIn) ( + *wire.MsgTx, *wire.TxOut, error) { + + // First, we'll attempt to fetch the transaction details from our + // transaction store. + txDetail, err := w.fetchTxDetails(&txIn.PreviousOutPoint.Hash) + if errors.Is(err, ErrTxNotFound) { + return nil, nil, fmt.Errorf("%w: %v", ErrNotMine, + txIn.PreviousOutPoint) + } + + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch tx details: %w", + err) + } + + // With the transaction details retrieved, we'll make an additional + // check to ensure we actually have control of this output. + if !findCredit(txDetail, txIn.PreviousOutPoint.Index) { + return nil, nil, fmt.Errorf("%w: %v", ErrNotMine, + txIn.PreviousOutPoint) + } + + // Now that we've confirmed we know about the UTXO, we'll check if it + // is locked. + if w.LockedOutpoint(txIn.PreviousOutPoint) { + return nil, nil, fmt.Errorf("%w: %v", ErrUtxoLocked, + txIn.PreviousOutPoint) + } + + // Now that we've confirmed we know about the UTXO, we'll proceed to + // gather the rest of the information required to decorate the PSBT + // input. + tx := &txDetail.MsgTx + utxo := tx.TxOut[txIn.PreviousOutPoint.Index] + + return tx, utxo, nil +} + +// findCredit determines whether a transaction's details contain a credit for a +// specific output index. +func findCredit(txDetail *wtxmgr.TxDetails, outputIndex uint32) bool { + for _, cred := range txDetail.Credits { + if cred.Index == outputIndex { + return true + } + } + + return false +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go new file mode 100644 index 0000000000..61c17230b5 --- /dev/null +++ b/wallet/psbt_manager_test.go @@ -0,0 +1,837 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var errDb = errors.New("db error") + +// TestFindCredit tests that the findCredit helper returns true if a credit +// exists at the specified index, and false otherwise. +func TestFindCredit(t *testing.T) { + t.Parallel() + + // Arrange: Create TxDetails with credits at indices 0 and 2. + txDetails := &wtxmgr.TxDetails{ + Credits: []wtxmgr.CreditRecord{ + {Index: 0}, + {Index: 2}, + }, + } + + // Arrange: Define test cases to check for credits at various indices. + testCases := []struct { + name string + index uint32 + expectedFound bool + }{ + { + name: "credit exists at index 0", + index: 0, + expectedFound: true, + }, + { + name: "credit exists at index 2", + index: 2, + expectedFound: true, + }, + { + name: "credit does not exist at index 1", + index: 1, + expectedFound: false, + }, + { + name: "credit does not exist at index 3", + index: 3, + expectedFound: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Call findCredit with the configured TxDetails + // and index. + found := findCredit(txDetails, tc.index) + + // Assert: Verify that the returned boolean matches the + // expected outcome. + require.Equal(t, tc.expectedFound, found) + }) + } +} + +// TestFetchAndValidateUtxoSuccess tests that fetchAndValidateUtxo correctly +// retrieves transaction details and validates ownership. +func TestFetchAndValidateUtxoSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Create a transaction input (txHash:0) and mock the wallet's + // transaction store to return a corresponding credit at index 0. + txHash := chainhash.Hash{1} + txIn := &wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: txHash, Index: 0}, + } + + txDetails := &wtxmgr.TxDetails{ + TxRecord: wtxmgr.TxRecord{ + MsgTx: wire.MsgTx{ + TxOut: []*wire.TxOut{ + {Value: 1000}, + }, + }, + }, + Credits: []wtxmgr.CreditRecord{ + {Index: 0}, + }, + } + + w, mocks := testWalletWithMocks(t) + + // Mock the transaction store to return the details for our txHash. + mocks.txStore.On( + "TxDetails", mock.Anything, + mock.MatchedBy(func(h *chainhash.Hash) bool { + return h.IsEqual(&txHash) + }), + ).Return(txDetails, nil) + + // Act: Call fetchAndValidateUtxo with the valid input. + tx, utxo, err := w.fetchAndValidateUtxo(txIn) + + // Assert: Verify that no error occurred and that the returned + // transaction and UTXO match the expected values from the store. + require.NoError(t, err) + require.NotNil(t, tx) + require.NotNil(t, utxo) + require.Equal(t, txDetails.MsgTx.TxOut[0], utxo) +} + +// TestFetchAndValidateUtxoError tests that fetchAndValidateUtxo returns the +// expected errors for various failure conditions. +func TestFetchAndValidateUtxoError(t *testing.T) { + t.Parallel() + + // Arrange: Prepare common data structures for the test cases. + txHash := chainhash.Hash{1} + + // txIn pointing to an unlocked outpoint (Index 0). + txIn := &wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: txHash, Index: 0}, + } + + // txInLocked pointing to a locked outpoint (Index 1). + txInLocked := &wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: txHash, Index: 1}, + } + + // txDetails contains credits for both Index 0 and Index 1. + // Index 0 is used for unlocked tests. + // Index 1 is used for the locked test. + txDetails := &wtxmgr.TxDetails{ + TxRecord: wtxmgr.TxRecord{ + MsgTx: wire.MsgTx{ + TxOut: []*wire.TxOut{ + {Value: 1000}, + {Value: 1000}, + }, + }, + }, + Credits: []wtxmgr.CreditRecord{ + {Index: 0}, + {Index: 1}, + }, + } + + noCreditDetails := &wtxmgr.TxDetails{ + TxRecord: txDetails.TxRecord, + Credits: []wtxmgr.CreditRecord{}, + } + + testCases := []struct { + name string + txIn *wire.TxIn + mockTxDetails *wtxmgr.TxDetails + mockErr error + expectedErr error + }{ + { + name: "tx not found", + txIn: txIn, + mockTxDetails: nil, + mockErr: ErrTxNotFound, + expectedErr: ErrNotMine, + }, + { + name: "store error", + txIn: txIn, + mockTxDetails: nil, + mockErr: errDb, + expectedErr: errDb, + }, + { + name: "not credit", + txIn: txIn, + mockTxDetails: noCreditDetails, + mockErr: nil, + expectedErr: ErrNotMine, + }, + { + name: "utxo locked", + txIn: txInLocked, + mockTxDetails: txDetails, + mockErr: nil, + expectedErr: ErrUtxoLocked, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + w, mocks := testWalletWithMocks(t) + + // Arrange: Lock the "locked" outpoint to simulate a + // locked UTXO scenario. + w.LockOutpoint(txInLocked.PreviousOutPoint) + + // Arrange: Mock the transaction store to return the + // configured details or error for the specific test + // case. + mocks.txStore.On( + "TxDetails", mock.Anything, + mock.MatchedBy(func(h *chainhash.Hash) bool { + return h.IsEqual(&txHash) + }), + ).Return(tc.mockTxDetails, tc.mockErr) + + // Act: Call fetchAndValidateUtxo with the configured + // input. + tx, utxo, err := w.fetchAndValidateUtxo(tc.txIn) + + // Assert: Verify that the returned error matches the + // expected error and that no transaction or UTXO is + // returned. + require.ErrorIs(t, err, tc.expectedErr) + require.Nil(t, tx) + require.Nil(t, utxo) + }) + } +} + +// TestDecorateInputSegWitV0 tests that decorateInput correctly populates +// PSBT input fields for a SegWit v0 (P2WKH) input. +func TestDecorateInputSegWitV0(t *testing.T) { + t.Parallel() + + // Arrange: Setup private and public keys for a P2WKH address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + // Arrange: Create a P2WKH address and its corresponding script. + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + // Arrange: Define key scope and derivation path for address manager + // mocks. + keyScope := waddrmgr.KeyScopeBIP0084 + derivationPath := waddrmgr.DerivationPath{ + Account: 0, + Branch: 0, + Index: 0, + } + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock the address manager to return our P2WKH address as a + // ManagedPubKeyAddress when `Address` is called with the P2WKH + // address. + mocks.addrStore.On( + "Address", mock.Anything, + mock.MatchedBy(func(addr btcutil.Address) bool { + return addr.String() == p2wkhAddr.String() + }), + ).Return(mocks.pubKeyAddr, nil) + + // Arrange: Mock the ManagedPubKeyAddress methods to return relevant + // derivation and public key information. + mocks.pubKeyAddr.On("Imported").Return(false) + mocks.pubKeyAddr.On("DerivationInfo").Return( + keyScope, derivationPath, true, + ) + mocks.pubKeyAddr.On("PubKey").Return(pubKey) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + + // Arrange: Create a UTXO with the P2WKH script and an empty PSBT input. + utxo := &wire.TxOut{ + Value: 1000, + PkScript: p2wkhScript, + } + tx := &wire.MsgTx{} + pInput := &psbt.PInput{} + + // Act: Call decorateInput to populate the PSBT input. + err = w.decorateInput(t.Context(), pInput, tx, utxo) + + // Assert: Verify no error occurred and that the PSBT input is correctly + // populated with WitnessUtxo, NonWitnessUtxo, SighashType, and BIP32 + // derivation info. + require.NoError(t, err) + require.Equal(t, utxo, pInput.WitnessUtxo) + require.Equal(t, tx, pInput.NonWitnessUtxo) + require.Equal(t, txscript.SigHashAll, pInput.SighashType) + require.Len(t, pInput.Bip32Derivation, 1) + require.Equal( + t, pubKey.SerializeCompressed(), + pInput.Bip32Derivation[0].PubKey, + ) +} + +// TestDecorateInputTaproot tests that decorateInput correctly populates +// PSBT input fields for a Taproot (SegWit v1) input. +func TestDecorateInputTaproot(t *testing.T) { + t.Parallel() + + // Arrange: Setup private and public keys for a Taproot address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + // Arrange: Create a Taproot address and its corresponding script. + taprootAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(pubKey), &chainParams, + ) + require.NoError(t, err) + + taprootScript, err := txscript.PayToAddrScript(taprootAddr) + require.NoError(t, err) + + // Arrange: Define key scope and derivation path for address manager + // mocks. + keyScope := waddrmgr.KeyScopeBIP0084 + derivationPath := waddrmgr.DerivationPath{ + Account: 0, + Branch: 0, + Index: 0, + } + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock the address manager to return our Taproot address as a + // ManagedPubKeyAddress when `Address` is called with the Taproot + // address. + mocks.addrStore.On( + "Address", mock.Anything, + mock.MatchedBy(func(addr btcutil.Address) bool { + return addr.String() == taprootAddr.String() + }), + ).Return(mocks.pubKeyAddr, nil) + + // Arrange: Mock the ManagedPubKeyAddress methods to return relevant + // derivation and public key information. AddrType is not strictly + // checked for Taproot inputs in decorateInput, so no mock is needed + // for it. + mocks.pubKeyAddr.On("Imported").Return(false) + mocks.pubKeyAddr.On("DerivationInfo").Return( + keyScope, derivationPath, true, + ) + mocks.pubKeyAddr.On("PubKey").Return(pubKey) + + // Arrange: Create a UTXO with the Taproot script and an empty PSBT + // input. + utxo := &wire.TxOut{ + Value: 1000, + PkScript: taprootScript, + } + tx := &wire.MsgTx{} + pInput := &psbt.PInput{} + + // Act: Call decorateInput to populate the PSBT input. + err = w.decorateInput(t.Context(), pInput, tx, utxo) + + // Assert: Verify no error occurred and that the PSBT input is + // correctly populated with WitnessUtxo, SighashType, and Taproot BIP32 + // derivation info, including the x-only public key. + require.NoError(t, err) + require.Equal(t, utxo, pInput.WitnessUtxo) + require.Equal(t, txscript.SigHashDefault, pInput.SighashType) + require.Len(t, pInput.TaprootBip32Derivation, 1) + require.Equal( + t, schnorr.SerializePubKey(pubKey), + pInput.TaprootBip32Derivation[0].XOnlyPubKey, + ) +} + +// TestDecorateInputErrExtractAddr tests that decorateInput returns +// ErrUnableToExtractAddress when the pkScript does not contain a valid +// address. +func TestDecorateInputErrExtractAddr(t *testing.T) { + t.Parallel() + + w, _ := testWalletWithMocks(t) + + // Arrange: Create a UTXO with an OP_RETURN script, which cannot be + // parsed into a valid address. + utxo := &wire.TxOut{ + Value: 1000, + PkScript: []byte{0x6a}, // OP_RETURN + } + tx := &wire.MsgTx{} + pInput := &psbt.PInput{} + + // Act: Call decorateInput. + err := w.decorateInput(t.Context(), pInput, tx, utxo) + + // Assert: Verify the error. + require.ErrorIs(t, err, ErrUnableToExtractAddress) +} + +// TestDecorateInputErrAddrInfo tests that decorateInput returns an error when +// the address lookup fails. +func TestDecorateInputErrAddrInfo(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys and address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock AddressInfo to return an error. + mocks.addrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(nil, errDb) + + utxo := &wire.TxOut{ + Value: 1000, + PkScript: p2wkhScript, + } + tx := &wire.MsgTx{} + pInput := &psbt.PInput{} + + // Act: Call decorateInput. + err = w.decorateInput(t.Context(), pInput, tx, utxo) + + // Assert: Verify the error. + require.ErrorIs(t, err, errDb) +} + +// TestDecorateInputErrNotPubKey tests that decorateInput returns +// ErrNotPubKeyAddress when the address is not a ManagedPubKeyAddress. +func TestDecorateInputErrNotPubKey(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys and address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock AddressInfo to return a generic ManagedAddress + // (mocks.addr) instead of a ManagedPubKeyAddress (mocks.pubKeyAddr). + mocks.addrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(mocks.addr, nil) + + mocks.addr.On("Address").Return(p2wkhAddr) + + utxo := &wire.TxOut{ + Value: 1000, + PkScript: p2wkhScript, + } + tx := &wire.MsgTx{} + pInput := &psbt.PInput{} + + // Act: Call decorateInput. + err = w.decorateInput(t.Context(), pInput, tx, utxo) + + // Assert: Verify the error. + require.ErrorIs(t, err, ErrNotPubKeyAddress) +} + +// TestDecorateInputErrImported tests that decorateInput returns +// ErrDerivationPathNotFound when the address is imported. +func TestDecorateInputErrImported(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys and address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock AddressInfo to return a ManagedPubKeyAddress that is + // marked as imported. + mocks.addrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil) + + mocks.pubKeyAddr.On("Imported").Return(true) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + + utxo := &wire.TxOut{ + Value: 1000, + PkScript: p2wkhScript, + } + tx := &wire.MsgTx{} + pInput := &psbt.PInput{} + + // Act: Call decorateInput. + err = w.decorateInput(t.Context(), pInput, tx, utxo) + + // Assert: Verify the error. + require.ErrorIs(t, err, ErrDerivationPathNotFound) +} + +// TestDecorateInputErrDerivationMissing tests that decorateInput returns +// ErrDerivationPathNotFound when derivation info is missing. +func TestDecorateInputErrDerivationMissing(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys and address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock AddressInfo to return a ManagedPubKeyAddress that has + // no derivation info. + mocks.addrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil) + + mocks.pubKeyAddr.On("Imported").Return(false) + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScope{}, waddrmgr.DerivationPath{}, false, + ) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + + utxo := &wire.TxOut{ + Value: 1000, + PkScript: p2wkhScript, + } + tx := &wire.MsgTx{} + pInput := &psbt.PInput{} + + // Act: Call decorateInput. + err = w.decorateInput(t.Context(), pInput, tx, utxo) + + // Assert: Verify the error. + require.ErrorIs(t, err, ErrDerivationPathNotFound) +} + +// TestDecorateInputsSuccess tests that DecorateInputs correctly decorates +// known inputs and skips unknown inputs when skipUnknown is true. +func TestDecorateInputsSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys and address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + // Arrange: Define 3 inputs. + // Input 0: Known (TxHash0) + // Input 1: Unknown (TxHash1) + // Input 2: Known (TxHash2) + txHash0 := chainhash.Hash{0} + txHash1 := chainhash.Hash{1} + txHash2 := chainhash.Hash{2} + + unsignedTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {PreviousOutPoint: wire.OutPoint{ + Hash: txHash0, Index: 0, + }}, + {PreviousOutPoint: wire.OutPoint{ + Hash: txHash1, Index: 0, + }}, + {PreviousOutPoint: wire.OutPoint{ + Hash: txHash2, Index: 0, + }}, + }, + } + + packet, err := psbt.NewFromUnsignedTx(unsignedTx) + require.NoError(t, err) + + // Arrange: Setup TxDetails for known inputs. + txDetails0 := &wtxmgr.TxDetails{ + TxRecord: wtxmgr.TxRecord{ + MsgTx: wire.MsgTx{ + TxOut: []*wire.TxOut{{ + Value: 1000, PkScript: p2wkhScript, + }}, + }, + }, + Credits: []wtxmgr.CreditRecord{{Index: 0}}, + } + txDetails2 := &wtxmgr.TxDetails{ + TxRecord: wtxmgr.TxRecord{ + MsgTx: wire.MsgTx{ + TxOut: []*wire.TxOut{{ + Value: 2000, PkScript: p2wkhScript, + }}, + }, + }, + Credits: []wtxmgr.CreditRecord{{Index: 0}}, + } + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock TxDetails lookups. + // Input 0 -> Found + mocks.txStore.On( + "TxDetails", mock.Anything, + mock.MatchedBy(func(h *chainhash.Hash) bool { + return h.IsEqual(&txHash0) + }), + ).Return(txDetails0, nil) + + // Input 1 -> Not Found + mocks.txStore.On( + "TxDetails", mock.Anything, + mock.MatchedBy(func(h *chainhash.Hash) bool { + return h.IsEqual(&txHash1) + }), + ).Return(nil, ErrTxNotFound) + + // Input 2 -> Found + mocks.txStore.On( + "TxDetails", mock.Anything, + mock.MatchedBy(func(h *chainhash.Hash) bool { + return h.IsEqual(&txHash2) + }), + ).Return(txDetails2, nil) + + // Arrange: Mock Address lookup (common for both known inputs). + mocks.addrStore.On( + "Address", mock.Anything, + mock.MatchedBy(func(addr btcutil.Address) bool { + return addr.String() == p2wkhAddr.String() + }), + ).Return(mocks.pubKeyAddr, nil) + + // Arrange: Mock ManagedPubKeyAddress methods. + mocks.pubKeyAddr.On("Imported").Return(false) + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0084, waddrmgr.DerivationPath{}, true, + ) + mocks.pubKeyAddr.On("PubKey").Return(pubKey) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + + // Act: Call DecorateInputs with skipUnknown=true. + _, err = w.DecorateInputs(t.Context(), packet, true) + require.NoError(t, err) + + // Assert: Input 0 is decorated. + require.NotNil(t, packet.Inputs[0].WitnessUtxo) + require.Equal(t, int64(1000), packet.Inputs[0].WitnessUtxo.Value) + require.Len(t, packet.Inputs[0].Bip32Derivation, 1) + + // Assert: Input 1 is NOT decorated. + require.Nil(t, packet.Inputs[1].WitnessUtxo) + require.Nil(t, packet.Inputs[1].NonWitnessUtxo) + require.Empty(t, packet.Inputs[1].Bip32Derivation) + + // Assert: Input 2 is decorated. + require.NotNil(t, packet.Inputs[2].WitnessUtxo) + require.Equal(t, int64(2000), packet.Inputs[2].WitnessUtxo.Value) + require.Len(t, packet.Inputs[2].Bip32Derivation, 1) +} + +// TestDecorateInputsErrUnknownRequired tests that DecorateInputs returns +// ErrNotMine when an input is unknown and skipUnknown is false. +func TestDecorateInputsErrUnknownRequired(t *testing.T) { + t.Parallel() + + txHash := chainhash.Hash{1} + unsignedTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: txHash, + Index: 0, + }, + }}, + } + packet, err := psbt.NewFromUnsignedTx(unsignedTx) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock TxDetails to return ErrTxNotFound. + mocks.txStore.On( + "TxDetails", mock.Anything, mock.Anything, + ).Return(nil, ErrTxNotFound) + + // Act: Call DecorateInputs with skipUnknown=false. + _, err = w.DecorateInputs(t.Context(), packet, false) + + // Assert: Error is ErrNotMine. + require.ErrorIs(t, err, ErrNotMine) +} + +// TestDecorateInputsErrFetchFailed tests that DecorateInputs returns an error +// when fetching/validating a UTXO fails with a non-ErrNotMine error. +func TestDecorateInputsErrFetchFailed(t *testing.T) { + t.Parallel() + + txHash := chainhash.Hash{1} + unsignedTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: txHash, + Index: 0, + }, + }}, + } + packet, err := psbt.NewFromUnsignedTx(unsignedTx) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock TxDetails to return a database error. + mocks.txStore.On( + "TxDetails", mock.Anything, mock.Anything, + ).Return(nil, errDb) + + // Act: Call DecorateInputs (skipUnknown irrelevant for other errors). + _, err = w.DecorateInputs(t.Context(), packet, true) + + // Assert: Error is errDb. + require.ErrorIs(t, err, errDb) +} + +// TestDecorateInputsErrDecorationFailed tests that DecorateInputs returns an +// error when the internal decorateInput call fails. +func TestDecorateInputsErrDecorationFailed(t *testing.T) { + t.Parallel() + + // Arrange: Setup valid key/address/script for a known input. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + txHash := chainhash.Hash{1} + unsignedTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: txHash, + Index: 0, + }, + }}, + } + packet, err := psbt.NewFromUnsignedTx(unsignedTx) + require.NoError(t, err) + + txDetails := &wtxmgr.TxDetails{ + TxRecord: wtxmgr.TxRecord{ + MsgTx: wire.MsgTx{ + TxOut: []*wire.TxOut{{ + Value: 1000, PkScript: p2wkhScript, + }}, + }, + }, + Credits: []wtxmgr.CreditRecord{{Index: 0}}, + } + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock TxDetails success. + mocks.txStore.On( + "TxDetails", mock.Anything, mock.Anything, + ).Return(txDetails, nil) + + // Arrange: Mock AddressInfo to fail (causing decorateInput to fail). + mocks.addrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(nil, errDb) + + // Act: Call DecorateInputs. + _, err = w.DecorateInputs(t.Context(), packet, true) + + // Assert: Error is errDb. + require.ErrorIs(t, err, errDb) +} From 52efd7a9b263ec3524a4011df7d213e3a1793a9e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 20 Nov 2025 01:37:08 +0800 Subject: [PATCH 193/691] wallet: implement `FundPsbt` --- wallet/psbt_manager.go | 296 ++++++++++++ wallet/psbt_manager_test.go | 934 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1230 insertions(+) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 591a23508e..e91399438b 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -8,18 +8,43 @@ import ( "context" "errors" "fmt" + "math" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wtxmgr" ) var ( // ErrUtxoLocked is returned when a UTXO is locked. ErrUtxoLocked = errors.New("utxo is locked") + + // ErrChangeAddressNotManagedPubKey is returned when a change address is + // not a managed public key address. + ErrChangeAddressNotManagedPubKey = errors.New( + "change address is not a managed pubkey address", + ) + + // ErrChangeIndexOutOfRange is returned when the change index is out of + // range. + ErrChangeIndexOutOfRange = errors.New("change index out of range") + + // ErrPacketOutputsMissing is returned when a PSBT is provided for + // funding with no outputs. + ErrPacketOutputsMissing = errors.New("psbt packet has no outputs") + + // ErrInputsAndPolicy is returned when a PSBT is provided with inputs, + // but a coin selection policy is also specified. + ErrInputsAndPolicy = errors.New( + "cannot specify both psbt inputs and a coin selection policy", + ) + + // ErrFeeRateNegative is returned when a negative fee rate is provided. + ErrFeeRateNegative = errors.New("fee rate cannot be negative") ) // FundIntent represents the user's intent for funding a PSBT. It serves as a @@ -405,3 +430,274 @@ func findCredit(txDetail *wtxmgr.TxDetails, outputIndex uint32) bool { return false } + +// FundPsbt performs coin selection and funds the PSBT. +// +// It executes the funding logic by: +// 1. Validation: Checking the `FundIntent` for consistency. +// 2. Creation: Converting the intent into a `TxIntent` and delegating to the +// `CreateTransaction` method (which handles the underlying coin selection +// and change calculation algorithms). +// 3. Population: Calling `populatePsbtPacket` to apply the selected inputs and +// change output to the PSBT structure and sort it according to BIP 69. +func (w *Wallet) FundPsbt(ctx context.Context, intent *FundIntent) ( + *psbt.Packet, int32, error) { + + // Validate the funding intent before proceeding. + err := w.validateFundIntent(intent) + if err != nil { + return nil, 0, err + } + + // Create a TxIntent from the FundIntent. + txIntent := w.createTxIntent(intent) + + // Create the transaction. + authoredTx, err := w.CreateTransaction(ctx, txIntent) + if err != nil { + return nil, 0, err + } + + // Populate the PSBT packet with the new transaction details. + packet, changeIndex, err := w.populatePsbtPacket( + ctx, intent.Packet, authoredTx, + ) + if err != nil { + return nil, 0, err + } + + return packet, changeIndex, nil +} + +// populatePsbtPacket updates the PSBT packet with the new transaction details, +// decorates the inputs, and handles the change output. It returns the modified +// packet and the index of the change output, or -1 if no change output was +// added. +func (w *Wallet) populatePsbtPacket(ctx context.Context, packet *psbt.Packet, + authoredTx *txauthor.AuthoredTx) (*psbt.Packet, int32, error) { + + // The authored transaction contains the selected inputs and the change + // output (if any). We'll update the PSBT packet with this new + // unsigned transaction. + packet.UnsignedTx = authoredTx.Tx + + // We'll also re-initialize the input and output slices to match the + // dimensions of the new transaction. This is crucial because the + // `authoredTx` may have a different output order than the original PSBT + // (e.g., due to change output randomization in txauthor.AuthoredTx), + // which would otherwise cause a misalignment between the wire outputs + // and the PSBT's output metadata. By resetting, we ensure consistency. + packet.Inputs = make([]psbt.PInput, len(authoredTx.Tx.TxIn)) + packet.Outputs = make([]psbt.POutput, len(authoredTx.Tx.TxOut)) + + // With the new inputs in place, we'll decorate them with UTXO and + // derivation information from the wallet. We set `skipUnknown` to + // false because all inputs in the `authoredTx` must be known to the + // wallet. + _, err := w.DecorateInputs(ctx, packet, false) + if err != nil { + return nil, 0, err + } + + // If a change output was created, we need to add its derivation + // information to the corresponding PSBT output. + var changeOutput *wire.TxOut + if authoredTx.ChangeIndex >= 0 { + err := w.addChangeOutputInfo(ctx, packet, authoredTx) + if err != nil { + return nil, 0, err + } + + changeOutput = authoredTx.Tx.TxOut[authoredTx.ChangeIndex] + } + + // The PSBT specification recommends that inputs and outputs are + // sorted. This is done for privacy and standardization. We'll sort + // the packet in place. + err = psbt.InPlaceSort(packet) + if err != nil { + return nil, 0, fmt.Errorf("cannot sort psbt: %w", err) + } + + // After sorting, the original change index from `authoredTx` is no + // longer valid. We need to find the new index of the change output in + // the sorted list. + changeIndex, err := findChangeIndex(changeOutput, packet) + if err != nil { + return nil, 0, err + } + + return packet, changeIndex, nil +} + +// addChangeOutputInfo is a helper function that adds the derivation information +// for a change output to a PSBT packet. +func (w *Wallet) addChangeOutputInfo(ctx context.Context, packet *psbt.Packet, + authoredTx *txauthor.AuthoredTx) error { + + // TODO(yy): The calls to `w.ScriptForOutput` and `w.AddressInfo` both + // involve database lookups. This could be optimized to a single + // database call to fetch all necessary address information. However, + // for now, this approach favors readability over micro-optimization, + // as this path is not performance-critical. + // + // First, we'll get the script information for the change output. + changeScriptInfo, err := w.ScriptForOutput( + ctx, *authoredTx.Tx.TxOut[authoredTx.ChangeIndex], + ) + if err != nil { + return err + } + + // Then, we'll get the managed address for the change output. + changeAddr, err := w.AddressInfo(ctx, changeScriptInfo.Addr.Address()) + if err != nil { + return err + } + + // We'll ensure that the change address is a public key address. + managedPubKeyAddr, ok := changeAddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return ErrChangeAddressNotManagedPubKey + } + + // With the managed address, we can now create the PSBT output + // information. + changeOutputInfo, err := createOutputInfo( + authoredTx.Tx.TxOut[authoredTx.ChangeIndex], + managedPubKeyAddr, + ) + if err != nil { + return err + } + + // Finally, we'll add the change output information to the PSBT packet. + packet.Outputs[authoredTx.ChangeIndex] = *changeOutputInfo + + return nil +} + +// validateFundIntent performs a series of checks on a FundIntent to ensure it +// is well-formed and unambiguous. This function is called before any funding +// logic to ensure that the caller has provided a valid intent. +// +// The following checks are performed: +// 1. The PSBT packet must not be nil. +// 2. If the PSBT has no inputs (automatic coin selection mode), it must have +// at least one output. +// 3. If the PSBT has inputs, a coin selection policy must not be specified +// (mutual exclusivity). +func (w *Wallet) validateFundIntent(intent *FundIntent) error { + // The PSBT packet must not be nil. + if intent.Packet == nil { + return fmt.Errorf( + "%w: psbt packet cannot be nil", ErrNilTxIntent, + ) + } + + // If the PSBT has no inputs (automatic coin selection mode), it must + // have at least one output. + if len(intent.Packet.UnsignedTx.TxIn) == 0 && + len(intent.Packet.UnsignedTx.TxOut) == 0 { + + return ErrPacketOutputsMissing + } + + // If the PSBT has inputs, a coin selection policy must not be + // specified (mutual exclusivity). + if len(intent.Packet.UnsignedTx.TxIn) > 0 && intent.Policy != nil { + return ErrInputsAndPolicy + } + + return nil +} + +// findChangeIndex finds the new index of the change output after the PSBT has +// been sorted. +func findChangeIndex(changeOutput *wire.TxOut, + packet *psbt.Packet) (int32, error) { + + if changeOutput == nil { + return -1, nil + } + + for i, txOut := range packet.UnsignedTx.TxOut { + if i > math.MaxInt32 { + return 0, ErrChangeIndexOutOfRange + } + + if psbt.TxOutsEqual(changeOutput, txOut) { + // The above check ensures that the conversion to int32 + // is safe. + // + //nolint:gosec + return int32(i), nil + } + } + + return -1, nil +} + +// createTxIntent creates a TxIntent from a FundIntent. This helper function +// acts as a pure adapter, translating the high-level funding request into a +// concrete transaction creation plan for the wallet's underlying `TxCreator`. +// +// It does not perform any database lookups or validation. Instead, it relies +// on the API contract that the caller must provide a fully specified +// `InputsPolicy` (with both `AccountName` and `KeyScope`) if automatic coin +// selection is desired. The underlying `TxCreator` is responsible for +// validating the existence of the specified account. +// +// The function is responsible for two main pieces of logic: +// 1. Input Source Determination: It inspects the incoming PSBT. If it has no +// inputs, it uses the `InputsPolicy` from the intent. If it has inputs, +// it creates an `InputsManual` source. +// 2. Change Source Mapping: It directly maps the `FundIntent.ChangeSource` +// to `TxIntent.ChangeSource`. Any default change source determination +// (e.g., when `FundIntent.ChangeSource` is `nil`) is delegated to the +// underlying `TxCreator`'s `determineChangeSource` method. +func (w *Wallet) createTxIntent(intent *FundIntent) *TxIntent { + // First, we'll copy the outputs from the PSBT packet to the TxIntent. + outputs := make([]wire.TxOut, len(intent.Packet.UnsignedTx.TxOut)) + for i, txOut := range intent.Packet.UnsignedTx.TxOut { + outputs[i] = *txOut + } + + // The fee rate and label are passed through directly. + txIntent := &TxIntent{ + Outputs: outputs, + FeeRate: intent.FeeRate, + Label: intent.Label, + } + + // Now, we'll determine the input source based on whether the PSBT + // packet already contains inputs. + if len(intent.Packet.UnsignedTx.TxIn) == 0 { + // If the packet has no inputs, we'll use the policy-based input + // source from the intent. This will trigger automatic coin + // selection by the wallet. The caller is responsible for + // providing a complete `ScopedAccount` with both `AccountName` + // and `KeyScope`. + txIntent.Inputs = intent.Policy + } else { + // If the packet already has inputs, we'll use a manual input + // source. This bypasses coin selection and tells the wallet to + // use the exact inputs provided in the PSBT. + utxos := make( + []wire.OutPoint, len(intent.Packet.UnsignedTx.TxIn), + ) + for i, txIn := range intent.Packet.UnsignedTx.TxIn { + utxos[i] = txIn.PreviousOutPoint + } + + txIntent.Inputs = &InputsManual{ + UTXOs: utxos, + } + } + + // The change source is directly mapped from the FundIntent. If it is + // nil, the underlying `TxCreator` will determine a default. + txIntent.ChangeSource = intent.ChangeSource + + return txIntent +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 61c17230b5..9a59e91e39 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -15,7 +15,9 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -835,3 +837,935 @@ func TestDecorateInputsErrDecorationFailed(t *testing.T) { // Assert: Error is errDb. require.ErrorIs(t, err, errDb) } + +// TestValidateFundIntentSuccess tests that validateFundIntent returns no error +// for valid funding intents. +func TestValidateFundIntentSuccess(t *testing.T) { + t.Parallel() + + w, _ := testWalletWithMocks(t) + + // Arrange: Create a valid PSBT packet with one output (for auto + // selection). + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // Arrange: Create a FundIntent for automatic coin selection (no inputs + // in packet). + intentAuto := &FundIntent{ + Packet: packet, + } + + // Arrange: Create a valid PSBT packet with one input and one output + // (for manual selection). + txWithInputs := wire.NewMsgTx(2) + txWithInputs.AddTxIn(&wire.TxIn{}) + txWithInputs.AddTxOut(&wire.TxOut{}) + packetWithInputs, err := psbt.NewFromUnsignedTx(txWithInputs) + require.NoError(t, err) + + // Arrange: Create a FundIntent for manual coin selection (inputs + // present in packet). + intentManual := &FundIntent{ + Packet: packetWithInputs, + } + + // Act & Assert: Validate the auto selection intent. Expect no error. + err = w.validateFundIntent(intentAuto) + require.NoError(t, err) + + // Act & Assert: Validate the manual selection intent. Expect no error. + err = w.validateFundIntent(intentManual) + require.NoError(t, err) +} + +// TestValidateFundIntentError tests that validateFundIntent returns expected +// errors for invalid funding intents. +func TestValidateFundIntentError(t *testing.T) { + t.Parallel() + + w, _ := testWalletWithMocks(t) + + // Arrange: Helper function to create a PSBT packet with specified + // inputs and outputs. + createPacket := func(numInputs, numOutputs int) *psbt.Packet { + tx := wire.NewMsgTx(2) + for range numInputs { + tx.AddTxIn(&wire.TxIn{}) + } + + for range numOutputs { + tx.AddTxOut(&wire.TxOut{}) + } + + p, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + return p + } + + // Arrange: Define test cases for various error scenarios. + testCases := []struct { + name string + intent *FundIntent + expectedErr error + }{ + { + name: "nil packet", + intent: &FundIntent{Packet: nil}, + expectedErr: ErrNilTxIntent, + }, + { + name: "no inputs and no outputs", + intent: &FundIntent{Packet: createPacket(0, 0)}, + expectedErr: ErrPacketOutputsMissing, + }, + { + name: "inputs and policy conflict", + intent: &FundIntent{ + Packet: createPacket(1, 1), + Policy: &InputsPolicy{}, + }, + expectedErr: ErrInputsAndPolicy, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Call validateFundIntent with the configured + // invalid intent. + err := w.validateFundIntent(tc.intent) + + // Assert: Verify that the returned error matches the + // expected error. + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +// TestCreateTxIntentAuto tests that createTxIntent correctly converts +// FundIntent to TxIntent for automatic coin selection. +func TestCreateTxIntentAuto(t *testing.T) { + t.Parallel() + + w, _ := testWalletWithMocks(t) + + // Arrange: Create a PSBT packet with two outputs and no inputs, + // which signals automatic coin selection. + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{Value: 1}) + tx.AddTxOut(&wire.TxOut{Value: 2}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // Arrange: Define the fee rate, coin selection policy, and change + // source. + feeRate := btcunit.NewSatPerKVByte(1000) + policy := &InputsPolicy{ + MinConfs: 1, + } + changeSource := &ScopedAccount{} + + // Arrange: Create the FundIntent with the above parameters. + intent := &FundIntent{ + Packet: packet, + Policy: policy, + FeeRate: feeRate, + Label: "test", + ChangeSource: changeSource, + } + + // Act: Call createTxIntent to convert the FundIntent. + txIntent := w.createTxIntent(intent) + + // Assert: Verify that the basic fields of the resulting TxIntent + // match the input FundIntent. + expectedOutputs := []wire.TxOut{{Value: 1}, {Value: 2}} + require.Equal(t, expectedOutputs, txIntent.Outputs) + require.Equal(t, feeRate, txIntent.FeeRate) + require.Equal(t, "test", txIntent.Label) + require.Equal(t, changeSource, txIntent.ChangeSource) + + // Assert: Verify that the Inputs field of TxIntent is of type + // *InputsPolicy and matches the expected policy for auto selection. + inputsPolicy, ok := txIntent.Inputs.(*InputsPolicy) + require.True(t, ok) + require.Equal(t, policy, inputsPolicy) +} + +// TestCreateTxIntentManual tests that createTxIntent correctly converts +// FundIntent to TxIntent for manual coin selection. +func TestCreateTxIntentManual(t *testing.T) { + t.Parallel() + + w, _ := testWalletWithMocks(t) + + // Arrange: Create a PSBT packet with two inputs and one output, + // which signals manual coin selection. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Index: 0}, + }) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Index: 1}, + }) + tx.AddTxOut(&wire.TxOut{Value: 1}) + + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // Arrange: Define the fee rate and change source. Policy is not needed + // for manual selection. + feeRate := btcunit.NewSatPerKVByte(1000) + changeSource := &ScopedAccount{} + + // Arrange: Create the FundIntent with the above parameters. + intent := &FundIntent{ + Packet: packet, + FeeRate: feeRate, + Label: "manual", + ChangeSource: changeSource, + } + + // Act: Call createTxIntent to convert the FundIntent. + txIntent := w.createTxIntent(intent) + + // Assert: Verify that the basic fields of the resulting TxIntent + // match the input FundIntent. + expectedOutputs := []wire.TxOut{{Value: 1}} + require.Equal(t, expectedOutputs, txIntent.Outputs) + require.Equal(t, feeRate, txIntent.FeeRate) + require.Equal(t, "manual", txIntent.Label) + require.Equal(t, changeSource, txIntent.ChangeSource) + + // Assert: Verify that the Inputs field of TxIntent is of type + // *InputsManual and contains the expected UTXOs from the packet inputs. + inputsManual, ok := txIntent.Inputs.(*InputsManual) + require.True(t, ok) + + expectedUTXOs := []wire.OutPoint{{Index: 0}, {Index: 1}} + require.Equal(t, expectedUTXOs, inputsManual.UTXOs) +} + +// TestFindChangeIndex tests that findChangeIndex correctly locates the change +// output in the sorted PSBT packet. +func TestFindChangeIndex(t *testing.T) { + t.Parallel() + + // Arrange: Create three distinct transaction outputs. + out1 := &wire.TxOut{Value: 1000, PkScript: []byte{1}} + out2 := &wire.TxOut{Value: 2000, PkScript: []byte{2}} + + // Identified as the change output. + changeOut := &wire.TxOut{Value: 500, PkScript: []byte{3}} + + // Arrange: Setup a PSBT Packet where the outputs are sorted + // differently, with the change output now at index 0: [changeOut, + // out1, out2]. + packet := &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxOut: []*wire.TxOut{changeOut, out1, out2}, + }, + } + + // Act: Call findChangeIndex to locate the change output within the + // sorted packet. + idx, err := findChangeIndex(changeOut, packet) + + // Assert: Verify that no error occurred and the change index found in + // the packet is 0, matching its new sorted position. + require.NoError(t, err) + require.Equal(t, int32(0), idx) + + // Act: Call findChangeIndex for the case with no change output (nil). + idx, err = findChangeIndex(nil, packet) + + // Assert: Verify that no error occurred and the returned index is -1, + // correctly indicating the absence of a change output. + require.NoError(t, err) + require.Equal(t, int32(-1), idx) + + // Act: Call findChangeIndex for a change output not present in the + // packet. + unknownOut := &wire.TxOut{Value: 9999, PkScript: []byte{4}} + idx, err = findChangeIndex(unknownOut, packet) + + // Assert: Verify that no error occurred and the returned index is -1. + require.NoError(t, err) + require.Equal(t, int32(-1), idx) +} + +// TestAddChangeOutputInfoSuccess tests that addChangeOutputInfo correctly adds +// derivation information to the change output. +func TestAddChangeOutputInfoSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys and address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + // Arrange: Create an AuthoredTx with a change output at index 0. + changeOut := &wire.TxOut{ + Value: 500, + PkScript: p2wkhScript, + } + authoredTx := &txauthor.AuthoredTx{ + Tx: &wire.MsgTx{ + TxOut: []*wire.TxOut{changeOut}, + }, + ChangeIndex: 0, + } + + // Arrange: Create a PSBT packet with a corresponding output. + packet, err := psbt.NewFromUnsignedTx(authoredTx.Tx) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock Address lookup. + mocks.addrStore.On( + "Address", mock.Anything, + mock.MatchedBy(func(addr btcutil.Address) bool { + return addr.String() == p2wkhAddr.String() + }), + ).Return(mocks.pubKeyAddr, nil) + + // Arrange: Mock ManagedPubKeyAddress methods. + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + mocks.pubKeyAddr.On("PubKey").Return(pubKey) + // Removed Imported() as addChangeOutputInfo does not call it. + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0084, waddrmgr.DerivationPath{}, true, + ) + + // Act: Call addChangeOutputInfo. + err = w.addChangeOutputInfo(t.Context(), packet, authoredTx) + + // Assert: Verify success and that derivation info is added. + require.NoError(t, err) + require.Len(t, packet.Outputs[0].Bip32Derivation, 1) + require.Equal( + t, pubKey.SerializeCompressed(), + packet.Outputs[0].Bip32Derivation[0].PubKey, + ) +} + +// TestAddChangeOutputInfoErrScriptFail tests that addChangeOutputInfo returns +// an error if the script cannot be resolved (e.g. address lookup fails). +func TestAddChangeOutputInfoErrScriptFail(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys/address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + // Arrange: Create authoredTx with change output. + authoredTx := &txauthor.AuthoredTx{ + Tx: &wire.MsgTx{ + TxOut: []*wire.TxOut{{ + Value: 500, PkScript: p2wkhScript, + }}, + }, + ChangeIndex: 0, + } + packet, err := psbt.NewFromUnsignedTx(authoredTx.Tx) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock Address lookup to fail. + mocks.addrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(nil, errDb) + + // Act: Call addChangeOutputInfo. + err = w.addChangeOutputInfo(t.Context(), packet, authoredTx) + + // Assert: Verify error (from ScriptForOutput). + require.ErrorIs(t, err, errDb) +} + +// TestAddChangeOutputInfoErrNotPubKey tests that addChangeOutputInfo returns +// ErrChangeAddressNotManagedPubKey if the change address is not a pubkey addr. +func TestAddChangeOutputInfoErrNotPubKey(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys/address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + authoredTx := &txauthor.AuthoredTx{ + Tx: &wire.MsgTx{ + TxOut: []*wire.TxOut{{ + Value: 500, PkScript: p2wkhScript, + }}, + }, + ChangeIndex: 0, + } + packet, err := psbt.NewFromUnsignedTx(authoredTx.Tx) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock Address lookup to return a generic address. + mocks.addrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(mocks.addr, nil) + mocks.addr.On("Address").Return(p2wkhAddr) + + // Act: Call addChangeOutputInfo. + err = w.addChangeOutputInfo(t.Context(), packet, authoredTx) + + // Assert: Verify error (ErrNotPubKeyAddress from ScriptForOutput + // check). + require.ErrorIs(t, err, ErrNotPubKeyAddress) +} + +// TestAddChangeOutputInfoErrDerivationUnknown tests that addChangeOutputInfo +// returns an error if the change address has no derivation info. +func TestAddChangeOutputInfoErrDerivationUnknown(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys/address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + authoredTx := &txauthor.AuthoredTx{ + Tx: &wire.MsgTx{ + TxOut: []*wire.TxOut{{ + Value: 500, PkScript: p2wkhScript, + }}, + }, + ChangeIndex: 0, + } + packet, err := psbt.NewFromUnsignedTx(authoredTx.Tx) + require.NoError(t, err) + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock Address lookup. + mocks.addrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil) + + // Arrange: Mock ManagedPubKeyAddress methods. + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + // PubKey is not called because DerivationInfo returns false. + // DerivationInfo returns false (unknown/imported). + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScope{}, waddrmgr.DerivationPath{}, false, + ) + + // Act: Call addChangeOutputInfo. + err = w.addChangeOutputInfo(t.Context(), packet, authoredTx) + + // Assert: Verify error. + require.ErrorContains(t, err, "change addr is an imported addr") +} + +// TestPopulatePsbtPacketErrors tests error paths in populatePsbtPacket. +func TestPopulatePsbtPacketErrors(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + // Input Address (Valid) + addrIn, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + scriptIn, err := txscript.PayToAddrScript(addrIn) + require.NoError(t, err) + + // Output Address (Valid struct, but will mock failure) + addrOut, err := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + scriptOut, err := txscript.PayToAddrScript(addrOut) + require.NoError(t, err) + + txHash := chainhash.Hash{1} + authoredTx := &txauthor.AuthoredTx{ + Tx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: txHash, + Index: 0, + }, + }}, + TxOut: []*wire.TxOut{{ + Value: 500, + PkScript: scriptOut, + }}, + }, + ChangeIndex: 0, // Output 0 is change + } + packet := &psbt.Packet{} + + t.Run("DecorateInputs fails", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + + // Mock TxDetails failure (DecorateInputs -> + // fetchAndValidateUtxo) + mocks.txStore.On("TxDetails", mock.Anything, mock.Anything). + Return(nil, errDb) + + _, _, err := w.populatePsbtPacket( + t.Context(), packet, authoredTx, + ) + require.ErrorIs(t, err, errDb) + }) + + t.Run("addChangeOutputInfo fails", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + + // Mock TxDetails success (DecorateInputs) + txDetails := &wtxmgr.TxDetails{ + TxRecord: wtxmgr.TxRecord{ + MsgTx: wire.MsgTx{ + TxOut: []*wire.TxOut{{ + Value: 1000, + PkScript: scriptIn, + }}, + }, + }, + Credits: []wtxmgr.CreditRecord{{Index: 0}}, + } + mocks.txStore.On("TxDetails", mock.Anything, mock.Anything). + Return(txDetails, nil) + + // Mock Address lookup for Input (Success) + mocks.addrStore.On( + "Address", mock.Anything, + mock.MatchedBy(func(a btcutil.Address) bool { + return a.String() == addrIn.String() + }), + ).Return(mocks.pubKeyAddr, nil) + + mocks.pubKeyAddr.On("Imported").Return(false) + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0084, waddrmgr.DerivationPath{}, + true, + ) + mocks.pubKeyAddr.On("PubKey").Return(pubKey) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + + // Mock Address lookup for Output (Fail) + mocks.addrStore.On( + "Address", mock.Anything, + mock.MatchedBy(func(a btcutil.Address) bool { + return a.String() == addrOut.String() + }), + ).Return(nil, errDb) + + _, _, err := w.populatePsbtPacket( + t.Context(), packet, authoredTx, + ) + require.ErrorIs(t, err, errDb) + }) +} + +// TestPopulatePsbtPacketSuccess tests that populatePsbtPacket correctly +// updates the packet with the transaction, decorates inputs, adds change info, +// and sorts the packet. +func TestPopulatePsbtPacketSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys/address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + // Arrange: Create AuthoredTx with 1 input and 2 outputs. + // Output 0: Change (Value 1001) + // Output 1: Payment (Value 1000) + txHash := chainhash.Hash{} + changeOut := &wire.TxOut{ + Value: 1001, + PkScript: p2wkhScript, + } + paymentOut := &wire.TxOut{ + Value: 1000, + PkScript: []byte{0x00}, // Simple script + } + + authoredTx := &txauthor.AuthoredTx{ + Tx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: txHash, + Index: 0, + }, + }}, + TxOut: []*wire.TxOut{changeOut, paymentOut}, + }, + ChangeIndex: 0, + } + + // Arrange: Create empty packet (will be overwritten). + packet := &psbt.Packet{} + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock TxDetails for input decoration. + txDetails := &wtxmgr.TxDetails{ + TxRecord: wtxmgr.TxRecord{ + MsgTx: wire.MsgTx{ + TxOut: []*wire.TxOut{{ + Value: 1000, PkScript: p2wkhScript, + }}, + }, + }, + Credits: []wtxmgr.CreditRecord{{Index: 0}}, + } + mocks.txStore.On("TxDetails", mock.Anything, mock.Anything). + Return(txDetails, nil) + + // Arrange: Mock Address lookup (used for both input decoration and + // change output info). + mocks.addrStore.On("Address", mock.Anything, mock.Anything). + Return(mocks.pubKeyAddr, nil) + + // Arrange: Mock ManagedPubKeyAddress methods. + mocks.pubKeyAddr.On("Imported").Return(false) + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0084, waddrmgr.DerivationPath{}, true, + ) + mocks.pubKeyAddr.On("PubKey").Return(pubKey) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + + // Act: Call populatePsbtPacket. + updatedPacket, changeIdx, err := w.populatePsbtPacket( + t.Context(), packet, authoredTx, + ) + + // Assert: Verify success. + require.NoError(t, err) + require.NotNil(t, updatedPacket) + + // Assert: Verify that the returned changeIdx points to the change + // output. We know the change output has Value 1001. + require.GreaterOrEqual(t, changeIdx, int32(0)) + require.Less(t, changeIdx, int32(len(updatedPacket.UnsignedTx.TxOut))) + require.Equal( + t, int64(1001), updatedPacket.UnsignedTx.TxOut[changeIdx].Value, + ) + + // Assert: Verify that the decorated output is indeed the change + // output. The test setup ensures only the change address (p2wkhAddr) + // returns derivation info in the mock. The payment output (simple + // script) won't trigger address lookup that leads to derivation info + // in this specific mock setup. + require.Len(t, updatedPacket.Outputs[changeIdx].Bip32Derivation, 1) + require.Equal( + t, pubKey.SerializeCompressed(), + updatedPacket.Outputs[changeIdx].Bip32Derivation[0].PubKey, + ) + + // Assert: Input decorated. + require.Len(t, updatedPacket.Inputs, 1) + require.NotNil(t, updatedPacket.Inputs[0].WitnessUtxo) +} + +// TestFundPsbtWorkflow tests the high-level FundPsbt workflow with manual +// inputs. +func TestFundPsbtWorkflow(t *testing.T) { + t.Parallel() + + // Arrange: Setup private and public keys for a P2WKH address. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + // Arrange: Create a PSBT with one input and one output to simulate a + // transaction that needs funding and decoration. + // Input: 1.0 BTC (100,000,000 sat) + // Output: 0.5 BTC (50,000,000 sat) + // Fee: ~1000 sat (simplified) + // Expected Change: ~0.5 BTC (after fees) + txHash := chainhash.Hash{1} + outPoint := wire.OutPoint{Hash: txHash, Index: 0} + inputAmount := btcutil.Amount(100000000) + outputAmount := btcutil.Amount(50000000) + + unsignedTx := wire.NewMsgTx(2) + unsignedTx.AddTxIn(&wire.TxIn{PreviousOutPoint: outPoint}) + unsignedTx.AddTxOut(&wire.TxOut{ + Value: int64(outputAmount), PkScript: p2wkhScript, + }) + + packet, err := psbt.NewFromUnsignedTx(unsignedTx) + require.NoError(t, err) + + // Arrange: Mock data for UTXO and Transaction Details required by + // internal calls. + credit := &wtxmgr.Credit{ + OutPoint: outPoint, + Amount: inputAmount, + PkScript: p2wkhScript, + } + + txDetails := &wtxmgr.TxDetails{ + TxRecord: wtxmgr.TxRecord{ + MsgTx: wire.MsgTx{ + TxOut: []*wire.TxOut{ + { + Value: int64(inputAmount), + PkScript: p2wkhScript, + }, + }, + }, + }, + Credits: []wtxmgr.CreditRecord{ + {Index: 0}, + }, + } + + // Arrange: Define the FundIntent for the PSBT, including fee rate and + // change source. + intent := &FundIntent{ + Packet: packet, + FeeRate: btcunit.NewSatPerKVByte(1000), + ChangeSource: &ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0084, + }, + } + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock the internal dependencies for the FundPsbt workflow. + // 1. Mock `txStore.GetUtxo` for `createManualInputSource`: + // Expect a call with any context and the specified outpoint, + // returning our predefined credit and no error. + mocks.txStore.On("GetUtxo", mock.Anything, outPoint).Return(credit, nil) + + // 2. Mock `addrStore.FetchScopedKeyManager` to retrieve the account + // manager: + // Expect a call with the BIP0084 key scope, returning the mock + // account manager and no error. + mocks.addrStore.On( + "FetchScopedKeyManager", waddrmgr.KeyScopeBIP0084, + ).Return(mocks.accountManager, nil) + + // 3. Mock `accountManager.LookupAccount` for the default account: + // Expect a call with any context and "default" account name, + // returning the default account number and no error. + mocks.accountManager.On("LookupAccount", mock.Anything, "default"). + Return(uint32(waddrmgr.DefaultAccountNum), nil) + + // 4. Mock `accountManager.AccountProperties` to return properties for + // the default account: + // Expect a call with any context and the default account number, + // returning predefined account properties and no error. + mocks.accountManager.On( + "AccountProperties", + mock.Anything, + uint32(waddrmgr.DefaultAccountNum), + ).Return(&waddrmgr.AccountProperties{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0084, + }, nil) + + // 5. Mock `accountManager.NextInternalAddresses` to generate a change + // address: + // Expect a call to generate one internal address for the default + // account, returning our mock managed address and no error. + changeAddr := p2wkhAddr // Reusing p2wkhAddr for simplicity as change + mockManagedAddr := mocks.pubKeyAddr + mocks.accountManager.On( + "NextInternalAddresses", + mock.Anything, + uint32(waddrmgr.DefaultAccountNum), + uint32(1), + ).Return([]waddrmgr.ManagedAddress{mockManagedAddr}, nil) + + // 6. Mock `mockManagedAddr.Address` to return the change address: + // Expect a call to get the address from the mock managed address, + // returning our predefined P2WKH address. + mockManagedAddr.On("Address").Return(changeAddr) + + // 7. Mock `txStore.TxDetails` for `fetchAndValidateUtxo` during + // `DecorateInputs`: + // Expect a call to retrieve transaction details for the input's + // hash, returning our predefined `txDetails` and no error. + mocks.txStore.On( + "TxDetails", + mock.Anything, + mock.MatchedBy(func(h *chainhash.Hash) bool { + return h.IsEqual(&txHash) + }), + ).Return(txDetails, nil) + + // 8. Mock `addrStore.Address` for `decorateInput` during + // `DecorateInputs`: + // Expect a call to look up the address by script, returning our + // mock managed public key address and no error. + mocks.addrStore.On( + "Address", + mock.Anything, + mock.MatchedBy(func(addr btcutil.Address) bool { + return addr.String() == p2wkhAddr.String() + }), + ).Return(mocks.pubKeyAddr, nil) + + // 9. Mock `ManagedPubKeyAddress` methods for `decorateInput`: + // Expect calls to get imported status, derivation info, public key, + // and address type, returning predefined values. + mocks.pubKeyAddr.On("Imported").Return(false) + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0084, waddrmgr.DerivationPath{}, true, + ) + mocks.pubKeyAddr.On("PubKey").Return(pubKey) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + + // Act: Execute the FundPsbt workflow with the configured intent. + fundedPacket, changeIndex, err := w.FundPsbt(t.Context(), intent) + + // Assert: Verify that no error occurred, a funded PSBT packet is + // returned, and a valid change index is provided. + require.NoError(t, err) + require.NotNil(t, fundedPacket) + require.GreaterOrEqual(t, changeIndex, int32(0)) +} + +// TestFundPsbtDecorateFailure tests that FundPsbt returns an error if the +// internal DecorateInputs call fails (e.g. due to database error). +func TestFundPsbtDecorateFailure(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys/address. + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + // Arrange: Create packet with 1 input. + txHash := chainhash.Hash{1} + outPoint := wire.OutPoint{Hash: txHash, Index: 0} + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: outPoint}) + tx.AddTxOut(&wire.TxOut{Value: 90000, PkScript: p2wkhScript}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // Arrange: Intent with manual inputs (so CreateTransaction uses + // GetUtxo). + intent := &FundIntent{ + Packet: packet, + FeeRate: btcunit.NewSatPerKVByte(1000), + ChangeSource: &ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0084, + }, + } + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock GetUtxo for CreateTransaction (Success). + credit := &wtxmgr.Credit{ + OutPoint: outPoint, + Amount: 100000, + PkScript: p2wkhScript, + } + mocks.txStore.On("GetUtxo", mock.Anything, outPoint).Return(credit, nil) + + // Arrange: Mock TxDetails for DecorateInputs (Failure). + // This triggers the error in populatePsbtPacket -> DecorateInputs. + mocks.txStore.On( + "TxDetails", mock.Anything, + mock.MatchedBy(func(h *chainhash.Hash) bool { + return h.IsEqual(&txHash) + }), + ).Return(nil, errDb) + + // Arrange: Mock account manager for change address generation, which is + // required because the input (100k) exceeds the output (90k) + fees. + mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(mocks.accountManager, nil) + mocks.accountManager.On("LookupAccount", mock.Anything, "default"). + Return(uint32(waddrmgr.DefaultAccountNum), nil) + mocks.accountManager.On( + "AccountProperties", mock.Anything, + uint32(waddrmgr.DefaultAccountNum), + ).Return(&waddrmgr.AccountProperties{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0084, + }, nil) + // Change address generation. + mocks.accountManager.On( + "NextInternalAddresses", mock.Anything, mock.Anything, + mock.Anything, + ).Return([]waddrmgr.ManagedAddress{mocks.pubKeyAddr}, nil) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + + // Act: FundPsbt. + _, _, err = w.FundPsbt(t.Context(), intent) + + // Assert: Should fail due to DecorateInputs error. + require.ErrorIs(t, err, errDb) +} From 0ccf71f715b96ebd360a7914a9b8dbe69a9aca01 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 18 Nov 2025 02:56:17 +0800 Subject: [PATCH 194/691] wallet: move deprecated PSBT methods into `deprecated.go` --- wallet/deprecated.go | 427 ++++++++++++++++++++++++++++++++++++++++++ wallet/psbt.go | 432 ------------------------------------------- 2 files changed, 427 insertions(+), 432 deletions(-) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 595a640358..11bcf6fe59 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -2,6 +2,7 @@ package wallet import ( + "bytes" "context" "errors" "fmt" @@ -14,6 +15,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" + "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -529,3 +531,428 @@ func (w *Wallet) CreateSimpleTx(coinSelectKeyScope *waddrmgr.KeyScope, resp := <-req.resp return resp.tx, resp.err } + +// FundPsbtDeprecated creates a fully populated PSBT packet that contains +// enough inputs to fund the outputs specified in the passed in packet with the +// specified fee rate. If there is change left, a change output from the wallet +// is added and the index of the change output is returned. If no custom change +// scope is specified, we will use the coin selection scope (if not nil) or the +// BIP0086 scope by default. Otherwise, no additional output is created and the +// index -1 is returned. +// +// NOTE: If the packet doesn't contain any inputs, coin selection is performed +// automatically, only selecting inputs from the account based on the given key +// scope and account number. If a key scope is not specified, then inputs from +// accounts matching the account number provided across all key scopes may be +// selected. This is done to handle the default account case, where a user wants +// to fund a PSBT with inputs regardless of their type (NP2WKH, P2WKH, etc.). If +// the packet does contain any inputs, it is assumed that full coin selection +// happened externally and no additional inputs are added. If the specified +// inputs aren't enough to fund the outputs with the given fee rate, an error is +// returned. +// +// NOTE: A caller of the method should hold the global coin selection lock of +// the wallet. However, no UTXO specific lock lease is acquired for any of the +// selected/validated inputs by this method. It is in the caller's +// responsibility to lock the inputs before handing the partial transaction out. +func (w *Wallet) FundPsbtDeprecated(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, + minConfs int32, account uint32, feeSatPerKB btcutil.Amount, + coinSelectionStrategy CoinSelectionStrategy, + optFuncs ...TxCreateOption) (int32, error) { + + // Make sure the packet is well formed. We only require there to be at + // least one input or output. + err := psbt.VerifyInputOutputLen(packet, false, false) + if err != nil { + return 0, err + } + + if len(packet.UnsignedTx.TxIn) == 0 && len(packet.UnsignedTx.TxOut) == 0 { + return 0, fmt.Errorf("PSBT packet must contain at least one " + + "input or output") + } + + txOut := packet.UnsignedTx.TxOut + txIn := packet.UnsignedTx.TxIn + + // Make sure none of the outputs are dust. + for _, output := range txOut { + // When checking an output for things like dusty-ness, we'll + // use the default mempool relay fee rather than the target + // effective fee rate to ensure accuracy. Otherwise, we may + // mistakenly mark small-ish, but not quite dust output as + // dust. + err := txrules.CheckOutput(output, txrules.DefaultRelayFeePerKb) + if err != nil { + return 0, err + } + } + + // Let's find out the amount to fund first. + amt := int64(0) + for _, output := range txOut { + amt += output.Value + } + + var tx *txauthor.AuthoredTx + switch { + // We need to do coin selection. + case len(txIn) == 0: + // We ask the underlying wallet to fund a TX for us. This + // includes everything we need, specifically fee estimation and + // change address creation. + tx, err = w.CreateSimpleTx( + keyScope, account, packet.UnsignedTx.TxOut, minConfs, + feeSatPerKB, coinSelectionStrategy, false, + optFuncs..., + ) + if err != nil { + return 0, fmt.Errorf("error creating funding TX: %w", + err) + } + + // Copy over the inputs now then collect all UTXO information + // that we can and attach them to the PSBT as well. We don't + // include the witness as the resulting PSBT isn't expected not + // should be signed yet. + packet.UnsignedTx.TxIn = tx.Tx.TxIn + packet.Inputs = make([]psbt.PInput, len(packet.UnsignedTx.TxIn)) + + for idx := range packet.UnsignedTx.TxIn { + // We don't want to include the witness or any script + // on the unsigned TX just yet. + packet.UnsignedTx.TxIn[idx].Witness = wire.TxWitness{} + packet.UnsignedTx.TxIn[idx].SignatureScript = nil + } + + err := w.DecorateInputsDeprecated(packet, true) + if err != nil { + return 0, err + } + + // If there are inputs, we need to check if they're sufficient and add + // a change output if necessary. + default: + // Make sure all inputs provided are actually ours. + packet.Inputs = make([]psbt.PInput, len(packet.UnsignedTx.TxIn)) + + for idx := range packet.UnsignedTx.TxIn { + // We don't want to include the witness or any script + // on the unsigned TX just yet. + packet.UnsignedTx.TxIn[idx].Witness = wire.TxWitness{} + packet.UnsignedTx.TxIn[idx].SignatureScript = nil + } + + err := w.DecorateInputsDeprecated(packet, true) + if err != nil { + return 0, err + } + + // We can leverage the fee calculation of the txauthor package + // if we provide the selected UTXOs as a coin source. We just + // need to make sure we always return the full list of user- + // selected UTXOs rather than a subset, otherwise our change + // amount will be off (in case the user selected multiple UTXOs + // that are large enough on their own). That's why we use our + // own static input source creator instead of the more generic + // makeInputSource() that selects a subset that is "large + // enough". + credits := make([]wtxmgr.Credit, len(txIn)) + for idx, in := range txIn { + utxo := packet.Inputs[idx].WitnessUtxo + credits[idx] = wtxmgr.Credit{ + OutPoint: in.PreviousOutPoint, + Amount: btcutil.Amount(utxo.Value), + PkScript: utxo.PkScript, + } + } + inputSource := constantInputSource(credits) + + // Build the TxCreateOption to retrieve the change scope. + opts := defaultTxCreateOptions() + for _, optFunc := range optFuncs { + optFunc(opts) + } + + if opts.changeKeyScope == nil { + opts.changeKeyScope = keyScope + } + + // The addrMgrWithChangeSource function of the wallet creates a + // new change address. The address manager uses OnCommit on the + // walletdb tx to update the in-memory state of the account + // state. But because the commit happens _after_ the account + // manager internal lock has been released, there is a chance + // for the address index to be accessed concurrently, even + // though the closure in OnCommit re-acquires the lock. To avoid + // this issue, we surround the whole address creation process + // with a lock. + w.newAddrMtx.Lock() + + // We also need a change source which needs to be able to insert + // a new change address into the database. + err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { + _, changeSource, err := w.addrMgrWithChangeSource( + dbtx, opts.changeKeyScope, account, + ) + if err != nil { + return err + } + + // Ask the txauthor to create a transaction with our + // selected coins. This will perform fee estimation and + // add a change output if necessary. + tx, err = txauthor.NewUnsignedTransaction( + txOut, feeSatPerKB, inputSource, changeSource, + ) + if err != nil { + return fmt.Errorf("fee estimation not "+ + "successful: %w", err) + } + + return nil + }) + w.newAddrMtx.Unlock() + + if err != nil { + return 0, fmt.Errorf("could not add change address to "+ + "database: %w", err) + } + } + + // If there is a change output, we need to copy it over to the PSBT now. + var changeTxOut *wire.TxOut + if tx.ChangeIndex >= 0 { + changeTxOut = tx.Tx.TxOut[tx.ChangeIndex] + packet.UnsignedTx.TxOut = append( + packet.UnsignedTx.TxOut, changeTxOut, + ) + + addr, _, _, err := w.ScriptForOutputDeprecated(changeTxOut) + if err != nil { + return 0, fmt.Errorf("error querying wallet for "+ + "change addr: %w", err) + } + + changeOutputInfo, err := createOutputInfo(changeTxOut, addr) + if err != nil { + return 0, fmt.Errorf("error adding output info to "+ + "change output: %w", err) + } + + packet.Outputs = append(packet.Outputs, *changeOutputInfo) + } + + // Now that we have the final PSBT ready, we can sort it according to + // BIP 69. This will sort the wire inputs and outputs and move the + // partial inputs and outputs accordingly. + err = psbt.InPlaceSort(packet) + if err != nil { + return 0, fmt.Errorf("could not sort PSBT: %w", err) + } + + // The change output index might have changed after the sorting. We need + // to find our index again. + changeIndex := int32(-1) + if changeTxOut != nil { + for idx, txOut := range packet.UnsignedTx.TxOut { + if psbt.TxOutsEqual(changeTxOut, txOut) { + changeIndex = int32(idx) + break + } + } + } + + return changeIndex, nil +} + +// DecorateInputsDeprecated fetches the UTXO information of all inputs it can identify and +// adds the required information to the package's inputs. The failOnUnknown +// boolean controls whether the method should return an error if it cannot +// identify an input or if it should just skip it. +func (w *Wallet) DecorateInputsDeprecated(packet *psbt.Packet, failOnUnknown bool) error { + for idx := range packet.Inputs { + txIn := packet.UnsignedTx.TxIn[idx] + + tx, utxo, derivationPath, _, err := w.FetchInputInfo( + &txIn.PreviousOutPoint, + ) + + switch { + // If the error just means it's not an input our wallet controls + // and the user doesn't care about that, then we can just skip + // this input and continue. + case errors.Is(err, ErrNotMine) && !failOnUnknown: + continue + + case err != nil: + return fmt.Errorf("error fetching UTXO: %w", err) + } + + addr, witnessProgram, _, err := w.ScriptForOutputDeprecated( + utxo, + ) + if err != nil { + return fmt.Errorf("error fetching UTXO script: %w", err) + } + + switch { + case txscript.IsPayToTaproot(utxo.PkScript): + addInputInfoSegWitV1( + &packet.Inputs[idx], utxo, derivationPath, + ) + + default: + addInputInfoSegWitV0( + &packet.Inputs[idx], tx, utxo, derivationPath, + addr, witnessProgram, + ) + } + } + + return nil +} + +// FinalizePsbt expects a partial transaction with all inputs and outputs fully +// declared and tries to sign all inputs that belong to the wallet. Our wallet +// must be the last signer of the transaction. That means, if there are any +// unsigned non-witness inputs or inputs without UTXO information attached or +// inputs without witness data that do not belong to the wallet, this method +// will fail. If no error is returned, the PSBT is ready to be extracted and the +// final TX within to be broadcast. +// +// NOTE: This method does NOT publish the transaction after it's been finalized +// successfully. +func (w *Wallet) FinalizePsbtDeprecated(keyScope *waddrmgr.KeyScope, account uint32, + packet *psbt.Packet) error { + + // Let's check that this is actually something we can and want to sign. + // We need at least one input and one output. In addition each + // input needs nonWitness Utxo or witness Utxo data specified. + err := psbt.InputsReadyToSign(packet) + if err != nil { + return err + } + + // Go through each input that doesn't have final witness data attached + // to it already and try to sign it. We do expect that we're the last + // ones to sign. If there is any input without witness data that we + // cannot sign because it's not our UTXO, this will be a hard failure. + tx := packet.UnsignedTx + sigHashes := txscript.NewTxSigHashes(tx, PsbtPrevOutputFetcher(packet)) + for idx, txIn := range tx.TxIn { + in := packet.Inputs[idx] + + // We can only sign if we have UTXO information available. We + // can just continue here as a later step will fail with a more + // precise error message. + if in.WitnessUtxo == nil && in.NonWitnessUtxo == nil { + continue + } + + // Skip this input if it's got final witness data attached. + if len(in.FinalScriptWitness) > 0 { + continue + } + + // We can only sign this input if it's ours, so we try to map it + // to a coin we own. If we can't, then we'll continue as it + // isn't our input. + fullTx, txOut, _, _, err := w.FetchInputInfo( + &txIn.PreviousOutPoint, + ) + if err != nil { + continue + } + + // Find out what UTXO we are signing. Wallets _should_ always + // provide the full non-witness UTXO for segwit v0. + var signOutput *wire.TxOut + if in.NonWitnessUtxo != nil { + prevIndex := txIn.PreviousOutPoint.Index + signOutput = in.NonWitnessUtxo.TxOut[prevIndex] + + if !psbt.TxOutsEqual(txOut, signOutput) { + return fmt.Errorf("found UTXO %#v but it "+ + "doesn't match PSBT's input %v", txOut, + signOutput) + } + + if fullTx.TxHash() != txIn.PreviousOutPoint.Hash { + return fmt.Errorf("found UTXO tx %v but it "+ + "doesn't match PSBT's input %v", + fullTx.TxHash(), + txIn.PreviousOutPoint.Hash) + } + } + + // Fall back to witness UTXO only for older wallets. + if in.WitnessUtxo != nil { + signOutput = in.WitnessUtxo + + if !psbt.TxOutsEqual(txOut, signOutput) { + return fmt.Errorf("found UTXO %#v but it "+ + "doesn't match PSBT's input %v", txOut, + signOutput) + } + } + + // Finally, if the input doesn't belong to a watch-only account, + // then we'll sign it as is, and populate the input with the + // witness and sigScript (if needed). + watchOnly := false + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + var err error + if keyScope == nil { + // If a key scope wasn't specified, then coin + // selection was performed from the default + // wallet accounts (NP2WKH, P2WKH, P2TR), so any + // key scope provided doesn't impact the result + // of this call. + watchOnly, err = w.addrStore.IsWatchOnlyAccount( + ns, waddrmgr.KeyScopeBIP0084, account, + ) + } else { + watchOnly, err = w.addrStore.IsWatchOnlyAccount( + ns, *keyScope, account, + ) + } + return err + }) + if err != nil { + return fmt.Errorf("unable to determine if account is "+ + "watch-only: %w", err) + } + if watchOnly { + continue + } + + witness, sigScript, err := w.ComputeInputScript( + tx, signOutput, idx, sigHashes, in.SighashType, nil, + ) + if err != nil { + return fmt.Errorf("error computing input script for "+ + "input %d: %w", idx, err) + } + + // Serialize the witness format from the stack representation to + // the wire representation. + var witnessBytes bytes.Buffer + err = psbt.WriteTxWitness(&witnessBytes, witness) + if err != nil { + return fmt.Errorf("error serializing witness: %w", err) + } + packet.Inputs[idx].FinalScriptWitness = witnessBytes.Bytes() + packet.Inputs[idx].FinalScriptSig = sigScript + } + + // Make sure the PSBT itself thinks it's finalized and ready to be + // broadcast. + err = psbt.MaybeFinalizeAll(packet) + if err != nil { + return fmt.Errorf("error finalizing PSBT: %w", err) + } + + return nil +} diff --git a/wallet/psbt.go b/wallet/psbt.go index f1480ab696..1782058ab9 100644 --- a/wallet/psbt.go +++ b/wallet/psbt.go @@ -5,303 +5,15 @@ package wallet import ( - "bytes" - "errors" "fmt" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/wallet/txauthor" - "github.com/btcsuite/btcwallet/wallet/txrules" - "github.com/btcsuite/btcwallet/walletdb" - "github.com/btcsuite/btcwallet/wtxmgr" ) -// FundPsbtDeprecated creates a fully populated PSBT packet that contains -// enough inputs to fund the outputs specified in the passed in packet with the -// specified fee rate. If there is change left, a change output from the wallet -// is added and the index of the change output is returned. If no custom change -// scope is specified, we will use the coin selection scope (if not nil) or the -// BIP0086 scope by default. Otherwise, no additional output is created and the -// index -1 is returned. -// -// NOTE: If the packet doesn't contain any inputs, coin selection is performed -// automatically, only selecting inputs from the account based on the given key -// scope and account number. If a key scope is not specified, then inputs from -// accounts matching the account number provided across all key scopes may be -// selected. This is done to handle the default account case, where a user wants -// to fund a PSBT with inputs regardless of their type (NP2WKH, P2WKH, etc.). If -// the packet does contain any inputs, it is assumed that full coin selection -// happened externally and no additional inputs are added. If the specified -// inputs aren't enough to fund the outputs with the given fee rate, an error is -// returned. -// -// NOTE: A caller of the method should hold the global coin selection lock of -// the wallet. However, no UTXO specific lock lease is acquired for any of the -// selected/validated inputs by this method. It is in the caller's -// responsibility to lock the inputs before handing the partial transaction out. -func (w *Wallet) FundPsbtDeprecated(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, - minConfs int32, account uint32, feeSatPerKB btcutil.Amount, - coinSelectionStrategy CoinSelectionStrategy, - optFuncs ...TxCreateOption) (int32, error) { - - // Make sure the packet is well formed. We only require there to be at - // least one input or output. - err := psbt.VerifyInputOutputLen(packet, false, false) - if err != nil { - return 0, err - } - - if len(packet.UnsignedTx.TxIn) == 0 && len(packet.UnsignedTx.TxOut) == 0 { - return 0, fmt.Errorf("PSBT packet must contain at least one " + - "input or output") - } - - txOut := packet.UnsignedTx.TxOut - txIn := packet.UnsignedTx.TxIn - - // Make sure none of the outputs are dust. - for _, output := range txOut { - // When checking an output for things like dusty-ness, we'll - // use the default mempool relay fee rather than the target - // effective fee rate to ensure accuracy. Otherwise, we may - // mistakenly mark small-ish, but not quite dust output as - // dust. - err := txrules.CheckOutput(output, txrules.DefaultRelayFeePerKb) - if err != nil { - return 0, err - } - } - - // Let's find out the amount to fund first. - amt := int64(0) - for _, output := range txOut { - amt += output.Value - } - - var tx *txauthor.AuthoredTx - switch { - // We need to do coin selection. - case len(txIn) == 0: - // We ask the underlying wallet to fund a TX for us. This - // includes everything we need, specifically fee estimation and - // change address creation. - tx, err = w.CreateSimpleTx( - keyScope, account, packet.UnsignedTx.TxOut, minConfs, - feeSatPerKB, coinSelectionStrategy, false, - optFuncs..., - ) - if err != nil { - return 0, fmt.Errorf("error creating funding TX: %w", - err) - } - - // Copy over the inputs now then collect all UTXO information - // that we can and attach them to the PSBT as well. We don't - // include the witness as the resulting PSBT isn't expected not - // should be signed yet. - packet.UnsignedTx.TxIn = tx.Tx.TxIn - packet.Inputs = make([]psbt.PInput, len(packet.UnsignedTx.TxIn)) - - for idx := range packet.UnsignedTx.TxIn { - // We don't want to include the witness or any script - // on the unsigned TX just yet. - packet.UnsignedTx.TxIn[idx].Witness = wire.TxWitness{} - packet.UnsignedTx.TxIn[idx].SignatureScript = nil - } - - err := w.DecorateInputsDeprecated(packet, true) - if err != nil { - return 0, err - } - - // If there are inputs, we need to check if they're sufficient and add - // a change output if necessary. - default: - // Make sure all inputs provided are actually ours. - packet.Inputs = make([]psbt.PInput, len(packet.UnsignedTx.TxIn)) - - for idx := range packet.UnsignedTx.TxIn { - // We don't want to include the witness or any script - // on the unsigned TX just yet. - packet.UnsignedTx.TxIn[idx].Witness = wire.TxWitness{} - packet.UnsignedTx.TxIn[idx].SignatureScript = nil - } - - err := w.DecorateInputsDeprecated(packet, true) - if err != nil { - return 0, err - } - - // We can leverage the fee calculation of the txauthor package - // if we provide the selected UTXOs as a coin source. We just - // need to make sure we always return the full list of user- - // selected UTXOs rather than a subset, otherwise our change - // amount will be off (in case the user selected multiple UTXOs - // that are large enough on their own). That's why we use our - // own static input source creator instead of the more generic - // makeInputSource() that selects a subset that is "large - // enough". - credits := make([]wtxmgr.Credit, len(txIn)) - for idx, in := range txIn { - utxo := packet.Inputs[idx].WitnessUtxo - credits[idx] = wtxmgr.Credit{ - OutPoint: in.PreviousOutPoint, - Amount: btcutil.Amount(utxo.Value), - PkScript: utxo.PkScript, - } - } - inputSource := constantInputSource(credits) - - // Build the TxCreateOption to retrieve the change scope. - opts := defaultTxCreateOptions() - for _, optFunc := range optFuncs { - optFunc(opts) - } - - if opts.changeKeyScope == nil { - opts.changeKeyScope = keyScope - } - - // The addrMgrWithChangeSource function of the wallet creates a - // new change address. The address manager uses OnCommit on the - // walletdb tx to update the in-memory state of the account - // state. But because the commit happens _after_ the account - // manager internal lock has been released, there is a chance - // for the address index to be accessed concurrently, even - // though the closure in OnCommit re-acquires the lock. To avoid - // this issue, we surround the whole address creation process - // with a lock. - w.newAddrMtx.Lock() - - // We also need a change source which needs to be able to insert - // a new change address into the database. - err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { - _, changeSource, err := w.addrMgrWithChangeSource( - dbtx, opts.changeKeyScope, account, - ) - if err != nil { - return err - } - - // Ask the txauthor to create a transaction with our - // selected coins. This will perform fee estimation and - // add a change output if necessary. - tx, err = txauthor.NewUnsignedTransaction( - txOut, feeSatPerKB, inputSource, changeSource, - ) - if err != nil { - return fmt.Errorf("fee estimation not "+ - "successful: %w", err) - } - - return nil - }) - w.newAddrMtx.Unlock() - - if err != nil { - return 0, fmt.Errorf("could not add change address to "+ - "database: %w", err) - } - } - - // If there is a change output, we need to copy it over to the PSBT now. - var changeTxOut *wire.TxOut - if tx.ChangeIndex >= 0 { - changeTxOut = tx.Tx.TxOut[tx.ChangeIndex] - packet.UnsignedTx.TxOut = append( - packet.UnsignedTx.TxOut, changeTxOut, - ) - - addr, _, _, err := w.ScriptForOutputDeprecated(changeTxOut) - if err != nil { - return 0, fmt.Errorf("error querying wallet for "+ - "change addr: %w", err) - } - - changeOutputInfo, err := createOutputInfo(changeTxOut, addr) - if err != nil { - return 0, fmt.Errorf("error adding output info to "+ - "change output: %w", err) - } - - packet.Outputs = append(packet.Outputs, *changeOutputInfo) - } - - // Now that we have the final PSBT ready, we can sort it according to - // BIP 69. This will sort the wire inputs and outputs and move the - // partial inputs and outputs accordingly. - err = psbt.InPlaceSort(packet) - if err != nil { - return 0, fmt.Errorf("could not sort PSBT: %w", err) - } - - // The change output index might have changed after the sorting. We need - // to find our index again. - changeIndex := int32(-1) - if changeTxOut != nil { - for idx, txOut := range packet.UnsignedTx.TxOut { - if psbt.TxOutsEqual(changeTxOut, txOut) { - changeIndex = int32(idx) - break - } - } - } - - return changeIndex, nil -} - -// DecorateInputsDeprecated fetches the UTXO information of all inputs it can identify and -// adds the required information to the package's inputs. The failOnUnknown -// boolean controls whether the method should return an error if it cannot -// identify an input or if it should just skip it. -func (w *Wallet) DecorateInputsDeprecated(packet *psbt.Packet, failOnUnknown bool) error { - for idx := range packet.Inputs { - txIn := packet.UnsignedTx.TxIn[idx] - - tx, utxo, derivationPath, _, err := w.FetchInputInfo( - &txIn.PreviousOutPoint, - ) - - switch { - // If the error just means it's not an input our wallet controls - // and the user doesn't care about that, then we can just skip - // this input and continue. - case errors.Is(err, ErrNotMine) && !failOnUnknown: - continue - - case err != nil: - return fmt.Errorf("error fetching UTXO: %w", err) - } - - addr, witnessProgram, _, err := w.ScriptForOutputDeprecated( - utxo, - ) - if err != nil { - return fmt.Errorf("error fetching UTXO script: %w", err) - } - - switch { - case txscript.IsPayToTaproot(utxo.PkScript): - addInputInfoSegWitV1( - &packet.Inputs[idx], utxo, derivationPath, - ) - - default: - addInputInfoSegWitV0( - &packet.Inputs[idx], tx, utxo, derivationPath, - addr, witnessProgram, - ) - } - } - - return nil -} - // addInputInfoSegWitV0 adds the UTXO and BIP32 derivation info for a // SegWit v0 PSBT input (p2wkh, np2wkh) from the given wallet // information. @@ -407,150 +119,6 @@ func createOutputInfo(txOut *wire.TxOut, return out, nil } -// FinalizePsbt expects a partial transaction with all inputs and outputs fully -// declared and tries to sign all inputs that belong to the wallet. Our wallet -// must be the last signer of the transaction. That means, if there are any -// unsigned non-witness inputs or inputs without UTXO information attached or -// inputs without witness data that do not belong to the wallet, this method -// will fail. If no error is returned, the PSBT is ready to be extracted and the -// final TX within to be broadcast. -// -// NOTE: This method does NOT publish the transaction after it's been finalized -// successfully. -func (w *Wallet) FinalizePsbtDeprecated(keyScope *waddrmgr.KeyScope, account uint32, - packet *psbt.Packet) error { - - // Let's check that this is actually something we can and want to sign. - // We need at least one input and one output. In addition each - // input needs nonWitness Utxo or witness Utxo data specified. - err := psbt.InputsReadyToSign(packet) - if err != nil { - return err - } - - // Go through each input that doesn't have final witness data attached - // to it already and try to sign it. We do expect that we're the last - // ones to sign. If there is any input without witness data that we - // cannot sign because it's not our UTXO, this will be a hard failure. - tx := packet.UnsignedTx - sigHashes := txscript.NewTxSigHashes(tx, PsbtPrevOutputFetcher(packet)) - for idx, txIn := range tx.TxIn { - in := packet.Inputs[idx] - - // We can only sign if we have UTXO information available. We - // can just continue here as a later step will fail with a more - // precise error message. - if in.WitnessUtxo == nil && in.NonWitnessUtxo == nil { - continue - } - - // Skip this input if it's got final witness data attached. - if len(in.FinalScriptWitness) > 0 { - continue - } - - // We can only sign this input if it's ours, so we try to map it - // to a coin we own. If we can't, then we'll continue as it - // isn't our input. - fullTx, txOut, _, _, err := w.FetchInputInfo( - &txIn.PreviousOutPoint, - ) - if err != nil { - continue - } - - // Find out what UTXO we are signing. Wallets _should_ always - // provide the full non-witness UTXO for segwit v0. - var signOutput *wire.TxOut - if in.NonWitnessUtxo != nil { - prevIndex := txIn.PreviousOutPoint.Index - signOutput = in.NonWitnessUtxo.TxOut[prevIndex] - - if !psbt.TxOutsEqual(txOut, signOutput) { - return fmt.Errorf("found UTXO %#v but it "+ - "doesn't match PSBT's input %v", txOut, - signOutput) - } - - if fullTx.TxHash() != txIn.PreviousOutPoint.Hash { - return fmt.Errorf("found UTXO tx %v but it "+ - "doesn't match PSBT's input %v", - fullTx.TxHash(), - txIn.PreviousOutPoint.Hash) - } - } - - // Fall back to witness UTXO only for older wallets. - if in.WitnessUtxo != nil { - signOutput = in.WitnessUtxo - - if !psbt.TxOutsEqual(txOut, signOutput) { - return fmt.Errorf("found UTXO %#v but it "+ - "doesn't match PSBT's input %v", txOut, - signOutput) - } - } - - // Finally, if the input doesn't belong to a watch-only account, - // then we'll sign it as is, and populate the input with the - // witness and sigScript (if needed). - watchOnly := false - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - ns := tx.ReadBucket(waddrmgrNamespaceKey) - var err error - if keyScope == nil { - // If a key scope wasn't specified, then coin - // selection was performed from the default - // wallet accounts (NP2WKH, P2WKH, P2TR), so any - // key scope provided doesn't impact the result - // of this call. - watchOnly, err = w.addrStore.IsWatchOnlyAccount( - ns, waddrmgr.KeyScopeBIP0084, account, - ) - } else { - watchOnly, err = w.addrStore.IsWatchOnlyAccount( - ns, *keyScope, account, - ) - } - return err - }) - if err != nil { - return fmt.Errorf("unable to determine if account is "+ - "watch-only: %w", err) - } - if watchOnly { - continue - } - - witness, sigScript, err := w.ComputeInputScript( - tx, signOutput, idx, sigHashes, in.SighashType, nil, - ) - if err != nil { - return fmt.Errorf("error computing input script for "+ - "input %d: %w", idx, err) - } - - // Serialize the witness format from the stack representation to - // the wire representation. - var witnessBytes bytes.Buffer - err = psbt.WriteTxWitness(&witnessBytes, witness) - if err != nil { - return fmt.Errorf("error serializing witness: %w", err) - } - packet.Inputs[idx].FinalScriptWitness = witnessBytes.Bytes() - packet.Inputs[idx].FinalScriptSig = sigScript - } - - // Make sure the PSBT itself thinks it's finalized and ready to be - // broadcast. - err = psbt.MaybeFinalizeAll(packet) - if err != nil { - return fmt.Errorf("error finalizing PSBT: %w", err) - } - - return nil -} - // PsbtPrevOutputFetcher returns a txscript.PrevOutFetcher built from the UTXO // information in a PSBT packet. func PsbtPrevOutputFetcher(packet *psbt.Packet) *txscript.MultiPrevOutFetcher { From d08aca43abdca80747c11a3f50f43896bff2f345 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Dec 2025 21:23:11 +0800 Subject: [PATCH 195/691] wallet: enforce nilness check in `validateFundIntent` --- wallet/psbt_manager.go | 15 ++++++++++++--- wallet/psbt_manager_test.go | 5 +++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index e91399438b..7c4db882ac 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -45,6 +45,9 @@ var ( // ErrFeeRateNegative is returned when a negative fee rate is provided. ErrFeeRateNegative = errors.New("fee rate cannot be negative") + + // ErrNilFundIntent is returned when a nil FundIntent is provided. + ErrNilFundIntent = errors.New("nil FundIntent") ) // FundIntent represents the user's intent for funding a PSBT. It serves as a @@ -582,12 +585,18 @@ func (w *Wallet) addChangeOutputInfo(ctx context.Context, packet *psbt.Packet, // logic to ensure that the caller has provided a valid intent. // // The following checks are performed: -// 1. The PSBT packet must not be nil. -// 2. If the PSBT has no inputs (automatic coin selection mode), it must have +// 1. The intent must not be nil. +// 2. The PSBT packet must not be nil. +// 3. If the PSBT has no inputs (automatic coin selection mode), it must have // at least one output. -// 3. If the PSBT has inputs, a coin selection policy must not be specified +// 4. If the PSBT has inputs, a coin selection policy must not be specified // (mutual exclusivity). func (w *Wallet) validateFundIntent(intent *FundIntent) error { + // The intent must not be nil. + if intent == nil { + return ErrNilFundIntent + } + // The PSBT packet must not be nil. if intent.Packet == nil { return fmt.Errorf( diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 9a59e91e39..e92bb5901a 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -912,6 +912,11 @@ func TestValidateFundIntentError(t *testing.T) { intent *FundIntent expectedErr error }{ + { + name: "nil intent", + intent: nil, + expectedErr: ErrNilFundIntent, + }, { name: "nil packet", intent: &FundIntent{Packet: nil}, From 17058af0e964cfeb159c4c2e369a028f20f15011 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 25 Nov 2025 16:48:02 +0800 Subject: [PATCH 196/691] wallet: add helper methods for `SignPsbt` This commit prepares the upcoming `SignPsbt` by introducing the helper methods first. --- wallet/psbt_manager.go | 301 +++++++++++++++++++++++++++++++++++- wallet/psbt_manager_test.go | 286 ++++++++++++++++++++++++++++++++++ 2 files changed, 584 insertions(+), 3 deletions(-) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 7c4db882ac..91d5cf6cfd 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -10,6 +10,7 @@ import ( "fmt" "math" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -43,11 +44,182 @@ var ( "cannot specify both psbt inputs and a coin selection policy", ) - // ErrFeeRateNegative is returned when a negative fee rate is provided. - ErrFeeRateNegative = errors.New("fee rate cannot be negative") - // ErrNilFundIntent is returned when a nil FundIntent is provided. ErrNilFundIntent = errors.New("nil FundIntent") + + // ErrNoPsbtsToCombine is returned when no PSBTs are provided to + // combine. + ErrNoPsbtsToCombine = errors.New("no psbts to combine") + + // ErrDifferentTransactions is returned when PSBTs do not refer to the + // same transaction. + ErrDifferentTransactions = errors.New( + "psbts do not refer to the same transaction", + ) + + // ErrInputCountMismatch is returned when PSBTs have different input + // counts. + ErrInputCountMismatch = errors.New("input count mismatch") + + // ErrOutputCountMismatch is returned when PSBTs have different output + // counts. + ErrOutputCountMismatch = errors.New("output count mismatch") + + // ErrUnknownAddressType is returned when an unknown address type is + // encountered. + ErrUnknownAddressType = errors.New("unknown address type") + + // ErrInvalidBip32PathLength is returned when a BIP32 path does not + // have the expected length of 5. + ErrInvalidBip32PathLength = errors.New("invalid BIP32 path length") + + // ErrUnknownBip32Purpose is returned when a BIP32 path has a purpose + // that is not supported by the wallet. + ErrUnknownBip32Purpose = errors.New("unknown BIP32 purpose") + + // ErrUnsupportedTaprootLeafCount is returned when a Taproot derivation + // info contains an unsupported number of leaf hashes (e.g. > 1). + ErrUnsupportedTaprootLeafCount = errors.New("unsupported number of " + + "leaf hashes in Taproot derivation") + + // ErrMissingTaprootLeafScript is returned when a Taproot derivation + // specifies a leaf hash but the corresponding Taproot leaf script is + // missing from the PSBT. + ErrMissingTaprootLeafScript = errors.New("specified leaf hash in " + + "taproot BIP0032 derivation but missing taproot leaf script") + + // ErrTaprootLeafHashMismatch is returned when the calculated hash of + // the provided Taproot leaf script does not match the leaf hash + // specified in the derivation info. + ErrTaprootLeafHashMismatch = errors.New("specified leaf hash in " + + "taproot BIP0032 derivation but corresponding taproot leaf " + + "script was not found") + + // ErrUnsupportedMultipleTaprootDerivation is returned when a Taproot + // input has multiple derivation paths, which is not supported. + ErrUnsupportedMultipleTaprootDerivation = errors.New( + "unsupported multiple taproot BIP0032 derivation info found", + ) + + // ErrUnsupportedMultipleBip32Derivation is returned when a BIP32 + // input has multiple derivation paths, which is not supported. + ErrUnsupportedMultipleBip32Derivation = errors.New( + "unsupported multiple BIP0032 derivation info found", + ) + + // ErrAmbiguousDerivation is returned when an input has both Taproot and + // BIP32 derivation information, which is an ambiguous state. + ErrAmbiguousDerivation = errors.New( + "both Taproot and BIP32 derivation info found", + ) + + // ErrInvalidTaprootMerkleRootLength is returned when the Taproot + // Merkle Root has an invalid length. + ErrInvalidTaprootMerkleRootLength = errors.New( + "invalid taproot merkle root length", + ) + + // ErrInvalidBip32PathElementHardened is returned when a BIP32 path + // element that should be hardened is not. + ErrInvalidBip32PathElementHardened = errors.New( + "invalid BIP32 derivation path, element must be hardened", + ) + + // ErrInvalidBip32DerivationCoinType is returned when the coin type in + // a BIP32 derivation path does not match the wallet's chain parameters. + ErrInvalidBip32DerivationCoinType = errors.New( + "invalid BIP32 derivation path, coin type mismatch", + ) + + // ErrSighashMismatch is returned when merging PSBTs with conflicting + // sighash types. + ErrSighashMismatch = errors.New("sighash type mismatch") + + // ErrRedeemScriptMismatch is returned when merging PSBTs with + // conflicting redeem scripts. + ErrRedeemScriptMismatch = errors.New("redeem script mismatch") + + // ErrWitnessScriptMismatch is returned when merging PSBTs with + // conflicting witness scripts. + ErrWitnessScriptMismatch = errors.New("witness script mismatch") + + // ErrFinalScriptSigMismatch is returned when merging PSBTs with + // conflicting final script sigs. + ErrFinalScriptSigMismatch = errors.New("final script sig mismatch") + + // ErrFinalScriptWitnessMismatch is returned when merging PSBTs with + // conflicting final script witnesses. + ErrFinalScriptWitnessMismatch = errors.New("final script witness " + + "mismatch") + + // ErrTaprootKeySpendSigMismatch is returned when merging PSBTs with + // conflicting Taproot key spend signatures. + ErrTaprootKeySpendSigMismatch = errors.New("taproot key spend sig " + + "mismatch") + + // ErrWitnessUtxoMismatch is returned when merging PSBTs with + // conflicting witness UTXOs. + ErrWitnessUtxoMismatch = errors.New("witness utxo mismatch") + + // ErrNonWitnessUtxoMismatch is returned when merging PSBTs with + // conflicting non-witness UTXOs. + ErrNonWitnessUtxoMismatch = errors.New("non-witness utxo mismatch") + + // ErrTaprootInternalKeyMismatch is returned when merging PSBTs with + // conflicting Taproot internal keys. + ErrTaprootInternalKeyMismatch = errors.New("taproot internal key " + + "mismatch") + + // ErrImportedAddrNoDerivation is returned when trying to add output + // info for an imported address that has no derivation path. + ErrImportedAddrNoDerivation = errors.New("change addr is an " + + "imported addr with unknown derivation path") + + // ErrNilSignPsbtParams is returned when nil SignPsbtParams are + // provided. + ErrNilSignPsbtParams = errors.New("nil SignPsbtParams") + + // ErrPsbtInputIndexOutOfBounds is returned when an input index is out + // of bounds. + ErrPsbtInputIndexOutOfBounds = errors.New("psbt input index out of " + + "bounds") + + // ErrInputMissingUtxoInfo is returned when an input lacks both + // WitnessUtxo and NonWitnessUtxo. + ErrInputMissingUtxoInfo = errors.New("input missing both " + + "WitnessUtxo and NonWitnessUtxo") + + // ErrUnsignedTxInputIndexOutOfBounds is returned when an input index + // is out of bounds for the unsigned transaction. + ErrUnsignedTxInputIndexOutOfBounds = errors.New("psbt input index " + + "out of bounds for UnsignedTx inputs") + + // ErrPrevOutIndexOutOfBounds is returned when a previous output index + // is out of bounds for the NonWitnessUtxo. + ErrPrevOutIndexOutOfBounds = errors.New("prevOut index out of " + + "bounds for NonWitnessUtxo") + + // errAlreadySigned is returned when an input is already signed. + // + // NOTE: This error is private because it is used for internal control + // flow within the signing loop (to skip inputs) and should not be + // returned to the caller. + errAlreadySigned = errors.New("input already signed") + + // errComputeRawSig is returned when the wallet cannot produce a + // signature for the input (e.g. key not found, signing error). + // + // NOTE: This error is private because it is used for internal control + // flow (skipping inputs that don't belong to this wallet) and should + // not be exposed to the caller. + errComputeRawSig = errors.New("cannot compute raw signature") +) + +const ( + // BIP32PathLength is the expected length of a BIP32 derivation path. A + // full path follows the structure: + // m / purpose' / coin_type' / account' / branch / index. + BIP32PathLength = 5 ) // FundIntent represents the user's intent for funding a PSBT. It serves as a @@ -710,3 +882,126 @@ func (w *Wallet) createTxIntent(intent *FundIntent) *TxIntent { return txIntent } + +// parseBip32Path parses a raw BIP32 derivation path (slice of uint32) into a +// strongly-typed structure containing the key scope and specific derivation +// components (Account, Branch, Index). +// +// It enforces the following wallet-specific constraints (based on BIP44/49/84 +// conventions): +// 1. Path length must be exactly 5. +// 2. First 3 elements must be hardened. +// 3. Coin type must match the wallet's chain parameters. +// +// NOTE: While the underlying cryptographic derivation is defined by BIP32, the +// specific requirement for a 5-level path with hardened prefixes is strictly a +// convention of the BIP44/49/84/86 standards, not a constraint of BIP32 +// itself. +// +// Returns `ErrInvalidBip32Path` if the path is invalid. +func (w *Wallet) parseBip32Path(path []uint32) (BIP32Path, error) { + // The BIP32 path must have exactly 5 elements: + // m / purpose' / coin_type' / account' / branch / index + if len(path) != BIP32PathLength { + return BIP32Path{}, fmt.Errorf("%w: %d", + ErrInvalidBip32PathLength, len(path)) + } + + // The first 3 elements (Purpose, CoinType, Account) must be hardened. + // We check this by verifying they are >= HardenedKeyStart. + for i := range 3 { + if path[i] < hdkeychain.HardenedKeyStart { + return BIP32Path{}, fmt.Errorf("%w: element %d", + ErrInvalidBip32PathElementHardened, i) + } + } + + // Helper to extract values (remove hardened flag). + purpose := path[0] - hdkeychain.HardenedKeyStart + coinType := path[1] - hdkeychain.HardenedKeyStart + account := path[2] - hdkeychain.HardenedKeyStart + branch := path[3] + index := path[4] + + // Verify that the coin type matches the wallet's chain parameters. + if coinType != w.chainParams.HDCoinType { + return BIP32Path{}, fmt.Errorf("%w: expected %d, got %d", + ErrInvalidBip32DerivationCoinType, + w.chainParams.HDCoinType, coinType) + } + + scope := waddrmgr.KeyScope{ + Purpose: purpose, + Coin: coinType, + } + + bip32Path := BIP32Path{ + KeyScope: scope, + DerivationPath: waddrmgr.DerivationPath{ + Account: account, + Branch: branch, + Index: index, + }, + } + + return bip32Path, nil +} + +// addressTypeFromPurpose maps a BIP purpose to a wallet address type. +func addressTypeFromPurpose(purpose uint32) (waddrmgr.AddressType, error) { + // TODO(yy): Currently, we hardcode the supported BIP purposes. + // A more robust solution would dynamically query the `waddrmgr` to + // determine supported key scopes configured in the database, allowing + // for custom purposes (e.g., LND's 1017 purpose key) to be seamlessly + // supported without code changes here. + switch purpose { + case waddrmgr.KeyScopeBIP0044.Purpose: + return waddrmgr.PubKeyHash, nil + + case waddrmgr.KeyScopeBIP0049Plus.Purpose: + return waddrmgr.NestedWitnessPubKey, nil + + case waddrmgr.KeyScopeBIP0084.Purpose: + return waddrmgr.WitnessPubKey, nil + + case waddrmgr.KeyScopeBIP0086.Purpose: + return waddrmgr.TaprootPubKey, nil + + default: + return 0, fmt.Errorf("%w: %d", ErrUnknownBip32Purpose, purpose) + } +} + +// shouldSkipInput determines whether the input at the given index should be +// skipped during the signing process. +// +// It checks for two conditions: +// 1. If the input already has a final script witness, it is considered +// complete and is skipped. +// 2. If the input lacks any derivation information (both Taproot and BIP32), +// it implies that the wallet does not have the key to sign it, so it is +// skipped. +func shouldSkipInput(pInput *psbt.PInput, idx int) bool { + // Skip if already finalized. + if len(pInput.FinalScriptWitness) > 0 { + log.Debugf("Skipping input %d: already has final "+ + "script witness", idx) + + return true + } + + // Check if we have any derivation info. + tapCount := len(pInput.TaprootBip32Derivation) + bip32Count := len(pInput.Bip32Derivation) + + if tapCount == 0 && bip32Count == 0 { + // No derivation info, so we can't sign this input. We skip it + // silently, assuming it's not ours or not meant to be signed + // by us. + log.Debugf("Skipping input %d: no derivation info", idx) + + return true + } + + return false +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index e92bb5901a..6313d909f6 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -11,7 +11,9 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -1774,3 +1776,287 @@ func TestFundPsbtDecorateFailure(t *testing.T) { // Assert: Should fail due to DecorateInputs error. require.ErrorIs(t, err, errDb) } + +// TestFundPsbtErrors tests various error conditions in FundPsbt. +func TestFundPsbtErrors(t *testing.T) { + t.Parallel() + + // Arrange: Common intent setup (auto coin selection). + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: []byte{0x00}}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + intent := &FundIntent{ + Packet: packet, + FeeRate: btcunit.NewSatPerKVByte(1000), + // Policy implies auto selection + Policy: &InputsPolicy{ + Source: &ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0084, + }, + }, + } + + t.Run("validate intent fails", func(t *testing.T) { + t.Parallel() + w, _ := testWalletWithMocks(t) + // Invalid intent (nil packet) + _, _, err := w.FundPsbt(t.Context(), &FundIntent{}) + require.ErrorIs(t, err, ErrNilTxIntent) + }) + + t.Run("CreateTransaction fails", func(t *testing.T) { + t.Parallel() + w, mocks := testWalletWithMocks(t) + + // Mock CreateTransaction failure via Account lookup failure + mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(nil, errDb) + + _, _, err := w.FundPsbt(t.Context(), intent) + // AccountNumber failure is wrapped in ErrAccountNotFound by + // prepareTxAuthSources. + require.ErrorIs(t, err, ErrAccountNotFound) + }) +} + +// TestParseBip32Path tests that parseBip32Path correctly parses valid BIP32 +// paths and returns the appropriate KeyScope and DerivationPath, while also +// flagging invalid paths. +func TestParseBip32Path(t *testing.T) { + t.Parallel() + + // Use mainnet params for testing (HDCoinType = 0). + chainParams := &chaincfg.MainNetParams + w := &Wallet{chainParams: chainParams} + + hardened := func(i uint32) uint32 { + return i + hdkeychain.HardenedKeyStart + } + + tests := []struct { + name string + path []uint32 + wantPath BIP32Path + expectedErr error // Use error type for require.ErrorIs + }{ + { + name: "valid BIP44", + path: []uint32{ + hardened(44), hardened(0), hardened(0), 0, 0, + }, + wantPath: BIP32Path{ + KeyScope: waddrmgr.KeyScopeBIP0044, + DerivationPath: waddrmgr.DerivationPath{ + Account: 0, + Branch: 0, + Index: 0, + }, + }, + }, + { + name: "valid BIP84", + path: []uint32{ + hardened(84), hardened(0), hardened(1), 0, 5, + }, + wantPath: BIP32Path{ + KeyScope: waddrmgr.KeyScopeBIP0084, + DerivationPath: waddrmgr.DerivationPath{ + Account: 1, + Branch: 0, + Index: 5, + }, + }, + }, + { + name: "invalid length", + path: []uint32{hardened(84)}, + expectedErr: ErrInvalidBip32PathLength, + }, + { + name: "unhardened purpose", + path: []uint32{ + 84, hardened(0), hardened(0), 0, 0, + }, + expectedErr: ErrInvalidBip32PathElementHardened, + }, + { + name: "unhardened coin type", + path: []uint32{ + hardened(84), 0, hardened(0), 0, 0, + }, + expectedErr: ErrInvalidBip32PathElementHardened, + }, + { + name: "unhardened account", + path: []uint32{ + hardened(84), hardened(0), 0, 0, 0, + }, + expectedErr: ErrInvalidBip32PathElementHardened, + }, + { + name: "coin type mismatch", + path: []uint32{ + hardened(84), hardened(1), hardened(0), 0, 0, + }, + expectedErr: ErrInvalidBip32DerivationCoinType, + }, + { + name: "unknown purpose (now allowed in parseBip32Path)", + path: []uint32{ + hardened(999), hardened(0), hardened(0), 0, 0, + }, + wantPath: BIP32Path{ + KeyScope: waddrmgr.KeyScope{ + Purpose: 999, Coin: 0, + }, + DerivationPath: waddrmgr.DerivationPath{ + Account: 0, + Branch: 0, + Index: 0, + }, + }, + expectedErr: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Call parseBip32Path with the test path. + gotPath, err := w.parseBip32Path(tc.path) + + // Assert: Verify that the function returns the expected + // error (if any) or that the parsed path components + // (KeyScope, DerivationPath) match the expected + // structure. + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.wantPath, gotPath) + }) + } +} + +// TestAddressTypeFromPurpose tests that addressTypeFromPurpose returns the +// correct AddressType for supported BIP32 purposes and returns an error for +// unknown purposes. +func TestAddressTypeFromPurpose(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + purpose uint32 + want waddrmgr.AddressType + expectedErr error + }{ + { + name: "BIP44", + purpose: waddrmgr.KeyScopeBIP0044.Purpose, + want: waddrmgr.PubKeyHash, + }, + { + name: "BIP49", + purpose: waddrmgr.KeyScopeBIP0049Plus.Purpose, + want: waddrmgr.NestedWitnessPubKey, + }, + { + name: "BIP84", + purpose: waddrmgr.KeyScopeBIP0084.Purpose, + want: waddrmgr.WitnessPubKey, + }, + { + name: "BIP86", + purpose: waddrmgr.KeyScopeBIP0086.Purpose, + want: waddrmgr.TaprootPubKey, + }, + { + name: "unknown", + purpose: 999, + expectedErr: ErrUnknownBip32Purpose, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Call addressTypeFromPurpose with the test + // purpose. + got, err := addressTypeFromPurpose(tc.purpose) + + // Assert: Verify that the returned address type matches + // the expected type for the given purpose, or that the + // expected error is returned for unknown purposes. + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.want, got) + }) + } +} + +// TestShouldSkipInput tests that shouldSkipInput correctly identifies inputs +// that should be skipped during signing (e.g., finalized inputs, inputs with +// no derivation info) and those that should be processed. +func TestShouldSkipInput(t *testing.T) { + t.Parallel() + + // Define shared variables for long literals to satisfy linter. + taprootDerivation := []*psbt.TaprootBip32Derivation{{ + XOnlyPubKey: []byte{0x01}, + }} + + tests := []struct { + name string + pInput *psbt.PInput + expected bool + }{ + { + name: "finalized input should be skipped", + pInput: &psbt.PInput{ + FinalScriptWitness: []byte{1, 2, 3}, + }, + expected: true, + }, + { + name: "no derivation info should be skipped", + pInput: &psbt.PInput{ + FinalScriptWitness: nil, + Bip32Derivation: nil, + TaprootBip32Derivation: nil, + }, + expected: true, + }, + { + name: "valid BIP32 derivation, not skipped", + pInput: &psbt.PInput{ + Bip32Derivation: []*psbt.Bip32Derivation{{ + PubKey: []byte{0x01}, + }}, + }, + expected: false, + }, + { + name: "valid Taproot derivation, not skipped", + pInput: &psbt.PInput{ + TaprootBip32Derivation: taprootDerivation, + }, + expected: false, + }, + } + + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Call shouldSkipInput with the configured PSBT + // input. + result := shouldSkipInput(tc.pInput, i) + + // Assert: Verify that the returned boolean matches the + // expectation (true for skippable inputs, false + // otherwise). + require.Equal(t, tc.expected, result) + }) + } +} From 280e41b79887739987ea3bcab0d1ca7501ced364 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 25 Nov 2025 16:51:31 +0800 Subject: [PATCH 197/691] wallet: add validation and padding methods for `SignPsbt` This commit prepares the upcoming `SignPsbt` by introducing the validation and padding methods. --- wallet/psbt_manager.go | 182 ++++++++++++ wallet/psbt_manager_test.go | 548 ++++++++++++++++++++++++++++++++++++ 2 files changed, 730 insertions(+) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 91d5cf6cfd..154a051414 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -5,6 +5,7 @@ package wallet import ( + "bytes" "context" "errors" "fmt" @@ -1005,3 +1006,184 @@ func shouldSkipInput(pInput *psbt.PInput, idx int) bool { return false } + +// shouldSkipSigningError determines whether a signing error should be skipped +// (logged and ignored) or returned as a fatal error. +// +// It handles cases typical in collaborative workflows where an input might +// belong to another signer, already be signed, or use an unknown derivation +// scheme. +func shouldSkipSigningError(err error, idx int) bool { + // If the input is already signed, we can just skip it. + if errors.Is(err, errAlreadySigned) { + log.Debugf("Skipping input %d: already signed", idx) + return true + } + + // In a collaborative PSBT workflow, the transaction may contain inputs + // that belong to other parties. Even if a derivation path is present + // and valid (e.g. BIP-84), it might correspond to a different signer's + // key (same path, different seed). + // + // If we encounter `errComputeRawSig`, it means we failed to produce a + // signature. This usually happens because we don't have the private + // key for the derived address (it's someone else's input). In this + // case, we skip the input and log a debug message, allowing us to + // proceed and sign the inputs that we DO own. + if errors.Is(err, errComputeRawSig) { + log.Debugf("Skipping input %d: %v", idx, err) + return true + } + + // If the derivation path has an unknown purpose, it likely belongs to + // another signer or a scheme we don't support. We skip these as well. + if errors.Is(err, ErrUnknownBip32Purpose) { + log.Debugf("Skipping input %d: unknown BIP32 purpose", idx) + return true + } + + return false +} + +// validateDerivation inspects the derivation information for the input and +// ensures it conforms to the supported signing modes. +// +// It enforces the following rules: +// 1. Only one derivation path per type is supported. +// 2. Taproot and BIP32 derivation information cannot be present +// simultaneously. This avoids ambiguity about which signing path to take. +// +// It returns a boolean indicating whether the input is a Taproot input (true) +// or a legacy/SegWit input (false), and an error if the validation fails. +func validateDerivation(pInput *psbt.PInput, idx int) (bool, error) { + tapCount := len(pInput.TaprootBip32Derivation) + bip32Count := len(pInput.Bip32Derivation) + + if tapCount > 1 { + return false, fmt.Errorf("input %d: %w", idx, + ErrUnsupportedMultipleTaprootDerivation) + } + + if bip32Count > 1 { + return false, fmt.Errorf("input %d: %w", idx, + ErrUnsupportedMultipleBip32Derivation) + } + + if tapCount == 1 && bip32Count == 1 { + // This is ambiguous/invalid state in the PSBT. + return false, fmt.Errorf("input %d: %w", idx, + ErrAmbiguousDerivation) + } + + // If we have Taproot info, it's a Taproot input. + return tapCount == 1, nil +} + +// fetchPsbtUtxo extracts the UTXO for the given input index from the PSBT +// packet. It prioritizes the WitnessUtxo if present, otherwise falls back to +// the NonWitnessUtxo. +// +// NOTE: While psbt.InputsReadyToSign guarantees that at least one of these +// fields is set, this function performs additional checks and returns an error +// if the UTXO information is missing or the index is out of bounds, preventing +// panics on malformed packets. +func fetchPsbtUtxo(packet *psbt.Packet, idx int) (*wire.TxOut, error) { + if idx >= len(packet.Inputs) { + return nil, fmt.Errorf("%w: %d", + ErrPsbtInputIndexOutOfBounds, idx) + } + + pInput := &packet.Inputs[idx] + + if pInput.WitnessUtxo != nil { + return pInput.WitnessUtxo, nil + } + + if pInput.NonWitnessUtxo == nil { + return nil, fmt.Errorf("%w: %d", + ErrInputMissingUtxoInfo, idx) + } + + if idx >= len(packet.UnsignedTx.TxIn) { + return nil, fmt.Errorf("%w: %d", + ErrUnsignedTxInputIndexOutOfBounds, idx) + } + + prevIdx := packet.UnsignedTx.TxIn[idx].PreviousOutPoint.Index + + if int(prevIdx) >= len(pInput.NonWitnessUtxo.TxOut) { + return nil, fmt.Errorf("%w: input %d prevOut index %d", + ErrPrevOutIndexOutOfBounds, idx, prevIdx) + } + + return pInput.NonWitnessUtxo.TxOut[prevIdx], nil +} + +// checkTaprootScriptSpendSig checks if a Taproot script-path signature already +// exists for the given input and derivation details. It returns +// errAlreadySigned, if a matching signature is found, otherwise nil. +func checkTaprootScriptSpendSig(pInput *psbt.PInput, + tapDerivation *psbt.TaprootBip32Derivation) error { + + for _, sig := range pInput.TaprootScriptSpendSig { + if bytes.Equal( + sig.XOnlyPubKey, tapDerivation.XOnlyPubKey, + ) && bytes.Equal( + sig.LeafHash, tapDerivation.LeafHashes[0], + ) { + + return errAlreadySigned + } + } + + return nil +} + +// addTaprootSigToPInput adds the generated signature to the PSBT input. +// +// NOTE: This method modifies the `pInput` in-place. +func addTaprootSigToPInput(pInput *psbt.PInput, sig []byte, + sighashType txscript.SigHashType, details TaprootSpendDetails, + tapDerivation *psbt.TaprootBip32Derivation) { + + if details.SpendPath == KeyPathSpend { + if sighashType != txscript.SigHashDefault { + sig = append(sig, byte(sighashType)) + } + + pInput.TaprootKeySpendSig = sig + } else { + tsSig := &psbt.TaprootScriptSpendSig{ + XOnlyPubKey: tapDerivation.XOnlyPubKey, + LeafHash: tapDerivation.LeafHashes[0], + Signature: sig, + SigHash: pInput.SighashType, + } + pInput.TaprootScriptSpendSig = append( + pInput.TaprootScriptSpendSig, tsSig, + ) + } +} + +// addBip32SigToPInput adds the generated signature to the PSBT input for +// non-Taproot (Legacy/SegWit) inputs. +// +// NOTE: This method modifies the `pInput` in-place. +func addBip32SigToPInput(pInput *psbt.PInput, sig []byte, + sighashType txscript.SigHashType, derivation *psbt.Bip32Derivation, + addrType waddrmgr.AddressType) { + + // Append sighash type if needed (SegWit v0). + if addrType == waddrmgr.NestedWitnessPubKey || + addrType == waddrmgr.WitnessPubKey { + + sig = append(sig, byte(sighashType)) + } + + pInput.PartialSigs = append(pInput.PartialSigs, + &psbt.PartialSig{ + PubKey: derivation.PubKey, + Signature: sig, + }, + ) +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 6313d909f6..81a4a2d137 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -5,7 +5,10 @@ package wallet import ( + "bytes" "errors" + "fmt" + "slices" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -2060,3 +2063,548 @@ func TestShouldSkipInput(t *testing.T) { }) } } + +// TestShouldSkipSigningError tests that shouldSkipSigningError correctly +// determines whether a signing error is non-critical (and thus the input can +// be skipped) or critical. +func TestShouldSkipSigningError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + expected bool + }{ + { + name: "already signed error should be skipped", + err: errAlreadySigned, + expected: true, + }, + { + name: "compute raw sig error should be skipped", + err: fmt.Errorf("wrapped: %w", errComputeRawSig), + expected: true, + }, + { + name: "unknown BIP32 purpose error should be " + + "skipped", + err: ErrUnknownBip32Purpose, + expected: true, + }, + + { + name: "generic error should not be skipped", + err: errDb, + expected: false, + }, + } + + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Call shouldSkipSigningError with the test error. + result := shouldSkipSigningError(tc.err, i) + + // Assert: Verify that the function correctly identifies + // whether the error should cause the input to be + // skipped (true) or treated as a failure (false). + require.Equal(t, tc.expected, result) + }) + } +} + +// TestValidateDerivation tests that validateDerivation correctly identifies the +// derivation type (Taproot vs. BIP32) and validates that there are no +// conflicting or multiple derivation paths. +func TestValidateDerivation(t *testing.T) { + t.Parallel() + + // Define shared variables for long literals. + taprootDerivation := []*psbt.TaprootBip32Derivation{{}} + multiTapDerivation := []*psbt.TaprootBip32Derivation{{}, {}} + multiBip32Derivation := []*psbt.Bip32Derivation{{}, {}} + singleBip32Derivation := []*psbt.Bip32Derivation{{}} + + // Arrange: Define test cases for derivation validation. + tests := []struct { + name string + pInput *psbt.PInput + isTaproot bool + err error + }{ + { + name: "single BIP32 derivation", + pInput: &psbt.PInput{ + Bip32Derivation: []*psbt.Bip32Derivation{{}}, + }, + isTaproot: false, + err: nil, + }, + { + name: "single Taproot derivation", + pInput: &psbt.PInput{ + TaprootBip32Derivation: taprootDerivation, + }, + isTaproot: true, + err: nil, + }, + { + name: "multiple BIP32 derivations error", + pInput: &psbt.PInput{ + Bip32Derivation: multiBip32Derivation, + }, + isTaproot: false, + err: ErrUnsupportedMultipleBip32Derivation, + }, + { + name: "multiple Taproot derivations error", + pInput: &psbt.PInput{ + TaprootBip32Derivation: multiTapDerivation, + }, + isTaproot: false, + err: ErrUnsupportedMultipleTaprootDerivation, + }, + { + name: "ambiguous derivation", + pInput: &psbt.PInput{ + Bip32Derivation: singleBip32Derivation, + TaprootBip32Derivation: taprootDerivation, + }, + isTaproot: false, + err: ErrAmbiguousDerivation, + }, + { + name: "no derivation info (valid)", + pInput: &psbt.PInput{}, + isTaproot: false, + err: nil, + }, + } + + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Act: Call validateDerivation with the configured + // input to check for validity and determine if it's a + // Taproot input. + isTaproot, err := validateDerivation(tc.pInput, i) + + // Assert: Verify that the returned error matches the + // expected error (e.g., for ambiguous or multiple + // derivations) and that the Taproot flag is set + // correctly for valid inputs. + require.ErrorIs(t, err, tc.err) + require.Equal(t, tc.isTaproot, isTaproot) + }) + } +} + +// TestFetchPsbtUtxo tests that fetchPsbtUtxo correctly prioritizes WitnessUtxo +// over NonWitnessUtxo when retrieving the UTXO for a PSBT input, and safely +// handles missing data. +func TestFetchPsbtUtxo(t *testing.T) { + t.Parallel() + + // Arrange: Create dummy UTXOs for testing. + witnessUtxo := &wire.TxOut{Value: 1000, PkScript: []byte{0x00}} + nonWitnessUtxo := &wire.TxOut{Value: 2000, PkScript: []byte{0x01}} + + // Arrange: Create a transaction that has the nonWitnessUtxo at index 0 + tx := wire.NewMsgTx(2) + tx.AddTxOut(nonWitnessUtxo) + + // Arrange: Define test cases for fetching UTXOs. + tests := []struct { + name string + packet *psbt.Packet + inputIdx int + expected *wire.TxOut + expectedErr error + }{ + { + name: "prioritize WitnessUtxo", + // Arrange: PSBT input with both WitnessUtxo and + // NonWitnessUtxo. + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + }, + Inputs: []psbt.PInput{{ + WitnessUtxo: witnessUtxo, + NonWitnessUtxo: tx, + }}, + }, + inputIdx: 0, + // Assert: Expect WitnessUtxo to be returned. + expected: witnessUtxo, + expectedErr: nil, + }, + { + name: "fallback to NonWitnessUtxo", + // Arrange: PSBT input with only NonWitnessUtxo. + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Index: 0, + }, + }}, + }, + Inputs: []psbt.PInput{{ + WitnessUtxo: nil, + NonWitnessUtxo: tx, + }}, + }, + inputIdx: 0, + // Assert: Expect NonWitnessUtxo to be returned. + expected: nonWitnessUtxo, + expectedErr: nil, + }, + { + name: "missing all utxo info", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + }, + Inputs: []psbt.PInput{{ + WitnessUtxo: nil, + NonWitnessUtxo: nil, + }}, + }, + inputIdx: 0, + expected: nil, + expectedErr: ErrInputMissingUtxoInfo, + }, + { + name: "input index out of bounds", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{}, + }, + Inputs: []psbt.PInput{}, + }, + inputIdx: 0, + expected: nil, + expectedErr: ErrPsbtInputIndexOutOfBounds, + }, + { + name: "prevout index out of bounds", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Index: 99, + }, + }}, + }, + Inputs: []psbt.PInput{{ + NonWitnessUtxo: tx, + }}, + }, + inputIdx: 0, + expected: nil, + expectedErr: ErrPrevOutIndexOutOfBounds, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Act: Call fetchPsbtUtxo with the configured packet + // and input index. + got, err := fetchPsbtUtxo(tc.packet, tc.inputIdx) + + // Assert: Verify that the function returns the correct + // UTXO (or nil on error) and the expected error + // status. + require.ErrorIs(t, err, tc.expectedErr) + require.Equal(t, tc.expected, got) + }) + } +} + +// TestCheckTaprootScriptSpendSig tests that checkTaprootScriptSpendSig +// correctly detects if a Taproot script spend signature already exists for the +// given key and leaf. +func TestCheckTaprootScriptSpendSig(t *testing.T) { + t.Parallel() + + // Arrange: Create dummy public key and leaf hash for Taproot + // signatures. + xOnlyPubKey := bytes.Repeat([]byte{0x01}, 32) + leafHash := bytes.Repeat([]byte{0x02}, 32) + + // Pre-define complex slice literals. + diffKeySig := []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: bytes.Repeat( + []byte{0x03}, 32, + ), + LeafHash: leafHash, + }, + } + + sameKeySig := []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey, + LeafHash: leafHash, + }, + } + + // Arrange: Define test cases for checking existing Taproot script + // spend signatures. + tests := []struct { + name string + pInput *psbt.PInput + tapDerivation *psbt.TaprootBip32Derivation + err error + }{ + { + name: "no existing signature", + // Arrange: No TaprootScriptSpendSig in the input. + pInput: &psbt.PInput{ + TaprootScriptSpendSig: nil, + }, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: [][]byte{leafHash}, + }, + err: nil, + }, + { + name: "existing signature for different key", + // Arrange: A TaprootScriptSpendSig exists, but for a + // different XOnlyPubKey. + pInput: &psbt.PInput{ + TaprootScriptSpendSig: diffKeySig, + }, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: [][]byte{leafHash}, + }, + err: nil, + }, + { + name: "existing signature for same key and leaf", + // Arrange: A matching TaprootScriptSpendSig already + // exists. + pInput: &psbt.PInput{ + TaprootScriptSpendSig: sameKeySig, + }, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: [][]byte{leafHash}, + }, + // Assert: Expect errAlreadySigned. + err: errAlreadySigned, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Act: Call checkTaprootScriptSpendSig with the + // configured input and derivation info. + err := checkTaprootScriptSpendSig( + tc.pInput, tc.tapDerivation, + ) + + // Assert: Verify that the function returns an error + // only if a valid signature for the same key and leaf + // hash already exists. + require.ErrorIs(t, err, tc.err) + }) + } +} + +// TestAddTaprootSigToPInput tests that addTaprootSigToPInput correctly adds a +// generated Taproot signature to the PSBT input, handling both Key Spend and +// Script Spend paths. +func TestAddTaprootSigToPInput(t *testing.T) { + t.Parallel() + + // Arrange: Define dummy signature, public key, and leaf hash. + sig := []byte{0x01, 0x02} + xOnlyPubKey := bytes.Repeat([]byte{0x03}, 32) + leafHash := bytes.Repeat([]byte{0x04}, 32) + + // Helper to create signature with appended sighash + sigWithHash := append(slices.Clone(sig), byte(txscript.SigHashAll)) + + // Arrange: Define test cases for adding Taproot signatures. + tests := []struct { + name string + initialPInput *psbt.PInput + sighashType txscript.SigHashType + details TaprootSpendDetails + tapDerivation *psbt.TaprootBip32Derivation + expectedPInput *psbt.PInput + }{ + { + name: "key path spend default sighash", + // Arrange: Initial empty PSBT input. + initialPInput: &psbt.PInput{}, + sighashType: txscript.SigHashDefault, + // Arrange: Key path spend details. + details: TaprootSpendDetails{ + SpendPath: KeyPathSpend, + }, + tapDerivation: nil, // Not used for key path spend + // Assert: Expect TaprootKeySpendSig to be set. + expectedPInput: &psbt.PInput{ + TaprootKeySpendSig: sig, + }, + }, + { + name: "key path spend non-default sighash", + // Arrange: Initial empty PSBT input. + initialPInput: &psbt.PInput{}, + sighashType: txscript.SigHashAll, + // Arrange: Key path spend details. + details: TaprootSpendDetails{ + SpendPath: KeyPathSpend, + }, + tapDerivation: nil, + // Assert: Expect TaprootKeySpendSig with appended + // sighash. + expectedPInput: &psbt.PInput{ + TaprootKeySpendSig: sigWithHash, + }, + }, + { + name: "script path spend", + // Arrange: Initial PSBT input with default SighashType. + initialPInput: &psbt.PInput{ + SighashType: txscript.SigHashDefault, + }, + sighashType: txscript.SigHashDefault, + // Arrange: Script path spend details. + details: TaprootSpendDetails{ + SpendPath: ScriptPathSpend, + }, + // Arrange: Taproot BIP32 derivation with XOnlyPubKey + // and LeafHashes. + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: [][]byte{leafHash}, + }, + // Assert: Expect TaprootScriptSpendSig to be appended. + //nolint:lll + expectedPInput: &psbt.PInput{ + SighashType: txscript.SigHashDefault, + TaprootScriptSpendSig: []*psbt.TaprootScriptSpendSig{{ + XOnlyPubKey: xOnlyPubKey, + LeafHash: leafHash, + Signature: sig, + SigHash: txscript.SigHashDefault, + }}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Arrange: Create a copy of the initial input to ensure + // test isolation and avoid side effects. + pInput := *tc.initialPInput + + // Act: Call addTaprootSigToPInput to add the signature + // to the PSBT input. + addTaprootSigToPInput( + &pInput, sig, tc.sighashType, tc.details, + tc.tapDerivation, + ) + + // Assert: Verify that the resulting PSBT input matches + // the expected state. + require.Equal(t, tc.expectedPInput, &pInput) + }) + } +} + +// TestAddBip32SigToPInput tests that addBip32SigToPInput correctly adds a +// generated BIP32 signature to the PSBT input's partial signatures, appending +// the sighash type if necessary. +func TestAddBip32SigToPInput(t *testing.T) { + t.Parallel() + + // Arrange: Define dummy signature and public key. + sig := []byte{0x01, 0x02} + pubKey := bytes.Repeat([]byte{0x03}, 33) + + // Helper to create signature with appended sighash + sigWithHash := append(slices.Clone(sig), byte(txscript.SigHashAll)) + + // Arrange: Define test cases for adding BIP32 signatures. + tests := []struct { + name string + initialPInput *psbt.PInput + sighashType txscript.SigHashType + addrType waddrmgr.AddressType + derivation *psbt.Bip32Derivation + expectedPInput *psbt.PInput + }{ + { + name: "legacy p2pkh (no sighash append)", + // Arrange: Initial empty PSBT input. + initialPInput: &psbt.PInput{}, + sighashType: txscript.SigHashAll, + // Arrange: Public Key Hash address type. + addrType: waddrmgr.PubKeyHash, + // Arrange: BIP32 derivation with PubKey. + derivation: &psbt.Bip32Derivation{ + PubKey: pubKey, + }, + // Assert: Expect PartialSigs to be appended with raw + // sig. + expectedPInput: &psbt.PInput{ + PartialSigs: []*psbt.PartialSig{{ + PubKey: pubKey, + Signature: sig, + }}, + }, + }, + { + name: "segwit p2wkh (append sighash)", + // Arrange: Initial empty PSBT input. + initialPInput: &psbt.PInput{}, + sighashType: txscript.SigHashAll, + // Arrange: Witness Public Key Hash address type. + addrType: waddrmgr.WitnessPubKey, + // Arrange: BIP32 derivation with PubKey. + derivation: &psbt.Bip32Derivation{ + PubKey: pubKey, + }, + // Assert: Expect PartialSigs to be appended with sig + + // sighash. + expectedPInput: &psbt.PInput{ + PartialSigs: []*psbt.PartialSig{{ + PubKey: pubKey, + Signature: sigWithHash, + }}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Arrange: Create a copy of the initial input to ensure + // test isolation. + pInput := *tc.initialPInput + + // Act: Call addBip32SigToPInput to add the signature + // to the PSBT input. + addBip32SigToPInput( + &pInput, sig, tc.sighashType, tc.derivation, + tc.addrType, + ) + + // Assert: Verify that the resulting PSBT input matches + // the expected state. + require.Equal(t, tc.expectedPInput, &pInput) + }) + } +} From e32d308d398a9a4982f2b35e7744e5ebf4d04d89 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 25 Nov 2025 16:53:30 +0800 Subject: [PATCH 198/691] wallet: implement `SignPsbt` This commit implements `SignPsbt` directly within `btcwallet`, replacing the need for external drivers to manually orchestrate signing logic. This implementation differs significantly from existing driver-level implementations (e.g., in `lnd`) by enforcing stricter validation and offering a more robust collaborative signing flow. Key Differences & Improvements: 1. **Strict Derivation Enforcement**: Unlike previous implementations that might permissively handle inputs with mixed derivation info, this implementation enforces a strict **"One Derivation Path Per Input"** rule. - If an input contains both `Bip32Derivation` and `TaprootBip32Derivation`, it returns an error to avoid ambiguity. - Callers must strictly define *which* path they intend to sign for in a given pass (e.g., for multisig). 2. **Robust Collaborative Signing (Skip-on-Conflict)**: The signing loop is designed for multi-party PSBTs. It distinguishes between *structural errors* (invalid PSBT) and *ownership mismatches* (not my input). - **LND Legacy**: Manually logged warnings and skipped inputs if key derivation failed or pubkeys didn't match. - **New Implementation**: Uses specific sentinel errors (`errComputeRawSig`, `ErrUnknownBip32Purpose`) to cleanly detect when an input belongs to another party (e.g., different seed, hardware wallet path) and skips it silently (debug log) without polluting the error return or requiring ad-hoc checks in the loop. 3. **Modular Architecture**: The logic is decomposed into `signTaprootPsbtInput` and `signBip32PsbtInput`. This separates the concern of *parsing/validating* a specific input type from the *orchestration* of the signing loop. 4. **Extensible API**: Introduces `SignPsbtParams` and `SignPsbtResult`. - **InputTweakers**: Explicit support for passing ephemeral tweakers (required for MuSig2/Taproot assets) directly to the signer, rather than relying on proprietary PSBT fields or global state. This provides a safe, standard, and testable foundation for all PSBT signing operations in the suite. --- wallet/psbt_manager.go | 426 +++++++++++++++++++++- wallet/psbt_manager_test.go | 683 +++++++++++++++++++++++++++++++++++- 2 files changed, 1105 insertions(+), 4 deletions(-) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 154a051414..83db280606 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -7,6 +7,7 @@ package wallet import ( "bytes" "context" + "crypto/sha256" "errors" "fmt" "math" @@ -884,9 +885,140 @@ func (w *Wallet) createTxIntent(intent *FundIntent) *TxIntent { return txIntent } -// parseBip32Path parses a raw BIP32 derivation path (slice of uint32) into a -// strongly-typed structure containing the key scope and specific derivation -// components (Account, Branch, Index). +// SignPsbt adds partial signatures to the PSBT. +// +// It achieves this by: +// 1. Pre-computation: Creating a `PsbtPrevOutputFetcher` and calculating the +// transaction sighashes once for efficiency. +// 2. Iteration: Processing each input to determine if it is owned by the +// wallet and ready for signing. +// 3. Derivation Validation: Enforcing strict rules on derivation paths (one +// path per input) to ensure deterministic key selection. +// 4. Signing: dispatching to `signTaprootPsbtInput` or `signBip32PsbtInput` to +// generate the raw ECDSA or Schnorr signature using the underlying +// `Signer`. +func (w *Wallet) SignPsbt(ctx context.Context, params *SignPsbtParams) ( + *SignPsbtResult, error) { + + if params == nil { + return nil, ErrNilSignPsbtParams + } + + packet := params.Packet + + // signedInputs will track the indices of all inputs that we + // successfully sign during this operation. This is useful for callers + // (e.g., LND) to know which inputs were partially signed by this + // wallet. + var signedInputs = make([]uint32, 0, len(packet.Inputs)) + + // Before proceeding, we ensure that the PSBT inputs are in a state + // that allows them to be signed. This check verifies that each input + // has at least a WitnessUtxo or NonWitnessUtxo, which is crucial for + // signature generation. If this check fails, it indicates a malformed + // or incomplete PSBT that cannot be signed. + err := psbt.InputsReadyToSign(packet) + if err != nil { + return nil, fmt.Errorf("psbt inputs not ready: %w", err) + } + + // We create a `PrevOutputFetcher` to allow `txscript` to retrieve the + // previous transaction outputs needed for sighash generation. This is + // a critical component as the value and script of the UTXO being spent + // are part of the data signed. Following this, we compute the + // transaction's sighashes, which are integral to producing valid + // signatures for each input. + prevOutFetcher := PsbtPrevOutputFetcher(packet) + sigHashes := txscript.NewTxSigHashes( + packet.UnsignedTx, prevOutFetcher, + ) + + // Iterate through each input in the PSBT. For each input, we attempt + // to sign it if the wallet can provide the necessary key material and + // if the input itself is in a signable state. This loop handles both + // Taproot (SegWit v1) and legacy/SegWit v0 inputs, adapting the + // signing process accordingly. + for i := range packet.Inputs { + pInput := &packet.Inputs[i] + + // First, we check if the current input should be skipped. This + // helper function identifies inputs that are already finalized + // or lack any derivation information (meaning we don't own the + // key or it's not intended for us to sign). Skipping these + // allows the wallet to focus on relevant inputs and gracefully + // handle multi-signer PSBTs. + if shouldSkipInput(pInput, i) { + continue + } + + // Validate the derivation information to ensure we have an + // unambiguous signing path. Our policy enforces that an input + // should not contain conflicting Taproot and BIP32 derivation + // paths, nor multiple paths of the same type. This prevents + // misinterpretations and ensures deterministic signing. + isTaproot, err := validateDerivation(pInput, i) + if err != nil { + return nil, err + } + + // Based on the validated derivation information, we dispatch + // the signing task to the appropriate helper function. If the + // input is identified as Taproot, we use + // `signTaprootPsbtInput`; otherwise, we assume it's a legacy + // or SegWit v0 input and use `signBip32PsbtInput`. + if isTaproot { + err = w.signTaprootPsbtInput( + ctx, packet, i, sigHashes, + params.InputTweakers[i], + ) + } else { + err = w.signBip32PsbtInput( + ctx, packet, i, sigHashes, + params.InputTweakers[i], + ) + } + + // If an error occurred during signing, we first check if it's + // an error that permits us to skip the current input (e.g., if + // the key is not found, implying it's another signer's input + // in a collaborative PSBT). If the error is *not* skippable, + // it indicates a critical issue, and we return it immediately. + // Otherwise, we continue to the next input. + if err != nil { + if shouldSkipSigningError(err, i) { + continue + } + + return nil, fmt.Errorf("input %d: %w", i, err) + } + + // If signing was successful (or the error was gracefully + // skipped), we record the index of this input as one that the + // wallet has contributed a signature to. This provides + // valuable feedback to the caller about the progress of the + // signing operation. + // + // We convert the index i (int) to uint32. This is safe because + // a Bitcoin transaction is strictly bounded by the block size + // limit (4MB). Even with the smallest possible input size, the + // maximum number of inputs is less than 100,000, which is far + // below MaxUint32 (~4.2 billion). + //nolint:gosec + signedInputs = append(signedInputs, uint32(i)) + } + + // Finally, return the result, which includes the list of inputs that + // were successfully signed and the modified (partially) signed PSBT + // packet. + return &SignPsbtResult{ + SignedInputs: signedInputs, + Packet: packet, + }, nil +} + +// parseBip32Path parses a raw derivation path (sequence of uint32s) and +// verifies that it conforms to the BIP44-like hierarchy structure +// (m / purpose' / coin_type' / account' / branch / index) used by this wallet. // // It enforces the following wallet-specific constraints (based on BIP44/49/84 // conventions): @@ -1187,3 +1319,291 @@ func addBip32SigToPInput(pInput *psbt.PInput, sig []byte, }, ) } + +// createTaprootSpendDetails determines the signing method (Key Path vs Script +// Path) and constructs the necessary details for generating a Taproot +// signature. +// +// It inspects the derivation info and the PSBT input to decide: +// 1. Key Path Spend: If `LeafHashes` is empty, it assumes a key path spend. +// It validates that the input hasn't already been signed with a key path +// signature. +// 2. Script Path Spend: If `LeafHashes` has exactly one entry, it assumes a +// script path spend. It validates the presence and correctness of the +// corresponding `TaprootLeafScript` and checks if a signature for this +// specific leaf and key already exists. +// +// Returns `ErrUnsupportedTaprootLeafCount` if `LeafHashes` has more than 1 +// entry. +// Returns `ErrMissingTaprootLeafScript` or `ErrTaprootLeafHashMismatch` for +// invalid script path state. +// Returns `errAlreadySigned` if a valid signature already exists for the +// target path. +func createTaprootSpendDetails(pInput *psbt.PInput, + tapDerivation *psbt.TaprootBip32Derivation) ( + TaprootSpendDetails, error) { + + var details TaprootSpendDetails + + nLeafHashes := len(tapDerivation.LeafHashes) + switch nLeafHashes { + // Case 1: Key Path Spend. + // A non-empty merkle root means we committed to a taproot hash + // that we need to use in the tap tweak. If LeafHashes is empty, it + // means we are signing for the internal key (Key Path). + case 0: + // If a Merkle Root is provided, it must be exactly 32 bytes. + if len(pInput.TaprootMerkleRoot) > 0 && + len(pInput.TaprootMerkleRoot) != sha256.Size { + + return details, fmt.Errorf("%w: expected %d, got %d", + ErrInvalidTaprootMerkleRootLength, + sha256.Size, len(pInput.TaprootMerkleRoot)) + } + + details = TaprootSpendDetails{ + SpendPath: KeyPathSpend, + Tweak: pInput.TaprootMerkleRoot, + } + + // Check if we have already signed this input. + if len(pInput.TaprootKeySpendSig) > 0 { + return details, errAlreadySigned + } + + // Case 2: Script Path Spend (Single Leaf). + // Currently, we only support signing for one leaf at a time. + case 1: + // If we're supposed to be signing for a leaf hash, we also + // expect the leaf script that hashes to that hash in the + // appropriate field. + if len(pInput.TaprootLeafScript) != 1 { + return details, fmt.Errorf("%w: expected 1, got %d", + ErrMissingTaprootLeafScript, + len(pInput.TaprootLeafScript)) + } + + leafScript := pInput.TaprootLeafScript[0] + leaf := txscript.TapLeaf{ + LeafVersion: leafScript.LeafVersion, + Script: leafScript.Script, + } + h := leaf.TapHash() + + // Verify that the calculated hash of the provided script + // matches the leaf hash specified in the derivation info. + if !bytes.Equal(h[:], tapDerivation.LeafHashes[0]) { + return details, ErrTaprootLeafHashMismatch + } + + details = TaprootSpendDetails{ + SpendPath: ScriptPathSpend, + WitnessScript: leafScript.Script, + } + + // Check if we have already signed this input. + err := checkTaprootScriptSpendSig(pInput, tapDerivation) + if err != nil { + return details, err + } + + default: + return details, fmt.Errorf("%w: %d", + ErrUnsupportedTaprootLeafCount, nLeafHashes) + } + + return details, nil +} + +// createBip32SpendDetails constructs the spending details (e.g. redeem scripts, +// witness scripts) required for signing a BIP32 input. +// +// It inspects the input's address type and existing script information in the +// PSBT to determine the correct spending path (Legacy, SegWit v0, or Nested +// SegWit). +// +// Returns `ErrUnknownAddressType` if the address type is not supported. +// Returns `errAlreadySigned` if a valid signature for the derived key already +// exists. +func createBip32SpendDetails(pInput *psbt.PInput, utxo *wire.TxOut, + addrType waddrmgr.AddressType, + derivation *psbt.Bip32Derivation) (SpendDetails, error) { + + // Determine the script to use for signing (subScript). + var subScript []byte + switch { + case len(pInput.RedeemScript) > 0: + subScript = pInput.RedeemScript + + case len(pInput.WitnessScript) > 0: + subScript = pInput.WitnessScript + + default: + subScript = utxo.PkScript + } + + var details SpendDetails + switch addrType { + case waddrmgr.WitnessPubKey, waddrmgr.NestedWitnessPubKey: + details = SegwitV0SpendDetails{WitnessScript: subScript} + + case waddrmgr.PubKeyHash: + details = LegacySpendDetails{RedeemScript: subScript} + + case waddrmgr.Script, waddrmgr.RawPubKey, + waddrmgr.WitnessScript, waddrmgr.TaprootPubKey, + waddrmgr.TaprootScript: + return nil, fmt.Errorf("%w: %v", ErrUnknownAddressType, + addrType) + default: + return nil, fmt.Errorf("%w: %v", ErrUnknownAddressType, + addrType) + } + + // Check if we have already signed this input. + for _, sig := range pInput.PartialSigs { + if bytes.Equal(sig.PubKey, derivation.PubKey) { + return nil, errAlreadySigned + } + } + + return details, nil +} + +// signTaprootPsbtInput attempts to sign a single Taproot input of a PSBT. +// +// It performs the following steps: +// 1. Parses the BIP32 derivation path to ensure it is valid. +// 2. Determines the specific spending path (Key Path vs Script Path) using +// createTaprootSpendDetails. +// 3. Computes the raw Schnorr signature using the wallet's signer. +// 4. Adds the generated signature to the PSBT input (either as the key spend +// signature or as a script spend signature). +// +// Returns an error if the input is invalid, the key is not found, or the +// signing operation fails. +func (w *Wallet) signTaprootPsbtInput(ctx context.Context, packet *psbt.Packet, + idx int, sigHashes *txscript.TxSigHashes, + tweaker PrivKeyTweaker) error { + + // It is safe to access packet.Inputs[idx] directly here because + // SignPsbt calls psbt.InputsReadyToSign before this method, which + // ensures that the Inputs slice corresponds to the UnsignedTx inputs. + pInput := &packet.Inputs[idx] + + // Fetch the UTXO (Witness or NonWitness) needed for signing. + utxo, err := fetchPsbtUtxo(packet, idx) + if err != nil { + return err + } + + tapDerivation := pInput.TaprootBip32Derivation[0] + + // Parse and validate the BIP32 derivation path. + path, err := w.parseBip32Path(tapDerivation.Bip32Path) + if err != nil { + // If the derivation path is invalid, we can't sign. + return fmt.Errorf("invalid derivation path: %w", err) + } + + // Determine the SpendDetails (Key Path or Script Path). + details, err := createTaprootSpendDetails(pInput, tapDerivation) + if err != nil { + return err + } + + params := &RawSigParams{ + Tx: packet.UnsignedTx, + InputIndex: idx, + Output: utxo, + SigHashes: sigHashes, + HashType: pInput.SighashType, + Path: path, + Tweaker: tweaker, + Details: details, + } + + // Compute the raw signature. + sig, err := w.ComputeRawSig(ctx, params) + if err != nil { + return fmt.Errorf("%w: %w", errComputeRawSig, err) + } + + // Apply the signature to the PSBT input. + addTaprootSigToPInput( + pInput, sig, params.HashType, details, tapDerivation, + ) + + return nil +} + +// signBip32PsbtInput attempts to sign a single non-Taproot (Legacy/SegWit) +// input of a PSBT. +// +// It performs the following steps: +// 1. Parses the BIP32 derivation path to determine the address type. +// 2. Constructs the spending details (redeem scripts, etc.) using +// createBip32SpendDetails. +// 3. Computes the raw ECDSA signature using the wallet's signer. +// 4. Adds the generated signature to the PSBT input's PartialSigs list. +// +// Returns an error if the input is invalid, the key is not found, or the +// signing operation fails. +func (w *Wallet) signBip32PsbtInput(ctx context.Context, packet *psbt.Packet, + idx int, sigHashes *txscript.TxSigHashes, + tweaker PrivKeyTweaker) error { + + // It is safe to access packet.Inputs[idx] directly here because + // SignPsbt calls psbt.InputsReadyToSign before this method, which + // ensures that the Inputs slice corresponds to the UnsignedTx inputs. + pInput := &packet.Inputs[idx] + + // Fetch the UTXO (Witness or NonWitness) needed for signing. + utxo, err := fetchPsbtUtxo(packet, idx) + if err != nil { + return err + } + + derivation := pInput.Bip32Derivation[0] + + // Parse and validate the BIP32 derivation path. + path, err := w.parseBip32Path(derivation.Bip32Path) + if err != nil { + return fmt.Errorf("invalid derivation path: %w", err) + } + + addrType, err := addressTypeFromPurpose(path.KeyScope.Purpose) + if err != nil { + return err + } + + // Construct SpendDetails for Legacy/SegWit input. + details, err := createBip32SpendDetails( + pInput, utxo, addrType, derivation, + ) + if err != nil { + return err + } + + params := &RawSigParams{ + Tx: packet.UnsignedTx, + InputIndex: idx, + Output: utxo, + SigHashes: sigHashes, + HashType: pInput.SighashType, + Path: path, + Tweaker: tweaker, + Details: details, + } + + // Compute the raw signature. + sig, err := w.ComputeRawSig(ctx, params) + if err != nil { + return fmt.Errorf("%w: %w", errComputeRawSig, err) + } + + // Apply the signature to the PSBT input. + addBip32SigToPInput(pInput, sig, params.HashType, derivation, addrType) + + return nil +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 81a4a2d137..b7f7c8b107 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -28,7 +28,10 @@ import ( "github.com/stretchr/testify/require" ) -var errDb = errors.New("db error") +var ( + errDb = errors.New("db error") + errKeyNotFound = errors.New("key not found") +) // TestFindCredit tests that the findCredit helper returns true if a credit // exists at the specified index, and false otherwise. @@ -2608,3 +2611,681 @@ func TestAddBip32SigToPInput(t *testing.T) { }) } } + +// TestCreateTaprootSpendDetails tests that createTaprootSpendDetails correctly +// constructs the TaprootSpendDetails required for signing, handling both Key +// Path and Script Path spends. +func TestCreateTaprootSpendDetails(t *testing.T) { + t.Parallel() + + // Helpers + xOnlyPubKey := bytes.Repeat([]byte{0x01}, 32) + leafHash := bytes.Repeat([]byte{0x02}, 32) + leafScript := []byte{0x51} // OP_TRUE + merkleRoot := bytes.Repeat([]byte{0x03}, 32) + + // Calculate expected hash for success case + tapLeaf := txscript.NewBaseTapLeaf(leafScript) + tapHash := tapLeaf.TapHash() + leafHashCalculated := tapHash[:] + + // Define slice literals. + tapLeafScriptSuccess := []*psbt.TaprootTapLeafScript{{ + LeafVersion: txscript.BaseLeafVersion, + Script: leafScript, + }} + + tests := []struct { + name string + pInput *psbt.PInput + tapDerivation *psbt.TaprootBip32Derivation + expected TaprootSpendDetails + err error + }{ + { + name: "key path spend success", + pInput: &psbt.PInput{ + TaprootMerkleRoot: merkleRoot, + }, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: nil, // Empty -> Key Path + }, + expected: TaprootSpendDetails{ + SpendPath: KeyPathSpend, + Tweak: merkleRoot, + }, + err: nil, + }, + { + name: "key path spend invalid merkle root length", + pInput: &psbt.PInput{ + // Invalid length + TaprootMerkleRoot: []byte{0x01}, + }, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: nil, + }, + expected: TaprootSpendDetails{}, + err: ErrInvalidTaprootMerkleRootLength, + }, + { + name: "key path spend already signed", + pInput: &psbt.PInput{ + // Already signed + TaprootKeySpendSig: []byte{0x01}, + }, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: nil, + }, + expected: TaprootSpendDetails{ + SpendPath: KeyPathSpend, + Tweak: nil, + }, + err: errAlreadySigned, + }, + { + name: "script path spend success", + pInput: &psbt.PInput{ + TaprootLeafScript: tapLeafScriptSuccess, + }, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: [][]byte{leafHashCalculated}, + }, + expected: TaprootSpendDetails{ + SpendPath: ScriptPathSpend, + WitnessScript: leafScript, + }, + err: nil, + }, + { + name: "script path spend mismatch hash", + pInput: &psbt.PInput{ + TaprootLeafScript: tapLeafScriptSuccess, + }, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: [][]byte{leafHash}, // Mismatch + }, + expected: TaprootSpendDetails{}, + err: ErrTaprootLeafHashMismatch, + }, + { + name: "script path spend missing script", + pInput: &psbt.PInput{ + TaprootLeafScript: nil, // Missing + }, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: [][]byte{leafHash}, + }, + expected: TaprootSpendDetails{}, + err: ErrMissingTaprootLeafScript, + }, + { + name: "script path spend multiple leaves unsupported", + pInput: &psbt.PInput{}, + tapDerivation: &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + LeafHashes: [][]byte{leafHash, leafHash}, + }, + expected: TaprootSpendDetails{}, + err: ErrUnsupportedTaprootLeafCount, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Call createTaprootSpendDetails with the + // configured input and derivation. + details, err := createTaprootSpendDetails( + tc.pInput, tc.tapDerivation, + ) + + // Assert: Verify that the returned spend details match + // the expected values (SpendPath, Tweak, or + // WitnessScript) and that any expected errors are + // returned. + require.ErrorIs(t, err, tc.err) + require.Equal(t, tc.expected, details) + }) + } +} + +// TestCreateBip32SpendDetails tests that createBip32SpendDetails correctly +// constructs the SpendDetails required for signing BIP32 inputs, supporting +// various address types. +func TestCreateBip32SpendDetails(t *testing.T) { + t.Parallel() + + pubKey := bytes.Repeat([]byte{0x02}, 33) + sig := []byte{0x01} + + tests := []struct { + name string + pInput *psbt.PInput + utxo *wire.TxOut + addrType waddrmgr.AddressType + derivation *psbt.Bip32Derivation + expected SpendDetails + err error + }{ + { + name: "p2wkh success", + pInput: &psbt.PInput{ + WitnessScript: []byte{0x03}, + }, + utxo: &wire.TxOut{}, + addrType: waddrmgr.WitnessPubKey, + derivation: &psbt.Bip32Derivation{ + PubKey: pubKey, + }, + expected: SegwitV0SpendDetails{ + WitnessScript: []byte{0x03}, + }, + err: nil, + }, + { + name: "p2pkh success", + pInput: &psbt.PInput{}, + utxo: &wire.TxOut{ + PkScript: []byte{0x04}, + }, + addrType: waddrmgr.PubKeyHash, + derivation: &psbt.Bip32Derivation{ + PubKey: pubKey, + }, + expected: LegacySpendDetails{ + RedeemScript: []byte{0x04}, + }, + err: nil, + }, + { + name: "nested p2wkh success", + pInput: &psbt.PInput{ + RedeemScript: []byte{0x05}, + }, + utxo: &wire.TxOut{}, + addrType: waddrmgr.NestedWitnessPubKey, + derivation: &psbt.Bip32Derivation{ + PubKey: pubKey, + }, + expected: SegwitV0SpendDetails{ + WitnessScript: []byte{0x05}, + }, + err: nil, + }, + { + name: "unknown address type", + pInput: &psbt.PInput{}, + utxo: &wire.TxOut{}, + addrType: waddrmgr.Script, // Not supported + derivation: &psbt.Bip32Derivation{ + PubKey: pubKey, + }, + expected: nil, + err: ErrUnknownAddressType, + }, + { + name: "already signed", + pInput: &psbt.PInput{ + PartialSigs: []*psbt.PartialSig{{ + PubKey: pubKey, + Signature: sig, + }}, + }, + utxo: &wire.TxOut{}, + addrType: waddrmgr.WitnessPubKey, + derivation: &psbt.Bip32Derivation{ + PubKey: pubKey, + }, + expected: nil, + err: errAlreadySigned, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Call createBip32SpendDetails with the configured + // input and UTXO information. + details, err := createBip32SpendDetails( + tc.pInput, tc.utxo, tc.addrType, tc.derivation, + ) + + // Assert: Verify that the returned spend details + // correctly reflect the address type (Legacy vs Segwit) + // and contain the expected scripts, or that an error is + // returned for invalid states. + require.ErrorIs(t, err, tc.err) + require.Equal(t, tc.expected, details) + }) + } +} + +// TestSignTaprootPsbtInput tests that signTaprootPsbtInput successfully +// generates and appends a signature for a valid Taproot input. +func TestSignTaprootPsbtInput(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + xOnlyPubKey := schnorr.SerializePubKey(pubKey) + + // Arrange: Define the BIP32 derivation path and Taproot derivation + // information for the input key. + derivationPath := []uint32{ + hdkeychain.HardenedKeyStart + 86, + hdkeychain.HardenedKeyStart + 1, + hdkeychain.HardenedKeyStart + 0, + 0, 0, + } + tapDerivation := &psbt.TaprootBip32Derivation{ + XOnlyPubKey: xOnlyPubKey, + Bip32Path: derivationPath, + } + + // Arrange: Create a dummy UTXO with a Taproot script to be signed. + utxo := &wire.TxOut{ + Value: 1000, + // Dummy Taproot script + PkScript: bytes.Repeat([]byte{0x51}, 34), + } + + // Arrange: Create a PSBT packet containing the transaction with the + // Taproot input. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + packet.Inputs[0].WitnessUtxo = utxo + tapDerivations := []*psbt.TaprootBip32Derivation{tapDerivation} + packet.Inputs[0].TaprootBip32Derivation = tapDerivations + packet.Inputs[0].SighashType = txscript.SigHashDefault + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock address lookup flow. + // 1. FetchScopedKeyManager + mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(mocks.accountManager, nil) + + // 2. DeriveFromKeyPath (called inside walletdb.View) + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil) + + // 3. Address/PrivKey from ManagedAddress + mocks.pubKeyAddr.On("PrivKey").Return(privKey, nil) + + // Act: Call signTaprootPsbtInput to sign the input using the mocked + // wallet and keys. + sigHashes := txscript.NewTxSigHashes( + tx, txscript.NewCannedPrevOutputFetcher( + packet.Inputs[0].WitnessUtxo.PkScript, + packet.Inputs[0].WitnessUtxo.Value, + ), + ) + err = w.signTaprootPsbtInput(t.Context(), packet, 0, sigHashes, nil) + + // Assert: Verify that no error occurred and that the TaprootKeySpendSig + // field in the PSBT input is now populated with a signature. + require.NoError(t, err) + require.NotEmpty(t, packet.Inputs[0].TaprootKeySpendSig) +} + +// TestSignBip32PsbtInput tests that signBip32PsbtInput successfully generates +// and appends a signature for a valid BIP32 (SegWit v0) input. +func TestSignBip32PsbtInput(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + pubKeyBytes := pubKey.SerializeCompressed() + + // Arrange: Define the BIP32 derivation path for the input key (BIP-84 + // P2WKH). + derivationPath := []uint32{ + hdkeychain.HardenedKeyStart + 84, + hdkeychain.HardenedKeyStart + 1, + hdkeychain.HardenedKeyStart + 0, + 0, 0, + } + derivation := &psbt.Bip32Derivation{ + PubKey: pubKeyBytes, + Bip32Path: derivationPath, + } + + // Arrange: Create a P2WKH UTXO using the public key derived from the + // path. + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKeyBytes), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + utxo := &wire.TxOut{ + Value: 1000, + PkScript: p2wkhScript, + } + + // Arrange: Create a PSBT packet containing the transaction with the + // BIP32 input. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + packet.Inputs[0].WitnessUtxo = utxo + packet.Inputs[0].Bip32Derivation = []*psbt.Bip32Derivation{derivation} + packet.Inputs[0].SighashType = txscript.SigHashAll + packet.Inputs[0].WitnessScript = p2wkhScript + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock address lookup flow. + mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(mocks.accountManager, nil) + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil) + mocks.pubKeyAddr.On("PrivKey").Return(privKey, nil) + + // Act: Call signBip32PsbtInput to sign the input using the mocked + // wallet and keys. + sigHashes := txscript.NewTxSigHashes( + tx, txscript.NewCannedPrevOutputFetcher( + packet.Inputs[0].WitnessUtxo.PkScript, + packet.Inputs[0].WitnessUtxo.Value, + ), + ) + err = w.signBip32PsbtInput(t.Context(), packet, 0, sigHashes, nil) + + // Assert: Verify that no error occurred and that the PartialSigs field + // in the PSBT input is populated with a signature from the expected + // public key. + require.NoError(t, err) + require.Len(t, packet.Inputs[0].PartialSigs, 1) + require.Equal(t, pubKeyBytes, packet.Inputs[0].PartialSigs[0].PubKey) +} + +// TestSignPsbtFailNilParams tests that SignPsbt returns ErrNilSignPsbtParams +// when provided with nil parameters. +func TestSignPsbtFailNilParams(t *testing.T) { + t.Parallel() + + // Arrange: Create a mock wallet. + w, _ := testWalletWithMocks(t) + + // Act: Call SignPsbt with nil params. + _, err := w.SignPsbt(t.Context(), nil) + + // Assert: Verify error. + require.ErrorIs(t, err, ErrNilSignPsbtParams) +} + +// TestSignPsbt tests the high-level SignPsbt method, ensuring it correctly +// orchestrates the signing process for a PSBT packet with valid inputs. +func TestSignPsbt(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + pubKeyBytes := pubKey.SerializeCompressed() + + // Arrange: Define the BIP32 derivation path for the input key + // (RegressionNet, P2WKH). + derivationPath := []uint32{ + hdkeychain.HardenedKeyStart + 84, + // CoinType 1 (RegressionNet) + hdkeychain.HardenedKeyStart + 1, + hdkeychain.HardenedKeyStart + 0, + 0, 0, + } + derivation := &psbt.Bip32Derivation{ + PubKey: pubKeyBytes, + Bip32Path: derivationPath, + } + + // Arrange: Create a P2WKH UTXO that corresponds to the derivation path, + // representing the input to be signed. + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKeyBytes), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + utxo := &wire.TxOut{ + Value: 1000, + PkScript: p2wkhScript, + } + + // Arrange: Create a PSBT packet containing the transaction to be + // signed. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: []byte{}}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + packet.Inputs[0].WitnessUtxo = utxo + packet.Inputs[0].Bip32Derivation = []*psbt.Bip32Derivation{derivation} + packet.Inputs[0].SighashType = txscript.SigHashAll + + // Arrange: Wrap the packet in SignPsbtParams. + signParams := &SignPsbtParams{ + Packet: packet, + } + + w, mocks := testWalletWithMocks(t) + + // Arrange: Configure mock expectations for key derivation and private + // key retrieval. + mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(mocks.accountManager, nil) + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil) + mocks.pubKeyAddr.On("PrivKey").Return(privKey, nil) + + // Act: Call SignPsbt to perform the full signing workflow on the + // packet. + result, err := w.SignPsbt(t.Context(), signParams) + + // Assert: Verify that the operation succeeded, the input is reported as + // signed, and the underlying PSBT packet contains the generated + // signature. + require.NoError(t, err) + require.Len(t, result.SignedInputs, 1) + require.Equal(t, uint32(0), result.SignedInputs[0]) + require.Len(t, packet.Inputs[0].PartialSigs, 1) +} + +// TestSignPsbtInputsNotReady tests that SignPsbt fails if inputs are not ready +// (missing WitnessUtxo/NonWitnessUtxo). +func TestSignPsbtInputsNotReady(t *testing.T) { + t.Parallel() + + // Arrange: Packet with input but no UTXO info. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + signParams := &SignPsbtParams{Packet: packet} + w, _ := testWalletWithMocks(t) + + // Act. + _, err = w.SignPsbt(t.Context(), signParams) + + // Assert. + require.ErrorContains(t, err, "psbt inputs not ready") +} + +// TestSignPsbtInvalidDerivationPath tests that SignPsbt returns a fatal error +// if the derivation path is invalid. +func TestSignPsbtInvalidDerivationPath(t *testing.T) { + t.Parallel() + + // Arrange: Packet with valid UTXO but invalid derivation path. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: []byte{}}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + packet.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 1000, + PkScript: []byte{0x00, 0x14}, // P2WKH dummy + } + // Invalid path (too short). + packet.Inputs[0].Bip32Derivation = []*psbt.Bip32Derivation{{ + Bip32Path: []uint32{1, 2, 3}, + PubKey: make([]byte, 33), + }} + + signParams := &SignPsbtParams{Packet: packet} + w, _ := testWalletWithMocks(t) + + // Act. + _, err = w.SignPsbt(t.Context(), signParams) + + // Assert. + require.ErrorIs(t, err, ErrInvalidBip32PathLength) +} + +// TestSignPsbtSignErrorSkippable tests that SignPsbt skips an input if +// signing fails with a skippable error (e.g. key not found). +func TestSignPsbtSignErrorSkippable(t *testing.T) { + t.Parallel() + + // Arrange: Packet with valid input. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: []byte{}}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + p2wkhScript, _ := txscript.PayToAddrScript( + &btcutil.AddressWitnessPubKeyHash{}, + ) // Dummy script + packet.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 1000, + PkScript: p2wkhScript, + } + // Valid path. + packet.Inputs[0].Bip32Derivation = []*psbt.Bip32Derivation{{ + Bip32Path: []uint32{ + hdkeychain.HardenedKeyStart + 84, + hdkeychain.HardenedKeyStart + 1, + hdkeychain.HardenedKeyStart + 0, + 0, 0, + }, + PubKey: make([]byte, 33), + }} + packet.Inputs[0].SighashType = txscript.SigHashAll + + signParams := &SignPsbtParams{Packet: packet} + w, mocks := testWalletWithMocks(t) + + // Arrange: Mocks to simulate signing failure. + mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(mocks.accountManager, nil) + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil) + + // PrivKey returns error! + mocks.pubKeyAddr.On("PrivKey").Return(nil, errKeyNotFound) + + // Act. + result, err := w.SignPsbt(t.Context(), signParams) + + // Assert: No error, but nothing signed. + require.NoError(t, err) + require.Empty(t, result.SignedInputs) +} + +// TestSignTaprootPsbtInputErrors tests various error conditions in +// signTaprootPsbtInput. +func TestSignTaprootPsbtInputErrors(t *testing.T) { + t.Parallel() + + w, _ := testWalletWithMocks(t) + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // Arrange: Add a dummy Witness UTXO to satisfy validity checks. + packet.Inputs[0].WitnessUtxo = &wire.TxOut{} + + // Case 1: Invalid Derivation Path. + tapDerivation := []*psbt.TaprootBip32Derivation{{ + Bip32Path: []uint32{1}, // Too short + }} + packet.Inputs[0].TaprootBip32Derivation = tapDerivation + err = w.signTaprootPsbtInput(t.Context(), packet, 0, nil, nil) + require.ErrorIs(t, err, ErrInvalidBip32PathLength) + + // Case 2: CreateTaprootSpendDetails error (e.g. invalid merkle root). + packet.Inputs[0].TaprootBip32Derivation[0].Bip32Path = []uint32{ + hdkeychain.HardenedKeyStart + 86, + hdkeychain.HardenedKeyStart + 1, + hdkeychain.HardenedKeyStart + 0, + 0, 0, + } + packet.Inputs[0].TaprootMerkleRoot = []byte{0x01} // Invalid length + err = w.signTaprootPsbtInput(t.Context(), packet, 0, nil, nil) + require.ErrorIs(t, err, ErrInvalidTaprootMerkleRootLength) +} + +// TestSignBip32PsbtInputErrors tests various error conditions in +// signBip32PsbtInput. +func TestSignBip32PsbtInputErrors(t *testing.T) { + t.Parallel() + + w, _ := testWalletWithMocks(t) + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // Arrange: Add a dummy Witness UTXO to satisfy validity checks. + packet.Inputs[0].WitnessUtxo = &wire.TxOut{} + + // Case 1: Invalid Derivation Path. + packet.Inputs[0].Bip32Derivation = []*psbt.Bip32Derivation{{ + Bip32Path: []uint32{1}, // Too short + }} + err = w.signBip32PsbtInput(t.Context(), packet, 0, nil, nil) + require.ErrorIs(t, err, ErrInvalidBip32PathLength) + + // Case 2: Unknown Purpose. + packet.Inputs[0].Bip32Derivation[0].Bip32Path = []uint32{ + hdkeychain.HardenedKeyStart + 999, // Unknown + hdkeychain.HardenedKeyStart + 1, + hdkeychain.HardenedKeyStart + 0, + 0, 0, + } + err = w.signBip32PsbtInput(t.Context(), packet, 0, nil, nil) + require.ErrorIs(t, err, ErrUnknownBip32Purpose) +} From af4e7144a72d0b5496e5fe5ec94c4eaa7386e543 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Dec 2025 23:29:33 +0800 Subject: [PATCH 199/691] wallet: fix linter error --- wallet/utxo_manager_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index eebffcc065..20f4344176 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -26,7 +26,7 @@ func TestListUnspent(t *testing.T) { w, mocks := testWalletWithMocks(t) // Define account names. - account1 := "default" + account1 := defaultAccountName account2 := "test" // Create the addresses that our mocks will return. From 7f130140fe8ad176388096f28203c38f9cbc2604 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 27 Nov 2025 21:16:59 +0800 Subject: [PATCH 200/691] wallet: implement `FinalizePsbt` The new FinalizePsbt implementation diverges from the deprecated method by decoupling signing logic from account scoping. Instead of requiring an account number and checking for watch-only status explicitly, it iterates over all inputs and attempts to sign them using the modern Signer interface (ComputeUnlockingScript). Advantages: 1. Robustness: It attempts to sign any input for which the wallet possesses the private key, supporting mixed-account transactions naturally. 2. Safety: Defensive checks in fetchPsbtUtxo and FinalizePsbt prevent panics on malformed PSBTs (missing UTXO info), logging warnings instead. 3. Multi-party Support: Inputs that cannot be signed (e.g., belonging to other parties) are skipped gracefully, allowing MaybeFinalizeAll to complete the transaction using existing partial signatures if present. This approach simplifies the API (no account args) and aligns with the PsbtManager's goal of being a general-purpose PSBT handler. --- wallet/psbt_manager.go | 138 ++++++++++++++ wallet/psbt_manager_test.go | 362 +++++++++++++++++++++++++++++++++++- 2 files changed, 490 insertions(+), 10 deletions(-) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 83db280606..f8a3d09387 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -1607,3 +1607,141 @@ func (w *Wallet) signBip32PsbtInput(ctx context.Context, packet *psbt.Packet, return nil } + +// FinalizePsbt finalizes the PSBT. +// +// It performs the finalization by: +// 1. Auto-Signing: Iterating through all inputs and calling `finalizeInput`. +// This helper attempts to generate a signature and script witness for any +// inputs owned by the wallet that are missing them. +// 2. Completion: Calling `psbt.MaybeFinalizeAll`, which checks if every input +// in the packet has the necessary data to pass script validation. If so, it +// constructs the final witnesses and strips the PSBT metadata, leaving a +// ready-to-broadcast transaction. +func (w *Wallet) FinalizePsbt(ctx context.Context, packet *psbt.Packet) error { + // Check that the PSBT is structurally ready to be signed/finalized. + err := psbt.InputsReadyToSign(packet) + if err != nil { + return fmt.Errorf("psbt inputs not ready: %w", err) + } + + tx := packet.UnsignedTx + + // We create a `PrevOutputFetcher` to allow `txscript` to retrieve the + // previous transaction outputs needed for sighash generation. This is + // required for generating valid signatures, as the value and script of + // the UTXO being spent are part of the signed digest. + prevOutFetcher := PsbtPrevOutputFetcher(packet) + + // Compute the transaction's sighashes. This is an optimization to + // calculate the sighashes once and reuse them for all inputs, rather + // than recalculating them for each signature. This is particularly + // beneficial for transactions with many inputs. + sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) + + // Iterate through each input in the PSBT. For each input, we will + // check if we can sign and finalize it (i.e., if we own the UTXO and + // have the private key). + for i := range packet.Inputs { + err := w.finalizeInput(ctx, packet, i, sigHashes) + if err != nil { + return err + } + } + + // Finally, attempt to finalize the entire PSBT. This will check if all + // inputs have final scripts (either added by us above or constructed + // from PartialSigs by the psbt library) and strip the partial data. + err = psbt.MaybeFinalizeAll(packet) + if err != nil { + return fmt.Errorf("error finalizing PSBT: %w", err) + } + + return nil +} + +// finalizeInput attempts to finalize a single input of the PSBT. +func (w *Wallet) finalizeInput(ctx context.Context, packet *psbt.Packet, + idx int, sigHashes *txscript.TxSigHashes) error { + + pInput := &packet.Inputs[idx] + + // If the input is already finalized, we can skip it. + if len(pInput.FinalScriptWitness) > 0 || + len(pInput.FinalScriptSig) > 0 { + + log.Debugf("Skipping input %d: already finalized", idx) + return nil + } + + // Fetch the UTXO for this input. + utxo, err := fetchPsbtUtxo(packet, idx) + if err != nil { + // This should not happen if InputsReadyToSign passed (which is + // called at the start of the function), as it guarantees the + // presence of WitnessUtxo or NonWitnessUtxo. However, for + // defensive programming, we log an error and continue to avoid + // aborting the process in case of unexpected data + // inconsistency. + log.Errorf("Input %d has no UTXO info: %v", idx, err) + return nil + } + + // Attempt to compute the unlocking script (witness and/or + // sigScript) for this input. + params := &UnlockingScriptParams{ + Tx: packet.UnsignedTx, + InputIndex: idx, + Output: utxo, + SigHashes: sigHashes, + HashType: pInput.SighashType, + } + + unlockingScript, err := w.ComputeUnlockingScript(ctx, params) + if err != nil { + // If we can't generate the script (e.g. we don't own the key, + // or it's a type we don't support yet, or the account is + // watch-only), we just skip this input and let the finalizer + // try to use any existing partial signatures. + log.Debugf("Could not compute unlocking script for "+ + "input %d: %v", idx, err) + + return nil + } + + err = addScriptToPInput(pInput, unlockingScript) + if err != nil { + return fmt.Errorf("failed to patch input %d: %w", + idx, err) + } + + return nil +} + +// addScriptToPInput applies the generated witness and/or sigScript to the PSBT +// input. +func addScriptToPInput(pInput *psbt.PInput, + unlockingScript *UnlockingScript) error { + + // If we successfully generated a witness, serialize and attach + // it. + if len(unlockingScript.Witness) > 0 { + var witnessBuf bytes.Buffer + + err := psbt.WriteTxWitness(&witnessBuf, unlockingScript.Witness) + if err != nil { + return fmt.Errorf("failed to serialize witness: %w", + err) + } + + pInput.FinalScriptWitness = witnessBuf.Bytes() + } + + // If we generated a sigScript (for legacy/nested P2SH), attach + // it. + if len(unlockingScript.SigScript) > 0 { + pInput.FinalScriptSig = unlockingScript.SigScript + } + + return nil +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index b7f7c8b107..a1317f212b 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -29,8 +29,9 @@ import ( ) var ( - errDb = errors.New("db error") - errKeyNotFound = errors.New("key not found") + errDb = errors.New("db error") + errKeyNotFound = errors.New("key not found") + errAddrNotFound = errors.New("addr not found") ) // TestFindCredit tests that the findCredit helper returns true if a credit @@ -3278,14 +3279,355 @@ func TestSignBip32PsbtInputErrors(t *testing.T) { }} err = w.signBip32PsbtInput(t.Context(), packet, 0, nil, nil) require.ErrorIs(t, err, ErrInvalidBip32PathLength) +} - // Case 2: Unknown Purpose. - packet.Inputs[0].Bip32Derivation[0].Bip32Path = []uint32{ - hdkeychain.HardenedKeyStart + 999, // Unknown - hdkeychain.HardenedKeyStart + 1, - hdkeychain.HardenedKeyStart + 0, - 0, 0, +// TestAddScriptToPInput tests that addScriptToPInput correctly updates +// the PSBT input with the provided witness and/or sigScript. +func TestAddScriptToPInput(t *testing.T) { + t.Parallel() + + // Arrange: Dummy witness and sigScript. + witness := wire.TxWitness{[]byte{0x01}, []byte{0x02}} + sigScript := []byte{0x03} + + // Arrange: Expected serialized witness: + // - 0x02 (stack items) + // - 0x01 (len) + 0x01 (data) + // - 0x01 (len) + 0x02 (data) + expectedWitness := []byte{0x02, 0x01, 0x01, 0x01, 0x02} + + tests := []struct { + name string + witness wire.TxWitness + sigScript []byte + expectedWitness []byte + expectedSig []byte + }{ + { + name: "witness only", + witness: witness, + sigScript: nil, + expectedWitness: expectedWitness, + expectedSig: nil, + }, + { + name: "sigScript only", + witness: nil, + sigScript: sigScript, + expectedWitness: nil, + expectedSig: sigScript, + }, + { + name: "both", + witness: witness, + sigScript: sigScript, + expectedWitness: expectedWitness, + expectedSig: sigScript, + }, + { + name: "none", + witness: nil, + sigScript: nil, + expectedWitness: nil, + expectedSig: nil, + }, } - err = w.signBip32PsbtInput(t.Context(), packet, 0, nil, nil) - require.ErrorIs(t, err, ErrUnknownBip32Purpose) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Arrange: UnlockingScript and target PInput. + script := &UnlockingScript{ + Witness: tc.witness, + SigScript: tc.sigScript, + } + pInput := &psbt.PInput{} + + // Act: Call addScriptToPInput. + err := addScriptToPInput(pInput, script) + + // Assert: Verify no error and fields match + // expectations. + require.NoError(t, err) + require.Equal(t, tc.expectedWitness, + pInput.FinalScriptWitness) + require.Equal(t, tc.expectedSig, + pInput.FinalScriptSig) + }) + } +} + +// TestFinalizeInput tests that finalizeInput correctly processes a single PSBT +// input, handling success, skips, and errors. +func TestFinalizeInput(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + t.Run("success", func(t *testing.T) { + t.Parallel() + // Arrange: Valid PSBT input. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + packet.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 1000, + PkScript: p2wkhScript, + } + packet.Inputs[0].SighashType = txscript.SigHashAll + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock dependencies. + mocks.addrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(mocks.pubKeyAddr, nil) + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) + mocks.pubKeyAddr.On("PrivKey").Return(privKey, nil) + + sigHashes := txscript.NewTxSigHashes( + tx, txscript.NewCannedPrevOutputFetcher( + packet.Inputs[0].WitnessUtxo.PkScript, + packet.Inputs[0].WitnessUtxo.Value, + ), + ) + + // Act. + err = w.finalizeInput(t.Context(), packet, 0, sigHashes) + + // Assert. + require.NoError(t, err) + require.NotEmpty(t, packet.Inputs[0].FinalScriptWitness) + }) + + t.Run("skip finalized", func(t *testing.T) { + t.Parallel() + // Arrange: Already finalized input. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + packet.Inputs[0].FinalScriptWitness = []byte{0x01} + + w, _ := testWalletWithMocks(t) + + // Act. + err = w.finalizeInput(t.Context(), packet, 0, nil) + + // Assert: No error, remains unchanged (mock not called). + require.NoError(t, err) + }) + + t.Run("skip missing utxo", func(t *testing.T) { + t.Parallel() + // Arrange: Input without UTXO. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + w, _ := testWalletWithMocks(t) + + // Act. + err = w.finalizeInput(t.Context(), packet, 0, nil) + + // Assert: No error (logs error but continues). + require.NoError(t, err) + }) + + t.Run("skip malformed script", func(t *testing.T) { + t.Parallel() + // Arrange: Input with malformed pkScript. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // OP_RETURN script cannot be extracted as an address. + packet.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 1000, + PkScript: []byte{0x6a}, + } + + w, _ := testWalletWithMocks(t) + + // Act. + err = w.finalizeInput(t.Context(), packet, 0, nil) + + // Assert: No error (logs error but continues). + require.NoError(t, err) + }) +} + +// TestFinalizePsbtSuccess tests that FinalizePsbt successfully generates +// witnesses for supported input types (P2WKH, Taproot). +func TestFinalizePsbtSuccess(t *testing.T) { + t.Parallel() + + // Arrange: Setup keys. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKey := privKey.PubKey() + + // Arrange: Create addresses/scripts. + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + trAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(pubKey), &chainParams, + ) + require.NoError(t, err) + trScript, err := txscript.PayToAddrScript(trAddr) + require.NoError(t, err) + + tests := []struct { + name string + pkScript []byte + addrType waddrmgr.AddressType + addr btcutil.Address + }{ + { + name: "p2wkh", + pkScript: p2wkhScript, + addrType: waddrmgr.WitnessPubKey, + addr: p2wkhAddr, + }, + { + name: "taproot", + pkScript: trScript, + addrType: waddrmgr.TaprootPubKey, + addr: trAddr, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Arrange: Create PSBT. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1000}) // Add output + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + packet.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 1000, + PkScript: tc.pkScript, + } + packet.Inputs[0].SighashType = txscript.SigHashDefault + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock address lookup. + mocks.addrStore.On( + "Address", mock.Anything, + mock.MatchedBy(func(a btcutil.Address) bool { + return a.String() == tc.addr.String() + }), + ).Return(mocks.pubKeyAddr, nil) + + // Arrange: Mock ManagedPubKeyAddress. + // Note: Address() and PubKey() are not called for + // P2WKH/Taproot signing paths in + // ComputeUnlockingScript. + mocks.pubKeyAddr.On("AddrType").Return(tc.addrType) + + // Create a copy of the private key to avoid data races + // when parallel tests call Zero() on it. + privKeyCopy := *privKey + mocks.pubKeyAddr.On("PrivKey").Return(&privKeyCopy, nil) + + // Act: Call FinalizePsbt. + err = w.FinalizePsbt(t.Context(), packet) + + // Assert: Verify success and witness presence. + require.NoError(t, err) + require.NotEmpty( + t, packet.Inputs[0].FinalScriptWitness, + ) + }) + } +} + +// TestFinalizePsbtErrors tests error conditions for FinalizePsbt. +func TestFinalizePsbtErrors(t *testing.T) { + t.Parallel() + + t.Run("inputs not ready", func(t *testing.T) { + t.Parallel() + // Arrange: Packet with input but no UTXO info. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1000}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + w, _ := testWalletWithMocks(t) + + // Act. + err = w.FinalizePsbt(t.Context(), packet) + + // Assert. + require.ErrorContains(t, err, "psbt inputs not ready") + }) + + t.Run("finalization failed", func(t *testing.T) { + t.Parallel() + // Arrange: Packet with valid UTXO but we can't sign it (watch + // only). + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1000}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // Use a valid P2WKH script so extraction succeeds. + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + dummyScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + packet.Inputs[0].WitnessUtxo = &wire.TxOut{ + Value: 1000, + PkScript: dummyScript, + } + + w, mocks := testWalletWithMocks(t) + + // Arrange: Mock Address lookup to return error (or watch only). + // Simulating "Address not found" or "Key not found". + // ComputeUnlockingScript will fail, log, and continue. + // Then MaybeFinalizeAll will fail because no witness. + mocks.addrStore.On("Address", mock.Anything, mock.Anything). + Return(nil, errAddrNotFound) + + // Act. + err = w.FinalizePsbt(t.Context(), packet) + + // Assert: Should return error from MaybeFinalizeAll. + require.ErrorContains(t, err, "error finalizing PSBT") + }) } From e7a0ada5fdb35889b0385f509a62be2c6aa0e865 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 26 Nov 2025 08:22:37 +0800 Subject: [PATCH 201/691] wallet: implement `CombinePsbt` --- wallet/psbt_manager.go | 448 ++++++++++++++++++++++ wallet/psbt_manager_test.go | 745 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1193 insertions(+) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index f8a3d09387..e7f8ab7e0a 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -1745,3 +1745,451 @@ func addScriptToPInput(pInput *psbt.PInput, return nil } + +// CombinePsbt merges multiple PSBTs into one. +// +// It implements the "Combiner" role by performing two passes: +// 1. Validation Pass: It iterates through all packets to ensure they refer to +// the exact same global transaction (TXID) and have matching input/output +// counts. +// 2. Construction Pass: It creates a new, combined PSBT packet (to avoid +// mutating inputs). It then iterates through every provided packet +// (including the first) and merges its data into the combined result. This +// includes deduplicating signatures and aggregating scripts/derivations. +func (w *Wallet) CombinePsbt(_ context.Context, psbts ...*psbt.Packet) ( + *psbt.Packet, error) { + + // 1. Validation Pass: Ensure compatibility of all packets and prepare + // a fresh result packet. + combined, err := validatePsbtMerge(psbts) + if err != nil { + return nil, err + } + + // 2. Construction Pass: Merge data into the prepared packet. + // + // Iterate through ALL packets (including the first) and merge their + // contents into the combined packet. + for _, p := range psbts { + // Merge Inputs. + for j := range combined.Inputs { + err := mergePsbtInputs( + &combined.Inputs[j], &p.Inputs[j], + ) + if err != nil { + return nil, fmt.Errorf("input %d merge "+ + "failed: %w", j, err) + } + } + + // Merge Outputs. + for j := range combined.Outputs { + err := mergePsbtOutputs( + &combined.Outputs[j], &p.Outputs[j], + ) + if err != nil { + return nil, fmt.Errorf("output %d merge "+ + "failed: %w", j, err) + } + } + } + + // Post-merge Validation: Ensure the resulting packet is structurally + // sound (e.g. has necessary UTXO info). This acts as a final sanity + // check. + err = psbt.InputsReadyToSign(combined) + if err != nil { + return nil, fmt.Errorf("combined psbt validation failed: %w", + err) + } + + return combined, nil +} + +// validatePsbtMerge checks that a set of PSBT packets are compatible for +// merging and returns a new, empty packet initialized with the transaction +// structure, ready to be populated. +func validatePsbtMerge(psbts []*psbt.Packet) (*psbt.Packet, error) { + if len(psbts) == 0 { + return nil, ErrNoPsbtsToCombine + } + + base := psbts[0] + baseTxHash := base.UnsignedTx.TxHash() + nInputs := len(base.Inputs) + nOutputs := len(base.Outputs) + + for i, p := range psbts[1:] { + if p.UnsignedTx.TxHash() != baseTxHash { + return nil, fmt.Errorf("%w: packet index %d", + ErrDifferentTransactions, i+1) + } + + if len(p.Inputs) != nInputs { + return nil, fmt.Errorf("%w: packet index %d", + ErrInputCountMismatch, i+1) + } + + if len(p.Outputs) != nOutputs { + return nil, fmt.Errorf("%w: packet index %d", + ErrOutputCountMismatch, i+1) + } + } + + // Initialize a fresh packet using a deep copy of the unsigned + // transaction to ensure we don't mutate any of the input packets. + combined := &psbt.Packet{ + UnsignedTx: base.UnsignedTx.Copy(), + Inputs: make([]psbt.PInput, nInputs), + Outputs: make([]psbt.POutput, nOutputs), + Unknowns: base.Unknowns, + } + + return combined, nil +} + +// mergePsbtInputs merges the source input into the destination input. +// +// It returns an error if any immutable fields (Scripts, UTXOs, SighashType) +// conflict between the two inputs. +func mergePsbtInputs(dest, src *psbt.PInput) error { + // Merge PartialSigs (deduplicating by pubkey). + dest.PartialSigs = deduplicatePartialSigs( + dest.PartialSigs, src.PartialSigs, + ) + + var err error + + err = mergeSighashType(dest, src) + if err != nil { + return err + } + + err = mergeInputScripts(dest, src) + if err != nil { + return err + } + + // Merge BIP32 Derivations (deduplicating by pubkey). + dest.Bip32Derivation = deduplicateBip32Derivations( + dest.Bip32Derivation, src.Bip32Derivation, + ) + + // Merge Taproot Derivations (deduplicating by x-only pubkey). + dest.TaprootBip32Derivation = deduplicateTaprootBip32Derivations( + dest.TaprootBip32Derivation, src.TaprootBip32Derivation, + ) + + err = mergeTaprootKeySpendSig(dest, src) + if err != nil { + return err + } + + mergeTaprootScriptSpendSigs(dest, src) + + err = mergeWitnessUtxo(dest, src) + if err != nil { + return err + } + + err = mergeNonWitnessUtxo(dest, src) + if err != nil { + return err + } + + return nil +} + +// mergePsbtOutputs merges the source output into the destination output. +// +// It returns an error if any immutable fields (Taproot Internal Key, Scripts) +// conflict. +func mergePsbtOutputs(dest, src *psbt.POutput) error { + // Merge BIP32 Derivations for outputs. + dest.Bip32Derivation = deduplicateBip32Derivations( + dest.Bip32Derivation, src.Bip32Derivation, + ) + + var err error + + err = mergeTaprootInternalKey(dest, src) + if err != nil { + return err + } + + // Merge Taproot BIP32 Derivations for outputs. + dest.TaprootBip32Derivation = deduplicateTaprootBip32Derivations( + dest.TaprootBip32Derivation, src.TaprootBip32Derivation, + ) + + err = mergeOutputScripts(dest, src) + if err != nil { + return err + } + + return nil +} + +// deduplicatePartialSigs adds new partial signatures from src to dest, +// avoiding duplicates based on pubkey. +func deduplicatePartialSigs(dest, src []*psbt.PartialSig) []*psbt.PartialSig { + for _, sig := range src { + found := false + for _, dSig := range dest { + if bytes.Equal(dSig.PubKey, sig.PubKey) { + found = true + break + } + } + + if !found { + dest = append(dest, sig) + } + } + + return dest +} + +// deduplicateBip32Derivations adds new BIP32 derivations from src to dest, +// avoiding duplicates based on pubkey. +func deduplicateBip32Derivations( + dest, src []*psbt.Bip32Derivation) []*psbt.Bip32Derivation { + + for _, der := range src { + found := false + for _, dDer := range dest { + if bytes.Equal(dDer.PubKey, der.PubKey) { + found = true + break + } + } + + if !found { + dest = append(dest, der) + } + } + + return dest +} + +// deduplicateTaprootBip32Derivations adds new Taproot BIP32 derivations +// from src to dest, avoiding duplicates based on x-only pubkey. +func deduplicateTaprootBip32Derivations(dest, + src []*psbt.TaprootBip32Derivation) []*psbt.TaprootBip32Derivation { + + for _, der := range src { + found := false + for _, dDer := range dest { + if bytes.Equal(dDer.XOnlyPubKey, der.XOnlyPubKey) { + found = true + break + } + } + + if !found { + dest = append(dest, der) + } + } + + return dest +} + +// mergeSighashType merges the SighashType field. Returns error on conflict. +func mergeSighashType(dest, src *psbt.PInput) error { + if dest.SighashType != 0 && src.SighashType != 0 && + dest.SighashType != src.SighashType { + + return fmt.Errorf("%w: %v vs %v", ErrSighashMismatch, + dest.SighashType, src.SighashType) + } + + if dest.SighashType == 0 { + dest.SighashType = src.SighashType + } + + return nil +} + +// mergeInputScripts merges RedeemScript, WitnessScript, FinalScriptSig, and +// FinalScriptWitness for inputs. Returns error on conflict. +func mergeInputScripts(dest, src *psbt.PInput) error { + err := mergeRedeemScript(dest, src) + if err != nil { + return err + } + + err = mergeWitnessScript(dest, src) + if err != nil { + return err + } + + err = mergeFinalScriptSig(dest, src) + if err != nil { + return err + } + + return mergeFinalScriptWitness(dest, src) +} + +// mergeRedeemScript merges the RedeemScript field. +func mergeRedeemScript(dest, src *psbt.PInput) error { + if len(dest.RedeemScript) > 0 && len(src.RedeemScript) > 0 && + !bytes.Equal(dest.RedeemScript, src.RedeemScript) { + + return ErrRedeemScriptMismatch + } + + if len(dest.RedeemScript) == 0 { + dest.RedeemScript = src.RedeemScript + } + + return nil +} + +// mergeWitnessScript merges the WitnessScript field. +func mergeWitnessScript(dest, src *psbt.PInput) error { + if len(dest.WitnessScript) > 0 && len(src.WitnessScript) > 0 && + !bytes.Equal(dest.WitnessScript, src.WitnessScript) { + + return ErrWitnessScriptMismatch + } + + if len(dest.WitnessScript) == 0 { + dest.WitnessScript = src.WitnessScript + } + + return nil +} + +// mergeFinalScriptSig merges the FinalScriptSig field. +func mergeFinalScriptSig(dest, src *psbt.PInput) error { + if len(dest.FinalScriptSig) > 0 && len(src.FinalScriptSig) > 0 && + !bytes.Equal(dest.FinalScriptSig, src.FinalScriptSig) { + + return ErrFinalScriptSigMismatch + } + + if len(dest.FinalScriptSig) == 0 { + dest.FinalScriptSig = src.FinalScriptSig + } + + return nil +} + +// mergeFinalScriptWitness merges the FinalScriptWitness field. +func mergeFinalScriptWitness(dest, src *psbt.PInput) error { + if len(dest.FinalScriptWitness) > 0 && + len(src.FinalScriptWitness) > 0 && + !bytes.Equal(dest.FinalScriptWitness, src.FinalScriptWitness) { + + return ErrFinalScriptWitnessMismatch + } + + if len(dest.FinalScriptWitness) == 0 { + dest.FinalScriptWitness = src.FinalScriptWitness + } + + return nil +} + +// mergeTaprootKeySpendSig merges the Taproot Key Spend Signature. +// Returns error on conflict. +func mergeTaprootKeySpendSig(dest, src *psbt.PInput) error { + if len(dest.TaprootKeySpendSig) > 0 && + len(src.TaprootKeySpendSig) > 0 && + !bytes.Equal(dest.TaprootKeySpendSig, src.TaprootKeySpendSig) { + + return ErrTaprootKeySpendSigMismatch + } + + if len(dest.TaprootKeySpendSig) == 0 { + dest.TaprootKeySpendSig = src.TaprootKeySpendSig + } + + return nil +} + +// mergeTaprootScriptSpendSigs appends Taproot Script Spend Signatures from src +// to dest. +func mergeTaprootScriptSpendSigs(dest, src *psbt.PInput) { + dest.TaprootScriptSpendSig = append( + dest.TaprootScriptSpendSig, src.TaprootScriptSpendSig..., + ) +} + +// mergeWitnessUtxo merges the Witness UTXO field. Returns error on conflict. +func mergeWitnessUtxo(dest, src *psbt.PInput) error { + if dest.WitnessUtxo != nil && src.WitnessUtxo != nil { + if dest.WitnessUtxo.Value != src.WitnessUtxo.Value || + !bytes.Equal(dest.WitnessUtxo.PkScript, + src.WitnessUtxo.PkScript) { + + return ErrWitnessUtxoMismatch + } + } + + if dest.WitnessUtxo == nil { + dest.WitnessUtxo = src.WitnessUtxo + } + + return nil +} + +// mergeNonWitnessUtxo merges the Non-Witness UTXO field. Returns error on +// conflict (by TXID). +func mergeNonWitnessUtxo(dest, src *psbt.PInput) error { + if dest.NonWitnessUtxo != nil && src.NonWitnessUtxo != nil { + if dest.NonWitnessUtxo.TxHash() != src.NonWitnessUtxo.TxHash() { + return ErrNonWitnessUtxoMismatch + } + } + + if dest.NonWitnessUtxo == nil { + dest.NonWitnessUtxo = src.NonWitnessUtxo + } + + return nil +} + +// mergeTaprootInternalKey merges the Taproot Internal Key for outputs. +// Returns error on conflict. +func mergeTaprootInternalKey(dest, src *psbt.POutput) error { + if len(dest.TaprootInternalKey) > 0 && + len(src.TaprootInternalKey) > 0 && + !bytes.Equal(dest.TaprootInternalKey, src.TaprootInternalKey) { + + return ErrTaprootInternalKeyMismatch + } + + if len(dest.TaprootInternalKey) == 0 { + dest.TaprootInternalKey = src.TaprootInternalKey + } + + return nil +} + +// mergeOutputScripts merges RedeemScript and WitnessScript for outputs. +// Returns error on conflict. +func mergeOutputScripts(dest, src *psbt.POutput) error { + if len(dest.RedeemScript) > 0 && len(src.RedeemScript) > 0 && + !bytes.Equal(dest.RedeemScript, src.RedeemScript) { + + return ErrRedeemScriptMismatch + } + + if len(dest.RedeemScript) == 0 { + dest.RedeemScript = src.RedeemScript + } + + if len(dest.WitnessScript) > 0 && len(src.WitnessScript) > 0 && + !bytes.Equal(dest.WitnessScript, src.WitnessScript) { + + return ErrWitnessScriptMismatch + } + + if len(dest.WitnessScript) == 0 { + dest.WitnessScript = src.WitnessScript + } + + return nil +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index a1317f212b..433673df6f 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -3631,3 +3631,748 @@ func TestFinalizePsbtErrors(t *testing.T) { require.ErrorContains(t, err, "error finalizing PSBT") }) } + +// TestValidatePsbtMerge tests the validatePsbtMerge helper function. +func TestValidatePsbtMerge(t *testing.T) { + t.Parallel() + + // Helper to create a dummy packet with specific tx hash and IO counts. + createPacket := func(txHash byte, inCount, outCount int) *psbt.Packet { + tx := wire.NewMsgTx(2) + // Add dummy inputs/outputs to affect count and hash. + for i := range inCount { + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{txHash}, + Index: uint32(i), + }, + }) + } + + for i := range outCount { + tx.AddTxOut(&wire.TxOut{Value: int64(i)}) + } + + p, _ := psbt.NewFromUnsignedTx(tx) + + return p + } + + base := createPacket(1, 1, 1) + + tests := []struct { + name string + psbts []*psbt.Packet + wantErr error + }{ + { + name: "success single", + psbts: []*psbt.Packet{base}, + wantErr: nil, + }, + { + name: "success multiple identical", + psbts: []*psbt.Packet{base, base}, + wantErr: nil, + }, + { + name: "empty list", + psbts: []*psbt.Packet{}, + wantErr: ErrNoPsbtsToCombine, + }, + { + name: "mismatched txid", + psbts: []*psbt.Packet{base, createPacket(2, 1, 1)}, + wantErr: ErrDifferentTransactions, + }, + { + name: "mismatched input count", + psbts: func() []*psbt.Packet { + // Create a packet with same TXID but corrupted + // input count. + p2 := createPacket(1, 1, 1) + p2.Inputs = append(p2.Inputs, psbt.PInput{}) + + return []*psbt.Packet{base, p2} + }(), + wantErr: ErrInputCountMismatch, + }, + { + name: "mismatched output count", + psbts: func() []*psbt.Packet { + // Create a packet with same TXID but corrupted + // output count. + p2 := createPacket(1, 1, 1) + p2.Outputs = append(p2.Outputs, psbt.POutput{}) + + return []*psbt.Packet{base, p2} + }(), + wantErr: ErrOutputCountMismatch, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := validatePsbtMerge(tc.psbts) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, got) + } else { + require.NoError(t, err) + require.NotNil(t, got) + + // Verify it is a different object (copy). + require.NotSame(t, tc.psbts[0], got) + + // Verify structure matches base. + require.Equal(t, + tc.psbts[0].UnsignedTx.TxHash(), + got.UnsignedTx.TxHash(), + ) + require.Len(t, got.Inputs, + len(tc.psbts[0].Inputs)) + require.Len(t, got.Outputs, + len(tc.psbts[0].Outputs)) + } + }) + } +} + +// TestMergePsbtInputs tests that mergePsbtInputs correctly merges and +// deduplicates input fields. +func TestMergePsbtInputs(t *testing.T) { + t.Parallel() + + t.Run("partial sigs deduplication", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a destination input with one signature, and + // a source input with the same signature (duplicate) plus a + // new one. This simulates merging updates from multiple + // signers where some data overlaps. + dest := &psbt.PInput{ + PartialSigs: []*psbt.PartialSig{ + { + PubKey: []byte{1}, + Signature: []byte{10}, + }, + }, + } + src := &psbt.PInput{ + PartialSigs: []*psbt.PartialSig{ + { + PubKey: []byte{1}, + Signature: []byte{10}, + }, // Duplicate + { + PubKey: []byte{2}, + Signature: []byte{20}, + }, // New + }, + } + + // Act: Merge the source input into the destination. + err := mergePsbtInputs(dest, src) + require.NoError(t, err) + + // Assert: Verify that the destination now contains exactly two + // signatures. The first one should be preserved, and the + // second one should be the new signature from the source. + require.Len(t, dest.PartialSigs, 2) + require.Equal(t, []byte{1}, dest.PartialSigs[0].PubKey) + require.Equal(t, []byte{2}, dest.PartialSigs[1].PubKey) + }) + + t.Run("sighash type adoption", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a destination input with the default sighash + // type (0) and a source input with a specific type + // (SigHashSingle). + dest := &psbt.PInput{SighashType: 0} // Default + src := &psbt.PInput{SighashType: txscript.SigHashSingle} + + // Act: Merge the inputs. + err := mergePsbtInputs(dest, src) + + // Assert: Verify that the destination adopted the source's + // sighash type, as 0 is treated as "unset". + require.NoError(t, err) + require.Equal(t, txscript.SigHashSingle, dest.SighashType) + + // Arrange: Create a scenario with conflicting sighash types. + // Destination has SigHashAll, Source has SigHashSingle. + dest.SighashType = txscript.SigHashAll + src.SighashType = txscript.SigHashSingle + + // Act: Attempt to merge conflicting inputs. + err = mergePsbtInputs(dest, src) + + // Assert: Verify that the merge returns an error indicating + // the mismatch. + require.ErrorContains(t, err, "sighash type mismatch") + }) + + t.Run("scripts merging", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a destination input missing script info, and + // a source input containing it. + dest := &psbt.PInput{} + src := &psbt.PInput{ + RedeemScript: []byte{1, 2, 3}, + WitnessScript: []byte{4, 5, 6}, + } + + // Act: Merge the inputs. + err := mergePsbtInputs(dest, src) + + // Assert: Verify that the scripts were successfully copied to + // the destination. + require.NoError(t, err) + require.Equal(t, src.RedeemScript, dest.RedeemScript) + require.Equal(t, src.WitnessScript, dest.WitnessScript) + }) +} + +// TestMergePsbtOutputs tests that mergePsbtOutputs correctly merges and +// deduplicates output fields. +func TestMergePsbtOutputs(t *testing.T) { + t.Parallel() + + t.Run("bip32 derivation deduplication", func(t *testing.T) { + t.Parallel() + + // Arrange: Create destination and source outputs with + // overlapping BIP32 derivation paths. + dest := &psbt.POutput{ + Bip32Derivation: []*psbt.Bip32Derivation{ + { + PubKey: []byte{1}, + MasterKeyFingerprint: 10, + }, + }, + } + src := &psbt.POutput{ + Bip32Derivation: []*psbt.Bip32Derivation{ + { + PubKey: []byte{1}, + MasterKeyFingerprint: 10, + }, // Duplicate + { + PubKey: []byte{2}, + MasterKeyFingerprint: 20, + }, // New + }, + } + + // Act: Merge the outputs. + err := mergePsbtOutputs(dest, src) + require.NoError(t, err) + + // Assert: Verify that the destination now contains both unique + // derivations. + require.Len(t, dest.Bip32Derivation, 2) + require.Equal(t, []byte{1}, dest.Bip32Derivation[0].PubKey) + require.Equal(t, []byte{2}, dest.Bip32Derivation[1].PubKey) + }) + + t.Run("taproot internal key adoption", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a destination output missing the Taproot + // internal key, and a source output that has it. + dest := &psbt.POutput{} + src := &psbt.POutput{ + TaprootInternalKey: []byte{1, 2, 3}, + } + + // Act: Merge the outputs. + err := mergePsbtOutputs(dest, src) + + require.NoError(t, err) + require.Equal(t, src.TaprootInternalKey, + dest.TaprootInternalKey) + }) +} + +// TestMergeSighashType tests the mergeSighashType helper function. +// +// It verifies two key behaviors: +// 1. Conflict Detection: It ensures that an error is returned if the +// destination and source inputs have different, non-zero sighash types. +// 2. Adoption: It ensures that if the destination has a default (0) sighash +// type, it correctly adopts the type from the source. +func TestMergeSighashType(t *testing.T) { + t.Parallel() + + t.Run("detect mismatch", func(t *testing.T) { + t.Parallel() + + // Arrange: Construct a 'destination' PSBT input that has + // already declared a sighash type of SigHashAll. + dest := &psbt.PInput{SighashType: txscript.SigHashAll} + + // Arrange: Construct a 'source' PSBT input that declares a + // different, conflicting sighash type of SigHashSingle. + src := &psbt.PInput{SighashType: txscript.SigHashSingle} + + // Act: Attempt to merge the source into the destination using + // the mergeSighashType helper. + err := mergeSighashType(dest, src) + + // Assert: Verify that the function identified the conflict and + // returned the expected ErrSighashMismatch error. + require.ErrorIs(t, err, ErrSighashMismatch) + }) + + t.Run("adopt source type", func(t *testing.T) { + t.Parallel() + + // Arrange: Construct a 'destination' PSBT input with the + // default (zero) sighash type, indicating it hasn't been set + // yet. + dest := &psbt.PInput{SighashType: 0} + + // Arrange: Construct a 'source' PSBT input with a specific + // sighash type (SigHashSingle) that should be propagated. + src := &psbt.PInput{SighashType: txscript.SigHashSingle} + + // Act: Merge the source into the destination. + err := mergeSighashType(dest, src) + + // Assert: Verify that the operation was successful (no error) + // and that the destination input has been updated to match the + // source's sighash type. + require.NoError(t, err) + require.Equal(t, txscript.SigHashSingle, dest.SighashType) + }) +} + +// TestMergeRedeemScript tests the mergeRedeemScript helper function. +// +// It verifies that: +// 1. Conflicting redeem scripts cause an error. +// 2. A missing redeem script in the destination is populated from the source. +func TestMergeRedeemScript(t *testing.T) { + t.Parallel() + + t.Run("detect mismatch", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a 'destination' input with a specific redeem + // script (byte sequence {1}). + dest := &psbt.PInput{RedeemScript: []byte{1}} + + // Arrange: Create a 'source' input with a different redeem + // script (byte sequence {2}). + src := &psbt.PInput{RedeemScript: []byte{2}} + + // Act: Attempt to merge the source into the destination. + err := mergeRedeemScript(dest, src) + + // Assert: Verify that the function returns + // ErrRedeemScriptMismatch, preventing the corruption of the + // redeem script. + require.ErrorIs(t, err, ErrRedeemScriptMismatch) + }) + + t.Run("adopt source script", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a 'destination' input with no redeem script + // (nil or empty). + dest := &psbt.PInput{} + + // Arrange: Create a 'source' input that contains a valid + // redeem script. + src := &psbt.PInput{RedeemScript: []byte{1, 2, 3}} + + // Act: Merge the source into the destination. + err := mergeRedeemScript(dest, src) + + // Assert: Verify that the merge succeeded and the destination + // now contains the redeem script from the source. + require.NoError(t, err) + require.Equal(t, src.RedeemScript, dest.RedeemScript) + }) +} + +// TestMergeWitnessUtxo tests the mergeWitnessUtxo helper function. +// +// It verifies that: +// 1. Conflicting Witness UTXO values (amount or script) trigger an error. +// 2. A missing Witness UTXO in the destination is correctly copied from the +// source. +func TestMergeWitnessUtxo(t *testing.T) { + t.Parallel() + + t.Run("detect value mismatch", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a 'destination' input with a Witness UTXO + // valued at 1000 sats. + dest := &psbt.PInput{WitnessUtxo: &wire.TxOut{Value: 1000}} + + // Arrange: Create a 'source' input with a Witness UTXO valued + // at 2000 sats (conflicting). + src := &psbt.PInput{WitnessUtxo: &wire.TxOut{Value: 2000}} + + // Act: Attempt to merge the inputs. + err := mergeWitnessUtxo(dest, src) + + // Assert: Verify that the function returns + // ErrWitnessUtxoMismatch. + require.ErrorIs(t, err, ErrWitnessUtxoMismatch) + }) + + t.Run("detect script mismatch", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a 'destination' input with a Witness UTXO + // script {1}. + dest := &psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: 1000, PkScript: []byte{1}, + }, + } + + // Arrange: Create a 'source' input with the same value but a + // different script {2}. + src := &psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: 1000, PkScript: []byte{2}, + }, + } + + // Act: Attempt to merge the inputs. + err := mergeWitnessUtxo(dest, src) + + // Assert: Verify that the function returns + // ErrWitnessUtxoMismatch due to the script difference. + require.ErrorIs(t, err, ErrWitnessUtxoMismatch) + }) + + t.Run("adopt source utxo", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a 'destination' input with no Witness UTXO + // info. + dest := &psbt.PInput{} + + // Arrange: Create a 'source' input with a full Witness UTXO. + src := &psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: 1000, PkScript: []byte{1}, + }, + } + + // Act: Merge the source into the destination. + err := mergeWitnessUtxo(dest, src) + + // Assert: Verify that the destination structure now holds the + // exact Witness UTXO pointer/value from the source. + require.NoError(t, err) + require.Equal(t, src.WitnessUtxo, dest.WitnessUtxo) + }) +} + +// TestMergeNonWitnessUtxo tests the mergeNonWitnessUtxo helper function. +// +// It ensures that full transaction data (for legacy/SegWit v0 inputs) is +// merged safely, rejecting conflicts where the transaction hash differs. +func TestMergeNonWitnessUtxo(t *testing.T) { + t.Parallel() + + t.Run("detect mismatch", func(t *testing.T) { + t.Parallel() + + // Arrange: Create two distinct wire transactions to serve as + // conflicting NonWitnessUtxo data. + tx1 := wire.NewMsgTx(1) + tx2 := wire.NewMsgTx(2) + + // Arrange: Assign tx1 to destination and tx2 to source. + dest := &psbt.PInput{NonWitnessUtxo: tx1} + src := &psbt.PInput{NonWitnessUtxo: tx2} + + // Act: Attempt to merge. + err := mergeNonWitnessUtxo(dest, src) + + // Assert: Verify that ErrNonWitnessUtxoMismatch is returned + // because the transactions differ. + require.ErrorIs(t, err, ErrNonWitnessUtxoMismatch) + }) + + t.Run("adopt source utxo", func(t *testing.T) { + t.Parallel() + + // Arrange: Create a 'destination' input with no + // NonWitnessUtxo. + dest := &psbt.PInput{} + + // Arrange: Create a 'source' input with a valid NonWitnessUtxo + // transaction. + tx := wire.NewMsgTx(1) + src := &psbt.PInput{NonWitnessUtxo: tx} + + // Act: Merge the inputs. + err := mergeNonWitnessUtxo(dest, src) + + // Assert: Verify that the destination adopted the + // NonWitnessUtxo from the source. + require.NoError(t, err) + require.Equal(t, src.NonWitnessUtxo, dest.NonWitnessUtxo) + }) +} + +// TestMergeTaprootInternalKeyMismatch verifies that conflicting Taproot +// internal keys are detected. +func TestMergeTaprootInternalKeyMismatch(t *testing.T) { + t.Parallel() + + // Arrange: Setup conflicting Taproot internal keys (byte {1} vs + // byte {2}). + dest := &psbt.POutput{TaprootInternalKey: []byte{1}} + src := &psbt.POutput{TaprootInternalKey: []byte{2}} + + // Act: Attempt to merge the outputs. + err := mergeTaprootInternalKey(dest, src) + + // Assert: Verify that ErrTaprootInternalKeyMismatch is returned. + require.ErrorIs(t, err, ErrTaprootInternalKeyMismatch) +} + +// TestMergeTaprootInternalKeyAdoption verifies that a source key is adopted +// if the destination key is missing. +func TestMergeTaprootInternalKeyAdoption(t *testing.T) { + t.Parallel() + + // Arrange: Create a destination output with no internal key. + dest := &psbt.POutput{} + + // Arrange: Create a source output with a valid internal key. + src := &psbt.POutput{TaprootInternalKey: []byte{1, 2, 3}} + + // Act: Merge the outputs. + err := mergeTaprootInternalKey(dest, src) + + // Assert: Verify that the internal key was successfully copied to + // the destination. + require.NoError(t, err) + require.Equal(t, src.TaprootInternalKey, dest.TaprootInternalKey) +} + +// TestDeduplicateTaprootBip32Derivations tests the deduplication logic for +// Taproot BIP32 derivations. +func TestDeduplicateTaprootBip32Derivations(t *testing.T) { + t.Parallel() + + t.Run("deduplicate", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup destination with one derivation. + dest := []*psbt.TaprootBip32Derivation{ + {XOnlyPubKey: []byte{1}}, + } + + // Arrange: Setup source with duplicate and new derivation. + src := []*psbt.TaprootBip32Derivation{ + {XOnlyPubKey: []byte{1}}, // Duplicate + {XOnlyPubKey: []byte{2}}, // New + } + + // Act. + got := deduplicateTaprootBip32Derivations(dest, src) + + // Assert: Verify deduplication. + require.Len(t, got, 2) + require.Equal(t, []byte{1}, got[0].XOnlyPubKey) + require.Equal(t, []byte{2}, got[1].XOnlyPubKey) + }) +} + +// TestMergeInputScripts tests the aggregate mergeInputScripts function to +// ensure it propagates errors from all sub-steps. +func TestMergeInputScripts(t *testing.T) { + t.Parallel() + + t.Run("fail on redeem script", func(t *testing.T) { + t.Parallel() + + dest := &psbt.PInput{RedeemScript: []byte{1}} + src := &psbt.PInput{RedeemScript: []byte{2}} + err := mergeInputScripts(dest, src) + require.ErrorIs(t, err, ErrRedeemScriptMismatch) + }) + + t.Run("fail on witness script", func(t *testing.T) { + t.Parallel() + + dest := &psbt.PInput{WitnessScript: []byte{1}} + src := &psbt.PInput{WitnessScript: []byte{2}} + err := mergeInputScripts(dest, src) + require.ErrorIs(t, err, ErrWitnessScriptMismatch) + }) + + t.Run("fail on final script sig", func(t *testing.T) { + t.Parallel() + + dest := &psbt.PInput{FinalScriptSig: []byte{1}} + src := &psbt.PInput{FinalScriptSig: []byte{2}} + err := mergeInputScripts(dest, src) + require.ErrorIs(t, err, ErrFinalScriptSigMismatch) + }) + + t.Run("fail on final script witness", func(t *testing.T) { + t.Parallel() + + dest := &psbt.PInput{FinalScriptWitness: []byte{1}} + src := &psbt.PInput{FinalScriptWitness: []byte{2}} + err := mergeInputScripts(dest, src) + require.ErrorIs(t, err, ErrFinalScriptWitnessMismatch) + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + + dest := &psbt.PInput{} + src := &psbt.PInput{ + RedeemScript: []byte{1}, + WitnessScript: []byte{2}, + FinalScriptSig: []byte{3}, + FinalScriptWitness: []byte{4}, + } + err := mergeInputScripts(dest, src) + require.NoError(t, err) + require.Equal(t, src.RedeemScript, dest.RedeemScript) + require.Equal(t, src.WitnessScript, dest.WitnessScript) + require.Equal(t, src.FinalScriptSig, dest.FinalScriptSig) + require.Equal(t, src.FinalScriptWitness, + dest.FinalScriptWitness) + }) +} + +// TestMergeOutputScripts tests the aggregate mergeOutputScripts function. +func TestMergeOutputScripts(t *testing.T) { + t.Parallel() + + t.Run("fail on redeem script", func(t *testing.T) { + t.Parallel() + + dest := &psbt.POutput{RedeemScript: []byte{1}} + src := &psbt.POutput{RedeemScript: []byte{2}} + err := mergeOutputScripts(dest, src) + require.ErrorIs(t, err, ErrRedeemScriptMismatch) + }) + + t.Run("fail on witness script", func(t *testing.T) { + t.Parallel() + + dest := &psbt.POutput{WitnessScript: []byte{1}} + src := &psbt.POutput{WitnessScript: []byte{2}} + err := mergeOutputScripts(dest, src) + require.ErrorIs(t, err, ErrWitnessScriptMismatch) + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + + dest := &psbt.POutput{} + src := &psbt.POutput{ + RedeemScript: []byte{1}, + WitnessScript: []byte{2}, + } + err := mergeOutputScripts(dest, src) + require.NoError(t, err) + require.Equal(t, src.RedeemScript, dest.RedeemScript) + require.Equal(t, src.WitnessScript, dest.WitnessScript) + }) +} + +// TestCombinePsbt tests that CombinePsbt correctly merges multiple PSBTs. +func TestCombinePsbt(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + w, _ := testWalletWithMocks(t) + + // Arrange: Create a base transaction with 1 input and 1 output. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1000}) // Add output + + // Arrange: Create two PSBT packets from this transaction. + packet1, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + packet2, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + // Arrange: Add UTXO info to satisfy structural validation + // checks. + dummyUtxo := &wire.TxOut{Value: 1000, PkScript: []byte{0x00}} + packet1.Inputs[0].WitnessUtxo = dummyUtxo + packet2.Inputs[0].WitnessUtxo = dummyUtxo + + // Arrange: Add a unique partial signature to the second + // packet. + packet2.Inputs[0].PartialSigs = []*psbt.PartialSig{{ + PubKey: []byte{1}, Signature: []byte{1}, + }} + + // Act: Combine the two packets. + combined, err := w.CombinePsbt(t.Context(), packet1, packet2) + + // Assert: Verify the merge was successful and the resulting + // packet contains the signature from packet2. + require.NoError(t, err) + require.Len(t, combined.Inputs[0].PartialSigs, 1) + require.Equal(t, []byte{1}, + combined.Inputs[0].PartialSigs[0].PubKey) + }) + + t.Run("empty inputs", func(t *testing.T) { + t.Parallel() + w, _ := testWalletWithMocks(t) + + // Act: Attempt to combine with no packets. + _, err := w.CombinePsbt(t.Context()) + + // Assert: Verify it returns the expected error. + require.ErrorIs(t, err, ErrNoPsbtsToCombine) + }) + + t.Run("mismatch tx", func(t *testing.T) { + t.Parallel() + w, _ := testWalletWithMocks(t) + + // Arrange: Create two packets with DIFFERENT transactions. + tx1 := wire.NewMsgTx(2) + tx1.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + }, + }) + packet1, err := psbt.NewFromUnsignedTx(tx1) + require.NoError(t, err) + + tx2 := wire.NewMsgTx(2) + tx2.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + }, + }) + packet2, err := psbt.NewFromUnsignedTx(tx2) + require.NoError(t, err) + + // Act: Attempt to combine conflicting packets. + _, err = w.CombinePsbt(t.Context(), packet1, packet2) + + // Assert: Verify it returns the specific mismatch error. + require.ErrorIs(t, err, ErrDifferentTransactions) + }) +} From 201148e74afbca922336eaf1e0d5a622517e9a1b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 26 Nov 2025 20:19:01 +0800 Subject: [PATCH 202/691] wallet: remove file `psbt.go` We now move all helper methods into `psbt_manager.go`. --- wallet/psbt.go | 153 ----------------------------------------- wallet/psbt_manager.go | 137 ++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 153 deletions(-) delete mode 100644 wallet/psbt.go diff --git a/wallet/psbt.go b/wallet/psbt.go deleted file mode 100644 index 1782058ab9..0000000000 --- a/wallet/psbt.go +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2020 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "fmt" - - "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" -) - -// addInputInfoSegWitV0 adds the UTXO and BIP32 derivation info for a -// SegWit v0 PSBT input (p2wkh, np2wkh) from the given wallet -// information. -func addInputInfoSegWitV0(in *psbt.PInput, prevTx *wire.MsgTx, utxo *wire.TxOut, - derivationInfo *psbt.Bip32Derivation, addr waddrmgr.ManagedAddress, - witnessProgram []byte) { - - // As a fix for CVE-2020-14199 we have to always include the full - // non-witness UTXO in the PSBT for segwit v0. - in.NonWitnessUtxo = prevTx - - // To make it more obvious that this is actually a witness output being - // spent, we also add the same information as the witness UTXO. - in.WitnessUtxo = &wire.TxOut{ - Value: utxo.Value, - PkScript: utxo.PkScript, - } - in.SighashType = txscript.SigHashAll - - // Include the derivation path for each input. - in.Bip32Derivation = []*psbt.Bip32Derivation{ - derivationInfo, - } - - // For nested P2WKH we need to add the redeem script to the input, - // otherwise an offline wallet won't be able to sign for it. For normal - // P2WKH this will be nil. - if addr.AddrType() == waddrmgr.NestedWitnessPubKey { - in.RedeemScript = witnessProgram - } -} - -// addInputInfoSegWitV0 adds the UTXO and BIP32 derivation info for a SegWit v1 -// PSBT input (p2tr) from the given wallet information. -func addInputInfoSegWitV1(in *psbt.PInput, utxo *wire.TxOut, - derivationInfo *psbt.Bip32Derivation) { - - // For SegWit v1 we only need the witness UTXO information. - in.WitnessUtxo = &wire.TxOut{ - Value: utxo.Value, - PkScript: utxo.PkScript, - } - in.SighashType = txscript.SigHashDefault - - // Include the derivation path for each input in addition to the - // taproot specific info we have below. - in.Bip32Derivation = []*psbt.Bip32Derivation{ - derivationInfo, - } - - // Include the derivation path for each input. - in.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{{ - XOnlyPubKey: derivationInfo.PubKey[1:], - MasterKeyFingerprint: derivationInfo.MasterKeyFingerprint, - Bip32Path: derivationInfo.Bip32Path, - }} -} - -// createOutputInfo creates the BIP32 derivation info for an output from our -// internal wallet. -func createOutputInfo(txOut *wire.TxOut, - addr waddrmgr.ManagedPubKeyAddress) (*psbt.POutput, error) { - - // We don't know the derivation path for imported keys. Those shouldn't - // be selected as change outputs in the first place, but just to make - // sure we don't run into an issue, we return early for imported keys. - keyScope, derivationPath, isKnown := addr.DerivationInfo() - if !isKnown { - return nil, fmt.Errorf("error adding output info to PSBT, " + - "change addr is an imported addr with unknown " + - "derivation path") - } - - // Include the derivation path for this output. - derivation := &psbt.Bip32Derivation{ - PubKey: addr.PubKey().SerializeCompressed(), - MasterKeyFingerprint: derivationPath.MasterKeyFingerprint, - Bip32Path: []uint32{ - keyScope.Purpose + hdkeychain.HardenedKeyStart, - keyScope.Coin + hdkeychain.HardenedKeyStart, - derivationPath.Account, - derivationPath.Branch, - derivationPath.Index, - }, - } - out := &psbt.POutput{ - Bip32Derivation: []*psbt.Bip32Derivation{ - derivation, - }, - } - - // Include the Taproot derivation path as well if this is a P2TR output. - if txscript.IsPayToTaproot(txOut.PkScript) { - schnorrPubKey := derivation.PubKey[1:] - out.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{{ - XOnlyPubKey: schnorrPubKey, - MasterKeyFingerprint: derivation.MasterKeyFingerprint, - Bip32Path: derivation.Bip32Path, - }} - out.TaprootInternalKey = schnorrPubKey - } - - return out, nil -} - -// PsbtPrevOutputFetcher returns a txscript.PrevOutFetcher built from the UTXO -// information in a PSBT packet. -func PsbtPrevOutputFetcher(packet *psbt.Packet) *txscript.MultiPrevOutFetcher { - fetcher := txscript.NewMultiPrevOutFetcher(nil) - for idx, txIn := range packet.UnsignedTx.TxIn { - in := packet.Inputs[idx] - - // Skip any input that has no UTXO. - if in.WitnessUtxo == nil && in.NonWitnessUtxo == nil { - continue - } - - if in.NonWitnessUtxo != nil { - prevIndex := txIn.PreviousOutPoint.Index - fetcher.AddPrevOut( - txIn.PreviousOutPoint, - in.NonWitnessUtxo.TxOut[prevIndex], - ) - - continue - } - - // Fall back to witness UTXO only for older wallets. - if in.WitnessUtxo != nil { - fetcher.AddPrevOut( - txIn.PreviousOutPoint, in.WitnessUtxo, - ) - } - } - - return fetcher -} diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index e7f8ab7e0a..c3e9cc68da 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -2193,3 +2193,140 @@ func mergeOutputScripts(dest, src *psbt.POutput) error { return nil } + +// addInputInfoSegWitV0 adds the UTXO and BIP32 derivation info for a +// SegWit v0 PSBT input (p2wkh, np2wkh) from the given wallet +// information. +func addInputInfoSegWitV0(in *psbt.PInput, prevTx *wire.MsgTx, utxo *wire.TxOut, + derivationInfo *psbt.Bip32Derivation, addr waddrmgr.ManagedAddress, + witnessProgram []byte) { + + // As a fix for CVE-2020-14199 we have to always include the full + // non-witness UTXO in the PSBT for segwit v0. + in.NonWitnessUtxo = prevTx + + // To make it more obvious that this is actually a witness output being + // spent, we also add the same information as the witness UTXO. + in.WitnessUtxo = &wire.TxOut{ + Value: utxo.Value, + PkScript: utxo.PkScript, + } + in.SighashType = txscript.SigHashAll + + // Include the derivation path for each input. + in.Bip32Derivation = []*psbt.Bip32Derivation{ + derivationInfo, + } + + // For nested P2WKH we need to add the redeem script to the input, + // otherwise an offline wallet won't be able to sign for it. For normal + // P2WKH this will be nil. + if addr.AddrType() == waddrmgr.NestedWitnessPubKey { + in.RedeemScript = witnessProgram + } +} + +// addInputInfoSegWitV1 adds the UTXO and BIP32 derivation info for a SegWit v1 +// PSBT input (p2tr) from the given wallet information. +func addInputInfoSegWitV1(in *psbt.PInput, utxo *wire.TxOut, + derivationInfo *psbt.Bip32Derivation) { + + // For SegWit v1 we only need the witness UTXO information. + in.WitnessUtxo = &wire.TxOut{ + Value: utxo.Value, + PkScript: utxo.PkScript, + } + in.SighashType = txscript.SigHashDefault + + // Include the derivation path for each input in addition to the + // taproot specific info we have below. + in.Bip32Derivation = []*psbt.Bip32Derivation{ + derivationInfo, + } + + // Include the derivation path for each input. + in.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{{ + XOnlyPubKey: derivationInfo.PubKey[1:], + MasterKeyFingerprint: derivationInfo.MasterKeyFingerprint, + Bip32Path: derivationInfo.Bip32Path, + }} +} + +// createOutputInfo creates the BIP32 derivation info for an output from our +// internal wallet. +func createOutputInfo(txOut *wire.TxOut, + addr waddrmgr.ManagedPubKeyAddress) (*psbt.POutput, error) { + + // We don't know the derivation path for imported keys. Those shouldn't + // be selected as change outputs in the first place, but just to make + // sure we don't run into an issue, we return early for imported keys. + keyScope, derivationPath, isKnown := addr.DerivationInfo() + if !isKnown { + return nil, fmt.Errorf("error adding output info to PSBT: %w", + ErrImportedAddrNoDerivation) + } + + // Include the derivation path for this output. + derivation := &psbt.Bip32Derivation{ + PubKey: addr.PubKey().SerializeCompressed(), + MasterKeyFingerprint: derivationPath.MasterKeyFingerprint, + Bip32Path: []uint32{ + keyScope.Purpose + hdkeychain.HardenedKeyStart, + keyScope.Coin + hdkeychain.HardenedKeyStart, + derivationPath.Account, + derivationPath.Branch, + derivationPath.Index, + }, + } + out := &psbt.POutput{ + Bip32Derivation: []*psbt.Bip32Derivation{ + derivation, + }, + } + + // Include the Taproot derivation path as well if this is a P2TR output. + if txscript.IsPayToTaproot(txOut.PkScript) { + schnorrPubKey := derivation.PubKey[1:] + out.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{{ + XOnlyPubKey: schnorrPubKey, + MasterKeyFingerprint: derivation.MasterKeyFingerprint, + Bip32Path: derivation.Bip32Path, + }} + out.TaprootInternalKey = schnorrPubKey + } + + return out, nil +} + +// PsbtPrevOutputFetcher returns a txscript.PrevOutFetcher built from the UTXO +// information in a PSBT packet. +func PsbtPrevOutputFetcher(packet *psbt.Packet) *txscript.MultiPrevOutFetcher { + fetcher := txscript.NewMultiPrevOutFetcher(nil) + for idx, txIn := range packet.UnsignedTx.TxIn { + in := packet.Inputs[idx] + + // Skip any input that has no UTXO. + if in.WitnessUtxo == nil && in.NonWitnessUtxo == nil { + continue + } + + if in.NonWitnessUtxo != nil { + prevIndex := txIn.PreviousOutPoint.Index + fetcher.AddPrevOut( + txIn.PreviousOutPoint, + in.NonWitnessUtxo.TxOut[prevIndex], + ) + + continue + } + + // Fall back to witness UTXO only for older wallets. + if in.WitnessUtxo != nil { + fetcher.AddPrevOut( + txIn.PreviousOutPoint, in.WitnessUtxo, + ) + } + } + + return fetcher +} From 5eefce340be46e6d9b2018197ccf712cc03b3600 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 26 Nov 2025 20:19:59 +0800 Subject: [PATCH 203/691] wallet: patch unit tests for existing helper methods Add dedicated unit tests for these old methods. --- wallet/psbt_manager_test.go | 86 +++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 433673df6f..1c90d61600 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -3898,6 +3898,92 @@ func TestMergePsbtOutputs(t *testing.T) { }) } +// TestAddInputInfoSegWitV0 tests the legacy helper for adding SegWit v0 input +// info. +func TestAddInputInfoSegWitV0(t *testing.T) { + t.Parallel() + + // Arrange: Setup input parameters (prevTx, utxo, derivation). + in := &psbt.PInput{} + prevTx := wire.NewMsgTx(1) + utxo := &wire.TxOut{Value: 1000, PkScript: []byte{1}} + derivation := &psbt.Bip32Derivation{PubKey: []byte{2}} + witnessProgram := []byte{3} + + // Mock address type. + mockAddr := &mockManagedAddress{} + mockAddr.On("AddrType").Return(waddrmgr.NestedWitnessPubKey) + + // Act: Call the helper. + addInputInfoSegWitV0(in, prevTx, utxo, derivation, mockAddr, + witnessProgram) + + // Assert: Verify fields are populated correctly. + require.Equal(t, prevTx, in.NonWitnessUtxo) + require.Equal(t, utxo.Value, in.WitnessUtxo.Value) + require.Equal(t, utxo.PkScript, in.WitnessUtxo.PkScript) + require.Equal(t, txscript.SigHashAll, in.SighashType) + require.Equal(t, derivation, in.Bip32Derivation[0]) + require.Equal(t, witnessProgram, in.RedeemScript) +} + +// TestAddInputInfoSegWitV1 tests the legacy helper for adding SegWit v1 input +// info. +func TestAddInputInfoSegWitV1(t *testing.T) { + t.Parallel() + + // Arrange: Setup input parameters. + in := &psbt.PInput{} + utxo := &wire.TxOut{Value: 1000, PkScript: []byte{1}} + // PubKey must be valid length for slicing [1:]. + pubKey := make([]byte, 33) + pubKey[0] = 0x02 + derivation := &psbt.Bip32Derivation{PubKey: pubKey} + + // Act: Call the helper. + addInputInfoSegWitV1(in, utxo, derivation) + + // Assert: Verify fields are populated correctly. + require.Equal(t, utxo.Value, in.WitnessUtxo.Value) + require.Equal(t, txscript.SigHashDefault, in.SighashType) + require.Equal(t, derivation, in.Bip32Derivation[0]) + require.Equal(t, pubKey[1:], in.TaprootBip32Derivation[0].XOnlyPubKey) +} + +// TestPsbtPrevOutputFetcher tests that the prev output fetcher correctly +// retrieves UTXOs from the PSBT packet. +func TestPsbtPrevOutputFetcher(t *testing.T) { + t.Parallel() + + // Arrange: Create a PSBT packet with multiple inputs. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: 0}}) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: 1}}) + + packet, _ := psbt.NewFromUnsignedTx(tx) + + // Input 0: NonWitnessUtxo. + prevTx := wire.NewMsgTx(1) + prevTx.AddTxOut(&wire.TxOut{Value: 1000}) + packet.Inputs[0].NonWitnessUtxo = prevTx + + // Input 1: WitnessUtxo. + packet.Inputs[1].WitnessUtxo = &wire.TxOut{Value: 2000} + + // Act: Create the fetcher. + fetcher := PsbtPrevOutputFetcher(packet) + + // Assert: Check input 0 (NonWitness). + out0 := fetcher.FetchPrevOutput(wire.OutPoint{Index: 0}) + require.NotNil(t, out0) + require.Equal(t, int64(1000), out0.Value) + + // Assert: Check input 1 (Witness). + out1 := fetcher.FetchPrevOutput(wire.OutPoint{Index: 1}) + require.NotNil(t, out1) + require.Equal(t, int64(2000), out1.Value) +} + // TestMergeSighashType tests the mergeSighashType helper function. // // It verifies two key behaviors: From 006da2c6d484a9d24db207ece6143d0f93d6ea88 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 26 Nov 2025 20:30:04 +0800 Subject: [PATCH 204/691] wallet: add PSBT workflow docs --- docs/developer/README.md | 10 +- docs/developer/psbt_workflows.md | 328 +++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 docs/developer/psbt_workflows.md diff --git a/docs/developer/README.md b/docs/developer/README.md index 1c3dd1f863..38ed43f839 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -40,4 +40,12 @@ A deep dive into the core design philosophy, architectural patterns, and Go impl Formal documentation of significant architectural decisions, their context, and consequences. -**[➡️ View Architecture Decision Records](./adr/README.md)** \ No newline at end of file +**[➡️ View Architecture Decision Records](./adr/README.md)** + +--- + +## 📜 PSBT Workflows Guide + +A detailed guide to creating Bitcoin transactions using the `PsbtManager` interface, covering various scenarios and best practices. + +**[➡️ Read the PSBT Workflows Guide](./psbt_workflows.md)** \ No newline at end of file diff --git a/docs/developer/psbt_workflows.md b/docs/developer/psbt_workflows.md new file mode 100644 index 0000000000..b77d635017 --- /dev/null +++ b/docs/developer/psbt_workflows.md @@ -0,0 +1,328 @@ +# PSBT Workflows Guide + +This document provides a guide to creating Bitcoin transactions using the +`PsbtManager` interface. We will explore several scenarios, from a simple +single-person payment to a more complex, multi-party collaborative transaction, +highlighting best practices for security and efficiency. + +Our actors: +- **Alice**: A user of `btcwallet`. +- **Bob**: Another user of `btcwallet`. +- **Carol**: The recipient of the payments. + +--- + +## Scenario 1: Simple Single-Signer Transaction (Alice Pays Carol) + +This is the most common use case: a single user creating a transaction from their +own wallet. The workflow is linear and straightforward. + +**Goal:** Alice wants to pay 1 BTC to Carol. + +```mermaid +flowchart LR + Start([Start]) --> Create["Create Bare PSBT"] + Create --> Fund["Fund PSBT
(Coin Selection)"] + Fund --> Sign["Sign PSBT"] + Sign --> Finalize["Finalize PSBT"] + Finalize --> Broadcast["Broadcast TX"] + Broadcast --> End([Done]) +``` + +### Workflow Steps + +1. **Create a Bare PSBT:** Alice's application first creates a bare PSBT that + describes the intended output. + + ```go + import "github.com/btcsuite/btcwallet/wallet" + + carolOutput := &wire.TxOut{Value: 100_000_000, PkScript: carolPkScript} + packet, err := wallet.CreatePsbt(nil, []*wire.TxOut{carolOutput}) + ``` + +2. **Fund the PSBT:** Alice's wallet performs coin selection to add inputs and a + change output. + + ```go + fundIntent := &wallet.FundIntent{ + Packet: packet, + Policy: &wallet.InputsPolicy{ + Source: &wallet.ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0086, + }, + MinConfs: 1, + }, + FeeRate: btcunit.NewSatPerKVByte(1000), // e.g., 1 sat/vb + } + + fundedPacket, _, err := aliceWallet.FundPsbt(ctx, fundIntent) + + ``` + + The `fundedPacket` now contains the necessary inputs (fully decorated) and a change output. + +3. **Sign the PSBT:** The wallet signs all inputs it has the keys for. + + ```go + signParams := &wallet.SignPsbtParams{Packet: packet} + _, err = aliceWallet.SignPsbt(ctx, signParams) + ``` + +4. **Finalize and Broadcast:** Alice finalizes the PSBT to produce a complete, + valid transaction and broadcasts it. + + ```go + err = aliceWallet.FinalizePsbt(ctx, packet) + finalTx, err := psbt.Extract(packet) + err = aliceWallet.Broadcast(ctx, finalTx, "Payment to Carol") + ``` + +### Analysis + +- **Round Trips:** 0 (all operations are local to Alice's wallet). +- **Security:** High. Alice controls the entire process, so there is no risk + of external manipulation. + +--- + +## Scenario 2: Collaborative Transaction (Alice and Bob Pay Carol) + +This is a more advanced workflow where multiple parties contribute inputs to a +single transaction. This requires careful coordination to ensure security. + +**Goal:** Alice and Bob want to jointly pay Carol. + +We will explore two models for this: a naive (and insecure) model, and the +recommended, secure Coordinator Model. + +### The Naive (and Insecure) Independent Funding Model - **DO NOT USE** + +In this model, participants create their contributions independently and a +coordinator merges them. + +1. **Alice Funds:** Alice creates a PSBT that pays Carol her portion. + `aliceWallet.FundPsbt(...)` -> `packet_alice` +2. **Bob Funds:** Bob does the same. `bobWallet.FundPsbt(...)` -> `packet_bob` +3. **Coordinator Combines:** A coordinator merges these. + `combinedPacket, _ := wallet.CombinePsbt(ctx, packet_alice, packet_bob)` +4. **Signing:** The `combinedPacket` is passed around for signatures. + +#### Security Concerns: Critical Flaw + +This model is **dangerously insecure** in a trustless environment. + +Imagine a malicious Bob. When creating `packet_bob`, he could add an extra, +unexpected output that pays some of the transaction's value to himself. + +When the coordinator calls `CombinePsbt`, this malicious output is merged into +the final transaction. If Alice's application logic does not manually parse and +validate every single input and output from Bob's PSBT fragment, she will +unknowingly sign a transaction that steals funds. **The API makes the insecure +path easy.** + +### The Recommended (and Secure) Coordinator Model + +This model ensures security by having all participants agree on the final +transaction structure *before* any signatures are created. + +**Principle:** Verify the whole transaction, then sign. + +```mermaid +sequenceDiagram + participant A as Alice (Coordinator) + participant B as Bob + participant N as Bitcoin Network + + Note over A,B: 1. Off-chain Agreement on Terms + + A->>A: Create Bare PSBT (Template) + A->>B: Send Bare PSBT + + par Parallel Signing + A->>A: Verify, Decorate, & Sign Input + B->>B: Verify, Decorate, & Sign Input + end + + B->>A: Send Partially Signed PSBT + A->>A: Combine Signatures + A->>A: Finalize Transaction + A->>N: Broadcast Transaction +``` + +#### Workflow Steps + +**1. Agreement (Off-chain)** +Alice and Bob first communicate and agree on the exact transaction: +- Which UTXO Alice will contribute (`alice_utxo_1`). +- Which UTXO Bob will contribute (`bob_utxo_1`). +- The final, combined output for Carol. +- The exact change output for Alice. +- The exact change output for Bob. +- The agreed-upon fee rate. + +**2. Coordinator Creates the Template (Alice)** +Alice, acting as coordinator, creates a single, bare PSBT that represents the +**entire, final transaction**. This is the "single source of truth". + +```go +// Alice's code (as coordinator) +allInputs := []*wire.OutPoint{&alice_utxo_1, &bob_utxo_1} +allOutputs := []*wire.TxOut{carol_output, alice_change_output, bob_change_output} + +// Create a single PSBT template for the entire transaction. +barePacket, err := wallet.CreatePsbt(allInputs, allOutputs) +``` + +**3. Participants Verify, Decorate, and Sign (Parallel)** +The coordinator sends the `barePacket` to all participants (including herself). +Each participant now performs the same set of actions independently. + +```go +// Bob's code (Alice does the same with her wallet) + +// CRITICAL STEP: Verify the transaction structure. +// Bob's application logic MUST inspect barePacket to ensure it exactly +// matches the off-chain agreement. It checks that only the expected inputs +// and outputs are present, with the correct values. +if !isValid(barePacket) { + return errors.New("transaction proposal is invalid") +} + +// If valid, Bob's wallet decorates its own input. +err := bobWallet.DecorateInputs(ctx, barePacket, true) +// The wallet finds bob_utxo_1 and adds its UTXO/derivation info. + +// Bob's wallet now signs its input. +signParams := &wallet.SignPsbtParams{Packet: barePacket} +_, err = bobWallet.SignPsbt(ctx, signParams) + +// Bob sends the partially signed PSBT back to the coordinator. +``` + +**4. Coordinator Combines Signatures (Alice)** +Alice collects the signed PSBTs from all participants. Each PSBT is a copy of +the original `barePacket` but now contains a different partial signature. She +uses `CombinePsbt` to merge these signatures into a single, fully-signed PSBT. + +```go +// Alice's code (as coordinator) +// (Alice has already signed her own copy, `my_signed_packet`) +fullySignedPacket, err := aliceWallet.CombinePsbt( + ctx, my_signed_packet, signed_packet_from_bob, +) +``` + +**5. Finalize and Broadcast (Alice)** +The coordinator now has a complete PSBT and can finalize it to produce the +broadcastable transaction. + +```go +err = aliceWallet.FinalizePsbt(ctx, fullySignedPacket) +finalTx, err := psbt.Extract(fullySignedPacket) +err = aliceWallet.Broadcast(ctx, finalTx, "Collaborative payment to Carol") +``` + +#### Analysis + +- **Round Trips:** 2. + 1. Coordinator distributes the `barePacket` to all participants. + 2. Participants return their signed PSBTs to the coordinator. +- **Security:** High. The security comes from the "verify-then-sign" workflow. + Each participant validates the entire, final transaction structure *before* + creating a signature. A signature becomes a cryptographic commitment to the + complete, agreed-upon transaction, preventing any party from maliciously + altering it after the fact. + +--- + +## Scenario 3: Taproot Script Path Multisig (Signing Multiple Times) + +Taproot introduces powerful new capabilities, such as Script Path spends where +multiple parties can sign via different leaf scripts or a single script requiring +multiple signatures (e.g., a 2-of-2 multisig script leaf). + +The `SignPsbt` method enforces a **strict single-derivation-path policy** per +input call. This means if a wallet holds multiple keys involved in a multisig +input, it must call `SignPsbt` multiple times—once for each key it intends to +sign with. + +**Goal:** A 2-of-2 multisig input (Alice + Bob) is being spent via a Taproot +Script Path. Alice holds both Key A1 and Key A2 (e.g., for redundancy or testing) +and needs to provide two signatures for the same input. + +### Workflow Steps + +1. **Prepare the PSBT:** The coordinator constructs the PSBT with the Taproot + input. The input MUST include the `TaprootLeafScript` and `ControlBlock` to + identify the script path being spent. + +2. **First Signing Pass (Key A1):** Alice's wallet inspects the PSBT. To sign + with Key A1, the application must ensure the PSBT input contains the + `TaprootBip32Derivation` for **Key A1 only**. + + ```go + // Populate derivation info for Key A1 ONLY. + packet.Inputs[0].TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{ + derivInfoForKeyA1, + } + + // Sign. The wallet sees one derivation path and generates one signature. + // It appends this signature to the `TaprootScriptSpendSig` list. + signedResult, err := aliceWallet.SignPsbt(ctx, &wallet.SignPsbtParams{ + Packet: packet, + }) + ``` + +3. **Second Signing Pass (Key A2):** Now Alice needs to sign with Key A2. The + application updates the PSBT input to show the derivation for **Key A2**. + + ```go + // Replace derivation info with Key A2. + packet.Inputs[0].TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{ + derivInfoForKeyA2, + } + + // Sign again. The wallet sees a new, single derivation path. + // It generates the second signature and appends it to the list. + // The previous signature for Key A1 is preserved. + signedResult2, err := aliceWallet.SignPsbt(ctx, &wallet.SignPsbtParams{ + Packet: packet, + }) + ``` + +### Why this Restriction? + +Enforcing a single derivation path per call eliminates ambiguity. +- If `SignPsbt` received multiple derivation paths for one input, it would be + unclear if the caller intended to sign *all* of them, *one* of them, or if + some were just metadata. +- By requiring explicit, singular intent, the API ensures deterministic + behavior: "Here is the key I want you to use; sign with it." + +--- + +## Advanced Topics & Best Practices + +### Why `SIGHASH_ALL` is Essential +In collaborative transactions, all signatures should use `SIGHASH_ALL` (the +default). This flag ensures that the signature commits to *all* inputs and *all* +outputs in the transaction. If a participant were to use a different flag like +`SIGHASH_SINGLE`, a malicious coordinator could modify the parts of the +transaction not covered by the signature, leading to fund loss or unexpected +behavior. + +### The Role of `DecorateInputs` +`DecorateInputs` is the bridge between a transaction's structure and its +signability. In the Coordinator Model, it's a crucial step that allows each +participant's wallet to add the private information (UTXO value, script, +derivation path) needed for its own hardware or software to produce a valid +signature. + +### Coin Control +A user can choose a specific UTXO to spend by creating a PSBT with that input +already included before calling `FundPsbt`. The `FundPsbt` method will detect +the existing input and enter a "completion" mode, where it simply calculates +fees and adds a change output, rather than performing automatic coin selection. +This is how Alice specified her input in the Coordinator Model example. \ No newline at end of file From 2ab23ac085bfbf8193f0a21e021048cad3be6af8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 16 Dec 2025 11:57:45 +0800 Subject: [PATCH 205/691] wallet: consolidate PSBT errors and refactor manager This commit consolidates various specific PSBT errors into broader categories (ErrNilArguments, ErrInvalidBip32Path, ErrIndexOutOfBounds, ErrPsbtMergeConflict) to reduce API surface area. It also refactors fetchPsbtUtxo to return errors instead of logging, ensuring better failure handling in SignPsbt and FinalizePsbt. Unit tests and linting issues (err113, lll) have been addressed. --- wallet/psbt_manager.go | 147 ++++++++++++------------------------ wallet/psbt_manager_test.go | 81 +++++++++++--------- 2 files changed, 92 insertions(+), 136 deletions(-) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index c3e9cc68da..5b8a65d315 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -23,6 +23,9 @@ import ( ) var ( + // ErrNilArguments is returned when a required argument is nil. + ErrNilArguments = errors.New("nil arguments") + // ErrUtxoLocked is returned when a UTXO is locked. ErrUtxoLocked = errors.New("utxo is locked") @@ -46,9 +49,6 @@ var ( "cannot specify both psbt inputs and a coin selection policy", ) - // ErrNilFundIntent is returned when a nil FundIntent is provided. - ErrNilFundIntent = errors.New("nil FundIntent") - // ErrNoPsbtsToCombine is returned when no PSBTs are provided to // combine. ErrNoPsbtsToCombine = errors.New("no psbts to combine") @@ -71,14 +71,14 @@ var ( // encountered. ErrUnknownAddressType = errors.New("unknown address type") - // ErrInvalidBip32PathLength is returned when a BIP32 path does not - // have the expected length of 5. - ErrInvalidBip32PathLength = errors.New("invalid BIP32 path length") - // ErrUnknownBip32Purpose is returned when a BIP32 path has a purpose // that is not supported by the wallet. ErrUnknownBip32Purpose = errors.New("unknown BIP32 purpose") + // ErrInvalidBip32Path is returned when a BIP32 derivation path is + // invalid (e.g. wrong length, missing hardening, wrong coin type). + ErrInvalidBip32Path = errors.New("invalid BIP32 path") + // ErrUnsupportedTaprootLeafCount is returned when a Taproot derivation // info contains an unsupported number of leaf hashes (e.g. > 1). ErrUnsupportedTaprootLeafCount = errors.New("unsupported number of " + @@ -121,86 +121,23 @@ var ( "invalid taproot merkle root length", ) - // ErrInvalidBip32PathElementHardened is returned when a BIP32 path - // element that should be hardened is not. - ErrInvalidBip32PathElementHardened = errors.New( - "invalid BIP32 derivation path, element must be hardened", - ) - - // ErrInvalidBip32DerivationCoinType is returned when the coin type in - // a BIP32 derivation path does not match the wallet's chain parameters. - ErrInvalidBip32DerivationCoinType = errors.New( - "invalid BIP32 derivation path, coin type mismatch", - ) - - // ErrSighashMismatch is returned when merging PSBTs with conflicting - // sighash types. - ErrSighashMismatch = errors.New("sighash type mismatch") - - // ErrRedeemScriptMismatch is returned when merging PSBTs with - // conflicting redeem scripts. - ErrRedeemScriptMismatch = errors.New("redeem script mismatch") - - // ErrWitnessScriptMismatch is returned when merging PSBTs with - // conflicting witness scripts. - ErrWitnessScriptMismatch = errors.New("witness script mismatch") - - // ErrFinalScriptSigMismatch is returned when merging PSBTs with - // conflicting final script sigs. - ErrFinalScriptSigMismatch = errors.New("final script sig mismatch") - - // ErrFinalScriptWitnessMismatch is returned when merging PSBTs with - // conflicting final script witnesses. - ErrFinalScriptWitnessMismatch = errors.New("final script witness " + - "mismatch") - - // ErrTaprootKeySpendSigMismatch is returned when merging PSBTs with - // conflicting Taproot key spend signatures. - ErrTaprootKeySpendSigMismatch = errors.New("taproot key spend sig " + - "mismatch") - - // ErrWitnessUtxoMismatch is returned when merging PSBTs with - // conflicting witness UTXOs. - ErrWitnessUtxoMismatch = errors.New("witness utxo mismatch") - - // ErrNonWitnessUtxoMismatch is returned when merging PSBTs with - // conflicting non-witness UTXOs. - ErrNonWitnessUtxoMismatch = errors.New("non-witness utxo mismatch") - - // ErrTaprootInternalKeyMismatch is returned when merging PSBTs with - // conflicting Taproot internal keys. - ErrTaprootInternalKeyMismatch = errors.New("taproot internal key " + - "mismatch") + // ErrPsbtMergeConflict is returned when merging PSBTs with conflicting + // fields (e.g. different sighash types, scripts, or signatures). + ErrPsbtMergeConflict = errors.New("psbt merge conflict") // ErrImportedAddrNoDerivation is returned when trying to add output // info for an imported address that has no derivation path. ErrImportedAddrNoDerivation = errors.New("change addr is an " + "imported addr with unknown derivation path") - // ErrNilSignPsbtParams is returned when nil SignPsbtParams are - // provided. - ErrNilSignPsbtParams = errors.New("nil SignPsbtParams") - - // ErrPsbtInputIndexOutOfBounds is returned when an input index is out - // of bounds. - ErrPsbtInputIndexOutOfBounds = errors.New("psbt input index out of " + - "bounds") + // ErrIndexOutOfBounds is returned when an index is out of bounds. + ErrIndexOutOfBounds = errors.New("index out of bounds") // ErrInputMissingUtxoInfo is returned when an input lacks both // WitnessUtxo and NonWitnessUtxo. ErrInputMissingUtxoInfo = errors.New("input missing both " + "WitnessUtxo and NonWitnessUtxo") - // ErrUnsignedTxInputIndexOutOfBounds is returned when an input index - // is out of bounds for the unsigned transaction. - ErrUnsignedTxInputIndexOutOfBounds = errors.New("psbt input index " + - "out of bounds for UnsignedTx inputs") - - // ErrPrevOutIndexOutOfBounds is returned when a previous output index - // is out of bounds for the NonWitnessUtxo. - ErrPrevOutIndexOutOfBounds = errors.New("prevOut index out of " + - "bounds for NonWitnessUtxo") - // errAlreadySigned is returned when an input is already signed. // // NOTE: This error is private because it is used for internal control @@ -768,7 +705,7 @@ func (w *Wallet) addChangeOutputInfo(ctx context.Context, packet *psbt.Packet, func (w *Wallet) validateFundIntent(intent *FundIntent) error { // The intent must not be nil. if intent == nil { - return ErrNilFundIntent + return ErrNilArguments } // The PSBT packet must not be nil. @@ -901,7 +838,7 @@ func (w *Wallet) SignPsbt(ctx context.Context, params *SignPsbtParams) ( *SignPsbtResult, error) { if params == nil { - return nil, ErrNilSignPsbtParams + return nil, ErrNilArguments } packet := params.Packet @@ -1036,16 +973,16 @@ func (w *Wallet) parseBip32Path(path []uint32) (BIP32Path, error) { // The BIP32 path must have exactly 5 elements: // m / purpose' / coin_type' / account' / branch / index if len(path) != BIP32PathLength { - return BIP32Path{}, fmt.Errorf("%w: %d", - ErrInvalidBip32PathLength, len(path)) + return BIP32Path{}, fmt.Errorf("%w: length %d", + ErrInvalidBip32Path, len(path)) } // The first 3 elements (Purpose, CoinType, Account) must be hardened. // We check this by verifying they are >= HardenedKeyStart. for i := range 3 { if path[i] < hdkeychain.HardenedKeyStart { - return BIP32Path{}, fmt.Errorf("%w: element %d", - ErrInvalidBip32PathElementHardened, i) + return BIP32Path{}, fmt.Errorf("%w: element %d not "+ + "hardened", ErrInvalidBip32Path, i) } } @@ -1058,8 +995,8 @@ func (w *Wallet) parseBip32Path(path []uint32) (BIP32Path, error) { // Verify that the coin type matches the wallet's chain parameters. if coinType != w.chainParams.HDCoinType { - return BIP32Path{}, fmt.Errorf("%w: expected %d, got %d", - ErrInvalidBip32DerivationCoinType, + return BIP32Path{}, fmt.Errorf("%w: expected coin type %d, "+ + "got %d", ErrInvalidBip32Path, w.chainParams.HDCoinType, coinType) } @@ -1221,8 +1158,8 @@ func validateDerivation(pInput *psbt.PInput, idx int) (bool, error) { // panics on malformed packets. func fetchPsbtUtxo(packet *psbt.Packet, idx int) (*wire.TxOut, error) { if idx >= len(packet.Inputs) { - return nil, fmt.Errorf("%w: %d", - ErrPsbtInputIndexOutOfBounds, idx) + return nil, fmt.Errorf("%w: psbt input index %d", + ErrIndexOutOfBounds, idx) } pInput := &packet.Inputs[idx] @@ -1237,15 +1174,15 @@ func fetchPsbtUtxo(packet *psbt.Packet, idx int) (*wire.TxOut, error) { } if idx >= len(packet.UnsignedTx.TxIn) { - return nil, fmt.Errorf("%w: %d", - ErrUnsignedTxInputIndexOutOfBounds, idx) + return nil, fmt.Errorf("%w: psbt input index %d for "+ + "UnsignedTx inputs", ErrIndexOutOfBounds, idx) } prevIdx := packet.UnsignedTx.TxIn[idx].PreviousOutPoint.Index if int(prevIdx) >= len(pInput.NonWitnessUtxo.TxOut) { return nil, fmt.Errorf("%w: input %d prevOut index %d", - ErrPrevOutIndexOutOfBounds, idx, prevIdx) + ErrIndexOutOfBounds, idx, prevIdx) } return pInput.NonWitnessUtxo.TxOut[prevIdx], nil @@ -1999,8 +1936,8 @@ func mergeSighashType(dest, src *psbt.PInput) error { if dest.SighashType != 0 && src.SighashType != 0 && dest.SighashType != src.SighashType { - return fmt.Errorf("%w: %v vs %v", ErrSighashMismatch, - dest.SighashType, src.SighashType) + return fmt.Errorf("%w: sighash type mismatch %v vs %v", + ErrPsbtMergeConflict, dest.SighashType, src.SighashType) } if dest.SighashType == 0 { @@ -2036,7 +1973,8 @@ func mergeRedeemScript(dest, src *psbt.PInput) error { if len(dest.RedeemScript) > 0 && len(src.RedeemScript) > 0 && !bytes.Equal(dest.RedeemScript, src.RedeemScript) { - return ErrRedeemScriptMismatch + return fmt.Errorf("%w: redeem script mismatch", + ErrPsbtMergeConflict) } if len(dest.RedeemScript) == 0 { @@ -2051,7 +1989,8 @@ func mergeWitnessScript(dest, src *psbt.PInput) error { if len(dest.WitnessScript) > 0 && len(src.WitnessScript) > 0 && !bytes.Equal(dest.WitnessScript, src.WitnessScript) { - return ErrWitnessScriptMismatch + return fmt.Errorf("%w: witness script mismatch", + ErrPsbtMergeConflict) } if len(dest.WitnessScript) == 0 { @@ -2066,7 +2005,8 @@ func mergeFinalScriptSig(dest, src *psbt.PInput) error { if len(dest.FinalScriptSig) > 0 && len(src.FinalScriptSig) > 0 && !bytes.Equal(dest.FinalScriptSig, src.FinalScriptSig) { - return ErrFinalScriptSigMismatch + return fmt.Errorf("%w: final script sig mismatch", + ErrPsbtMergeConflict) } if len(dest.FinalScriptSig) == 0 { @@ -2082,7 +2022,8 @@ func mergeFinalScriptWitness(dest, src *psbt.PInput) error { len(src.FinalScriptWitness) > 0 && !bytes.Equal(dest.FinalScriptWitness, src.FinalScriptWitness) { - return ErrFinalScriptWitnessMismatch + return fmt.Errorf("%w: final script witness mismatch", + ErrPsbtMergeConflict) } if len(dest.FinalScriptWitness) == 0 { @@ -2099,7 +2040,8 @@ func mergeTaprootKeySpendSig(dest, src *psbt.PInput) error { len(src.TaprootKeySpendSig) > 0 && !bytes.Equal(dest.TaprootKeySpendSig, src.TaprootKeySpendSig) { - return ErrTaprootKeySpendSigMismatch + return fmt.Errorf("%w: taproot key spend sig mismatch", + ErrPsbtMergeConflict) } if len(dest.TaprootKeySpendSig) == 0 { @@ -2124,7 +2066,8 @@ func mergeWitnessUtxo(dest, src *psbt.PInput) error { !bytes.Equal(dest.WitnessUtxo.PkScript, src.WitnessUtxo.PkScript) { - return ErrWitnessUtxoMismatch + return fmt.Errorf("%w: witness utxo mismatch", + ErrPsbtMergeConflict) } } @@ -2140,7 +2083,8 @@ func mergeWitnessUtxo(dest, src *psbt.PInput) error { func mergeNonWitnessUtxo(dest, src *psbt.PInput) error { if dest.NonWitnessUtxo != nil && src.NonWitnessUtxo != nil { if dest.NonWitnessUtxo.TxHash() != src.NonWitnessUtxo.TxHash() { - return ErrNonWitnessUtxoMismatch + return fmt.Errorf("%w: non-witness utxo mismatch", + ErrPsbtMergeConflict) } } @@ -2158,7 +2102,8 @@ func mergeTaprootInternalKey(dest, src *psbt.POutput) error { len(src.TaprootInternalKey) > 0 && !bytes.Equal(dest.TaprootInternalKey, src.TaprootInternalKey) { - return ErrTaprootInternalKeyMismatch + return fmt.Errorf("%w: taproot internal key mismatch", + ErrPsbtMergeConflict) } if len(dest.TaprootInternalKey) == 0 { @@ -2174,7 +2119,8 @@ func mergeOutputScripts(dest, src *psbt.POutput) error { if len(dest.RedeemScript) > 0 && len(src.RedeemScript) > 0 && !bytes.Equal(dest.RedeemScript, src.RedeemScript) { - return ErrRedeemScriptMismatch + return fmt.Errorf("%w: redeem script mismatch", + ErrPsbtMergeConflict) } if len(dest.RedeemScript) == 0 { @@ -2184,7 +2130,8 @@ func mergeOutputScripts(dest, src *psbt.POutput) error { if len(dest.WitnessScript) > 0 && len(src.WitnessScript) > 0 && !bytes.Equal(dest.WitnessScript, src.WitnessScript) { - return ErrWitnessScriptMismatch + return fmt.Errorf("%w: witness script mismatch", + ErrPsbtMergeConflict) } if len(dest.WitnessScript) == 0 { diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 1c90d61600..08b4f01aa2 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -924,9 +924,8 @@ func TestValidateFundIntentError(t *testing.T) { { name: "nil intent", intent: nil, - expectedErr: ErrNilFundIntent, - }, - { + expectedErr: ErrNilArguments, + }, { name: "nil packet", intent: &FundIntent{Packet: nil}, expectedErr: ErrNilTxIntent, @@ -1880,35 +1879,35 @@ func TestParseBip32Path(t *testing.T) { { name: "invalid length", path: []uint32{hardened(84)}, - expectedErr: ErrInvalidBip32PathLength, + expectedErr: ErrInvalidBip32Path, }, { name: "unhardened purpose", path: []uint32{ 84, hardened(0), hardened(0), 0, 0, }, - expectedErr: ErrInvalidBip32PathElementHardened, + expectedErr: ErrInvalidBip32Path, }, { name: "unhardened coin type", path: []uint32{ hardened(84), 0, hardened(0), 0, 0, }, - expectedErr: ErrInvalidBip32PathElementHardened, + expectedErr: ErrInvalidBip32Path, }, { name: "unhardened account", path: []uint32{ hardened(84), hardened(0), 0, 0, 0, }, - expectedErr: ErrInvalidBip32PathElementHardened, + expectedErr: ErrInvalidBip32Path, }, { name: "coin type mismatch", path: []uint32{ hardened(84), hardened(1), hardened(0), 0, 0, }, - expectedErr: ErrInvalidBip32DerivationCoinType, + expectedErr: ErrInvalidBip32Path, }, { name: "unknown purpose (now allowed in parseBip32Path)", @@ -2290,7 +2289,7 @@ func TestFetchPsbtUtxo(t *testing.T) { }, inputIdx: 0, expected: nil, - expectedErr: ErrPsbtInputIndexOutOfBounds, + expectedErr: ErrIndexOutOfBounds, }, { name: "prevout index out of bounds", @@ -2308,7 +2307,7 @@ func TestFetchPsbtUtxo(t *testing.T) { }, inputIdx: 0, expected: nil, - expectedErr: ErrPrevOutIndexOutOfBounds, + expectedErr: ErrIndexOutOfBounds, }, } @@ -3036,7 +3035,7 @@ func TestSignPsbtFailNilParams(t *testing.T) { _, err := w.SignPsbt(t.Context(), nil) // Assert: Verify error. - require.ErrorIs(t, err, ErrNilSignPsbtParams) + require.ErrorIs(t, err, ErrNilArguments) } // TestSignPsbt tests the high-level SignPsbt method, ensuring it correctly @@ -3170,7 +3169,7 @@ func TestSignPsbtInvalidDerivationPath(t *testing.T) { _, err = w.SignPsbt(t.Context(), signParams) // Assert. - require.ErrorIs(t, err, ErrInvalidBip32PathLength) + require.ErrorIs(t, err, ErrInvalidBip32Path) } // TestSignPsbtSignErrorSkippable tests that SignPsbt skips an input if @@ -3245,7 +3244,7 @@ func TestSignTaprootPsbtInputErrors(t *testing.T) { }} packet.Inputs[0].TaprootBip32Derivation = tapDerivation err = w.signTaprootPsbtInput(t.Context(), packet, 0, nil, nil) - require.ErrorIs(t, err, ErrInvalidBip32PathLength) + require.ErrorIs(t, err, ErrInvalidBip32Path) // Case 2: CreateTaprootSpendDetails error (e.g. invalid merkle root). packet.Inputs[0].TaprootBip32Derivation[0].Bip32Path = []uint32{ @@ -3278,7 +3277,17 @@ func TestSignBip32PsbtInputErrors(t *testing.T) { Bip32Path: []uint32{1}, // Too short }} err = w.signBip32PsbtInput(t.Context(), packet, 0, nil, nil) - require.ErrorIs(t, err, ErrInvalidBip32PathLength) + require.ErrorIs(t, err, ErrInvalidBip32Path) + + // Case 2: Unknown Purpose. + packet.Inputs[0].Bip32Derivation[0].Bip32Path = []uint32{ + hdkeychain.HardenedKeyStart + 999, // Unknown + hdkeychain.HardenedKeyStart + 1, + hdkeychain.HardenedKeyStart + 0, + 0, 0, + } + err = w.signBip32PsbtInput(t.Context(), packet, 0, nil, nil) + require.ErrorIs(t, err, ErrUnknownBip32Purpose) } // TestAddScriptToPInput tests that addScriptToPInput correctly updates @@ -4005,13 +4014,12 @@ func TestMergeSighashType(t *testing.T) { // different, conflicting sighash type of SigHashSingle. src := &psbt.PInput{SighashType: txscript.SigHashSingle} - // Act: Attempt to merge the source into the destination using - // the mergeSighashType helper. + // Act: Attempt to merge the conflicting inputs. err := mergeSighashType(dest, src) // Assert: Verify that the function identified the conflict and - // returned the expected ErrSighashMismatch error. - require.ErrorIs(t, err, ErrSighashMismatch) + // returned the expected error. + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("adopt source type", func(t *testing.T) { @@ -4060,9 +4068,9 @@ func TestMergeRedeemScript(t *testing.T) { err := mergeRedeemScript(dest, src) // Assert: Verify that the function returns - // ErrRedeemScriptMismatch, preventing the corruption of the + // ErrPsbtMergeConflict, preventing the corruption of the // redeem script. - require.ErrorIs(t, err, ErrRedeemScriptMismatch) + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("adopt source script", func(t *testing.T) { @@ -4110,8 +4118,8 @@ func TestMergeWitnessUtxo(t *testing.T) { err := mergeWitnessUtxo(dest, src) // Assert: Verify that the function returns - // ErrWitnessUtxoMismatch. - require.ErrorIs(t, err, ErrWitnessUtxoMismatch) + // ErrPsbtMergeConflict. + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("detect script mismatch", func(t *testing.T) { @@ -4137,8 +4145,8 @@ func TestMergeWitnessUtxo(t *testing.T) { err := mergeWitnessUtxo(dest, src) // Assert: Verify that the function returns - // ErrWitnessUtxoMismatch due to the script difference. - require.ErrorIs(t, err, ErrWitnessUtxoMismatch) + // ErrPsbtMergeConflict due to the script difference. + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("adopt source utxo", func(t *testing.T) { @@ -4187,9 +4195,9 @@ func TestMergeNonWitnessUtxo(t *testing.T) { // Act: Attempt to merge. err := mergeNonWitnessUtxo(dest, src) - // Assert: Verify that ErrNonWitnessUtxoMismatch is returned + // Assert: Verify that ErrPsbtMergeConflict is returned // because the transactions differ. - require.ErrorIs(t, err, ErrNonWitnessUtxoMismatch) + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("adopt source utxo", func(t *testing.T) { @@ -4227,8 +4235,8 @@ func TestMergeTaprootInternalKeyMismatch(t *testing.T) { // Act: Attempt to merge the outputs. err := mergeTaprootInternalKey(dest, src) - // Assert: Verify that ErrTaprootInternalKeyMismatch is returned. - require.ErrorIs(t, err, ErrTaprootInternalKeyMismatch) + // Assert: Verify that ErrPsbtMergeConflict is returned. + require.ErrorIs(t, err, ErrPsbtMergeConflict) } // TestMergeTaprootInternalKeyAdoption verifies that a source key is adopted @@ -4291,7 +4299,7 @@ func TestMergeInputScripts(t *testing.T) { dest := &psbt.PInput{RedeemScript: []byte{1}} src := &psbt.PInput{RedeemScript: []byte{2}} err := mergeInputScripts(dest, src) - require.ErrorIs(t, err, ErrRedeemScriptMismatch) + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("fail on witness script", func(t *testing.T) { @@ -4300,7 +4308,7 @@ func TestMergeInputScripts(t *testing.T) { dest := &psbt.PInput{WitnessScript: []byte{1}} src := &psbt.PInput{WitnessScript: []byte{2}} err := mergeInputScripts(dest, src) - require.ErrorIs(t, err, ErrWitnessScriptMismatch) + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("fail on final script sig", func(t *testing.T) { @@ -4309,7 +4317,7 @@ func TestMergeInputScripts(t *testing.T) { dest := &psbt.PInput{FinalScriptSig: []byte{1}} src := &psbt.PInput{FinalScriptSig: []byte{2}} err := mergeInputScripts(dest, src) - require.ErrorIs(t, err, ErrFinalScriptSigMismatch) + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("fail on final script witness", func(t *testing.T) { @@ -4318,7 +4326,7 @@ func TestMergeInputScripts(t *testing.T) { dest := &psbt.PInput{FinalScriptWitness: []byte{1}} src := &psbt.PInput{FinalScriptWitness: []byte{2}} err := mergeInputScripts(dest, src) - require.ErrorIs(t, err, ErrFinalScriptWitnessMismatch) + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("success", func(t *testing.T) { @@ -4336,8 +4344,9 @@ func TestMergeInputScripts(t *testing.T) { require.Equal(t, src.RedeemScript, dest.RedeemScript) require.Equal(t, src.WitnessScript, dest.WitnessScript) require.Equal(t, src.FinalScriptSig, dest.FinalScriptSig) - require.Equal(t, src.FinalScriptWitness, - dest.FinalScriptWitness) + require.Equal( + t, src.FinalScriptWitness, dest.FinalScriptWitness, + ) }) } @@ -4351,7 +4360,7 @@ func TestMergeOutputScripts(t *testing.T) { dest := &psbt.POutput{RedeemScript: []byte{1}} src := &psbt.POutput{RedeemScript: []byte{2}} err := mergeOutputScripts(dest, src) - require.ErrorIs(t, err, ErrRedeemScriptMismatch) + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("fail on witness script", func(t *testing.T) { @@ -4360,7 +4369,7 @@ func TestMergeOutputScripts(t *testing.T) { dest := &psbt.POutput{WitnessScript: []byte{1}} src := &psbt.POutput{WitnessScript: []byte{2}} err := mergeOutputScripts(dest, src) - require.ErrorIs(t, err, ErrWitnessScriptMismatch) + require.ErrorIs(t, err, ErrPsbtMergeConflict) }) t.Run("success", func(t *testing.T) { From a0c6036d44c7ec1cf093b79612eec3eb986b8213 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 17 Dec 2025 20:37:58 +0800 Subject: [PATCH 206/691] wallet: use `slices.ContainsFunc` --- wallet/psbt_manager.go | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 5b8a65d315..37df062e56 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "math" + "slices" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/psbt" @@ -1871,15 +1872,10 @@ func mergePsbtOutputs(dest, src *psbt.POutput) error { // avoiding duplicates based on pubkey. func deduplicatePartialSigs(dest, src []*psbt.PartialSig) []*psbt.PartialSig { for _, sig := range src { - found := false - for _, dSig := range dest { - if bytes.Equal(dSig.PubKey, sig.PubKey) { - found = true - break - } - } + if !slices.ContainsFunc(dest, func(dSig *psbt.PartialSig) bool { + return bytes.Equal(dSig.PubKey, sig.PubKey) + }) { - if !found { dest = append(dest, sig) } } @@ -1893,15 +1889,12 @@ func deduplicateBip32Derivations( dest, src []*psbt.Bip32Derivation) []*psbt.Bip32Derivation { for _, der := range src { - found := false - for _, dDer := range dest { - if bytes.Equal(dDer.PubKey, der.PubKey) { - found = true - break - } - } + if !slices.ContainsFunc( + dest, func(dDer *psbt.Bip32Derivation) bool { + return bytes.Equal(dDer.PubKey, der.PubKey) + }, + ) { - if !found { dest = append(dest, der) } } @@ -1915,15 +1908,14 @@ func deduplicateTaprootBip32Derivations(dest, src []*psbt.TaprootBip32Derivation) []*psbt.TaprootBip32Derivation { for _, der := range src { - found := false - for _, dDer := range dest { - if bytes.Equal(dDer.XOnlyPubKey, der.XOnlyPubKey) { - found = true - break - } - } + if !slices.ContainsFunc( + dest, func(dDer *psbt.TaprootBip32Derivation) bool { + return bytes.Equal( + dDer.XOnlyPubKey, der.XOnlyPubKey, + ) + }, + ) { - if !found { dest = append(dest, der) } } From 78552da0aed11e2ecf097600d992c1bedda05f55 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 17 Dec 2025 21:23:12 +0800 Subject: [PATCH 207/691] wallet: return error from `PsbtPrevOutputFetcher` --- wallet/deprecated.go | 6 ++++- wallet/psbt_manager.go | 53 +++++++++++++++++++------------------ wallet/psbt_manager_test.go | 3 ++- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 11bcf6fe59..5bc7fb6c28 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -839,7 +839,11 @@ func (w *Wallet) FinalizePsbtDeprecated(keyScope *waddrmgr.KeyScope, account uin // ones to sign. If there is any input without witness data that we // cannot sign because it's not our UTXO, this will be a hard failure. tx := packet.UnsignedTx - sigHashes := txscript.NewTxSigHashes(tx, PsbtPrevOutputFetcher(packet)) + fetcher, err := PsbtPrevOutputFetcher(packet) + if err != nil { + return err + } + sigHashes := txscript.NewTxSigHashes(tx, fetcher) for idx, txIn := range tx.TxIn { in := packet.Inputs[idx] diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 37df062e56..262fa4f6d0 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -866,7 +866,11 @@ func (w *Wallet) SignPsbt(ctx context.Context, params *SignPsbtParams) ( // are part of the data signed. Following this, we compute the // transaction's sighashes, which are integral to producing valid // signatures for each input. - prevOutFetcher := PsbtPrevOutputFetcher(packet) + prevOutFetcher, err := PsbtPrevOutputFetcher(packet) + if err != nil { + return nil, fmt.Errorf("error creating prevOutFetcher: %w", err) + } + sigHashes := txscript.NewTxSigHashes( packet.UnsignedTx, prevOutFetcher, ) @@ -1569,7 +1573,10 @@ func (w *Wallet) FinalizePsbt(ctx context.Context, packet *psbt.Packet) error { // previous transaction outputs needed for sighash generation. This is // required for generating valid signatures, as the value and script of // the UTXO being spent are part of the signed digest. - prevOutFetcher := PsbtPrevOutputFetcher(packet) + prevOutFetcher, err := PsbtPrevOutputFetcher(packet) + if err != nil { + return fmt.Errorf("error creating prevOutFetcher: %w", err) + } // Compute the transaction's sighashes. This is an optimization to // calculate the sighashes once and reuse them for all inputs, rather @@ -2237,35 +2244,29 @@ func createOutputInfo(txOut *wire.TxOut, return out, nil } -// PsbtPrevOutputFetcher returns a txscript.PrevOutFetcher built from the UTXO -// information in a PSBT packet. -func PsbtPrevOutputFetcher(packet *psbt.Packet) *txscript.MultiPrevOutFetcher { +// PsbtPrevOutputFetcher returns a txscript.PrevOutputFetcher that is +// backed by the UTXO information in a PSBT packet. +func PsbtPrevOutputFetcher(packet *psbt.Packet) ( + *txscript.MultiPrevOutFetcher, error) { + fetcher := txscript.NewMultiPrevOutFetcher(nil) for idx, txIn := range packet.UnsignedTx.TxIn { - in := packet.Inputs[idx] - - // Skip any input that has no UTXO. - if in.WitnessUtxo == nil && in.NonWitnessUtxo == nil { - continue - } - - if in.NonWitnessUtxo != nil { - prevIndex := txIn.PreviousOutPoint.Index - fetcher.AddPrevOut( - txIn.PreviousOutPoint, - in.NonWitnessUtxo.TxOut[prevIndex], - ) + // Use the robust fetchPsbtUtxo helper. + utxo, err := fetchPsbtUtxo(packet, idx) + if err != nil { + // If the input is missing UTXO info entirely, we skip + // it (matching previous behavior). + if errors.Is(err, ErrInputMissingUtxoInfo) { + continue + } - continue + // Other errors (e.g. index out of bounds) are fatal + // as they indicate a malformed PSBT. + return nil, err } - // Fall back to witness UTXO only for older wallets. - if in.WitnessUtxo != nil { - fetcher.AddPrevOut( - txIn.PreviousOutPoint, in.WitnessUtxo, - ) - } + fetcher.AddPrevOut(txIn.PreviousOutPoint, utxo) } - return fetcher + return fetcher, nil } diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 08b4f01aa2..f430e99cb9 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -3980,7 +3980,8 @@ func TestPsbtPrevOutputFetcher(t *testing.T) { packet.Inputs[1].WitnessUtxo = &wire.TxOut{Value: 2000} // Act: Create the fetcher. - fetcher := PsbtPrevOutputFetcher(packet) + fetcher, err := PsbtPrevOutputFetcher(packet) + require.NoError(t, err) // Assert: Check input 0 (NonWitness). out0 := fetcher.FetchPrevOutput(wire.OutPoint{Index: 0}) From 4e78824693cd32c2e0c00744a2db8fa22beba0e4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 17 Dec 2025 21:38:45 +0800 Subject: [PATCH 208/691] wallet: add deduplicateTaprootScriptSpendSigs and its test This commit introduces a new helper function, deduplicateTaprootScriptSpendSigs, to correctly handle deduplication of Taproot script spend signatures when merging PSBTs. This aligns with BIP-174 combiner role and ensures consistency with other deduplication methods. A comprehensive unit test, TestDeduplicateTaprootScriptSpendSigs, has been added to verify its behavior. --- wallet/psbt_manager.go | 35 ++++++--- wallet/psbt_manager_test.go | 152 ++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 9 deletions(-) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 262fa4f6d0..bb51ff3722 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -1830,7 +1830,9 @@ func mergePsbtInputs(dest, src *psbt.PInput) error { return err } - mergeTaprootScriptSpendSigs(dest, src) + dest.TaprootScriptSpendSig = deduplicateTaprootScriptSpendSigs( + dest.TaprootScriptSpendSig, src.TaprootScriptSpendSig, + ) err = mergeWitnessUtxo(dest, src) if err != nil { @@ -1930,6 +1932,29 @@ func deduplicateTaprootBip32Derivations(dest, return dest } +// deduplicateTaprootScriptSpendSigs adds new Taproot Script Spend Signatures +// from src to dest, avoiding duplicates based on XOnlyPubKey and LeafHash. +func deduplicateTaprootScriptSpendSigs(dest, + src []*psbt.TaprootScriptSpendSig) []*psbt.TaprootScriptSpendSig { + + for _, srcSig := range src { + if !slices.ContainsFunc( + dest, func(destSig *psbt.TaprootScriptSpendSig) bool { + return bytes.Equal( + destSig.XOnlyPubKey, srcSig.XOnlyPubKey, + ) && bytes.Equal( + destSig.LeafHash, srcSig.LeafHash, + ) + }, + ) { + + dest = append(dest, srcSig) + } + } + + return dest +} + // mergeSighashType merges the SighashType field. Returns error on conflict. func mergeSighashType(dest, src *psbt.PInput) error { if dest.SighashType != 0 && src.SighashType != 0 && @@ -2050,14 +2075,6 @@ func mergeTaprootKeySpendSig(dest, src *psbt.PInput) error { return nil } -// mergeTaprootScriptSpendSigs appends Taproot Script Spend Signatures from src -// to dest. -func mergeTaprootScriptSpendSigs(dest, src *psbt.PInput) { - dest.TaprootScriptSpendSig = append( - dest.TaprootScriptSpendSig, src.TaprootScriptSpendSig..., - ) -} - // mergeWitnessUtxo merges the Witness UTXO field. Returns error on conflict. func mergeWitnessUtxo(dest, src *psbt.PInput) error { if dest.WitnessUtxo != nil && src.WitnessUtxo != nil { diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index f430e99cb9..7de67d53b5 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -4289,6 +4289,158 @@ func TestDeduplicateTaprootBip32Derivations(t *testing.T) { }) } +// TestDeduplicateTaprootScriptSpendSigs tests the deduplication logic for +// Taproot script spend signatures based on XOnlyPubKey and LeafHash. +func TestDeduplicateTaprootScriptSpendSigs(t *testing.T) { + t.Parallel() + + // Define common elements for signatures. + xOnlyPubKey1 := []byte{1} + xOnlyPubKey2 := []byte{2} + leafHash1 := []byte{10} + leafHash2 := []byte{20} + + tests := []struct { + name string + dest []*psbt.TaprootScriptSpendSig + src []*psbt.TaprootScriptSpendSig + want []*psbt.TaprootScriptSpendSig + }{ + { + name: "empty slices", + dest: nil, + src: nil, + want: nil, + }, + { + name: "add unique from src", + dest: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, + }, + src: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey2, + LeafHash: leafHash2, + }, + }, + want: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, + { + XOnlyPubKey: xOnlyPubKey2, + LeafHash: leafHash2, + }, + }, + }, + { + name: "skip duplicate from src", + dest: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, + }, + src: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, + }, // Duplicate + want: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, + }, + }, + { + name: "mix unique and duplicate", + dest: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, + }, + src: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, // Duplicate + { + XOnlyPubKey: xOnlyPubKey2, + LeafHash: leafHash2, + }, // Unique + }, + want: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, + { + XOnlyPubKey: xOnlyPubKey2, + LeafHash: leafHash2, + }, + }, + }, + { + name: "complex mix", + dest: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, + { + XOnlyPubKey: xOnlyPubKey2, + LeafHash: leafHash1, + }, // Same LeafHash, diff PubKey + }, + src: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, // Duplicate of dest[0] + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash2, + }, // Unique + }, + want: []*psbt.TaprootScriptSpendSig{ + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash1, + }, + { + XOnlyPubKey: xOnlyPubKey2, + LeafHash: leafHash1, + }, + { + XOnlyPubKey: xOnlyPubKey1, + LeafHash: leafHash2, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Deduplicate the signatures. + got := deduplicateTaprootScriptSpendSigs( + tc.dest, tc.src, + ) + + // Assert: Verify the result. + require.ElementsMatch(t, tc.want, got) + }) + } +} + // TestMergeInputScripts tests the aggregate mergeInputScripts function to // ensure it propagates errors from all sub-steps. func TestMergeInputScripts(t *testing.T) { From 334a9c600a19d479db59675452de2459489df145 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Dec 2025 19:54:11 +0800 Subject: [PATCH 209/691] waddrmgr: add `DeriveAddr` for efficient in-memory bulk derivation Adds a new method DeriveAddr and DeriveAddrs to ScopedKeyManager that allows deriving a range of addresses using only in-memory state (cached account info). This is critical for high-performance scanning loops where holding a database transaction for address derivation is inefficient or causes lock contention. It supports all standard address types and includes comprehensive unit tests verifying parity with the existing DB-backed logic. --- waddrmgr/scoped_manager.go | 134 ++++++++++++++ waddrmgr/scoped_manager_test.go | 303 ++++++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 waddrmgr/scoped_manager_test.go diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index ed3fd69740..61d8d69342 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -2014,6 +2014,82 @@ func (s *ScopedKeyManager) ImportPublicKey(ns walletdb.ReadWriteBucket, return s.toImportedPublicManagedAddress(pubKey, true) } +// DeriveAddr derives a single address and its corresponding pkScript for the +// given account, branch, and index. This method relies on the in-memory +// account state and extended public keys, avoiding database access. +func (s *ScopedKeyManager) DeriveAddr(account uint32, branch uint32, + index uint32) (btcutil.Address, []byte, error) { + + s.mtx.RLock() + defer s.mtx.RUnlock() + + acctInfo, ok := s.acctInfo[account] + if !ok { + return nil, nil, managerError(ErrAccountNotCached, + "account not cached", nil) + } + + return s.deriveAddr(acctInfo, account, branch, index) +} + +// DeriveAddrs derives a range of addresses and their corresponding pkScripts +// for the given account and branch. It generates `count` addresses starting +// from `startIndex`. This method relies on the in-memory account state and +// extended public keys, avoiding database access. +// +// It returns: +// - A slice of derived addresses. +// - A slice of corresponding pkScripts. +// - An error if the account is not cached or derivation fails. +func (s *ScopedKeyManager) DeriveAddrs(account uint32, branch uint32, + startIndex uint32, count uint32) ([]btcutil.Address, [][]byte, error) { + + // Make sure the index is sane. + if startIndex+count < startIndex { + str := fmt.Sprintf("child index overflow: %d + %d", + startIndex, count) + + return nil, nil, managerError(ErrTooManyAddresses, str, nil) + } + + s.mtx.RLock() + defer s.mtx.RUnlock() + + // Ensure the account information is cached. If not, we cannot proceed + // without a DB transaction, so we return an error. The caller is + // expected to ensure the account is loaded (e.g. via AccountProperties) + // before calling this method. + acctInfo, ok := s.acctInfo[account] + if !ok { + return nil, nil, managerError(ErrAccountNotCached, + "account not cached", nil) + } + + addrs := make([]btcutil.Address, 0, count) + scripts := make([][]byte, 0, count) + + // Iterate through the requested range of child indexes (startIndex to + // startIndex+count). For each index, we derive the corresponding + // extended key and convert it into a payment address and script. + // + // TODO(yy): Optimize by deriving the branch key once outside the loop + // instead of re-deriving it for every index via s.deriveKey. + endIndex := startIndex + count + for index := startIndex; index < endIndex; index++ { + addr, script, err := s.deriveAddr( + acctInfo, account, branch, index, + ) + if err != nil { + return nil, nil, err + } + + addrs = append(addrs, addr) + scripts = append(scripts, script) + } + + return addrs, scripts, nil +} + // importPublicKey imports a public key into the address manager and updates the // wallet's start block if necessary. An error is returned if the public key // already exists. @@ -2656,6 +2732,64 @@ func (s *ScopedKeyManager) NewAddress(addrmgrNs walletdb.ReadWriteBucket, return addr, nil } +// deriveAddr performs the actual derivation logic for a single address using +// the provided account info. It assumes the manager lock is held. +func (s *ScopedKeyManager) deriveAddr(acctInfo *accountInfo, account, branch, + index uint32) (btcutil.Address, []byte, error) { + + // Determine the address type (schema) for this account and branch. + // This tells us whether to generate P2PKH (BIP44), P2WPKH (BIP84), + // Nested P2WPKH (BIP49), or Taproot (BIP86) addresses. + // Internal branch usually implies change addresses. + internal := branch == InternalBranch + addrType := s.accountAddrType(acctInfo, internal) + + // Derive the extended key for this index. + // We pass 'false' for the private flag because we only need the + // public key to derive the address. This allows operation even + // if the wallet is locked (encrypted private keys unavailable). + key, err := s.deriveKey(acctInfo, branch, index, false) + if err != nil { + return nil, nil, err + } + + pubKey, err := key.ECPubKey() + if err != nil { + return nil, nil, fmt.Errorf("failed to parse public key: %w", + err) + } + + // Construct the derivation path metadata. This is required by + // newManagedAddressWithoutPrivKey to properly tag the address. + derivationPath := DerivationPath{ + InternalAccount: account, + Account: acctInfo.acctKeyPub.ChildIndex(), + Branch: branch, + Index: index, + MasterKeyFingerprint: acctInfo.masterKeyFingerprint, + } + + // Create a temporary managed address. We use this helper because + // it encapsulates the complex logic for converting a public key + // into the correct address format (e.g. P2SH wrapping for nested + // SegWit) based on the addrType. + ma, err := newManagedAddressWithoutPrivKey( + s, derivationPath, pubKey, true, addrType, + ) + if err != nil { + return nil, nil, err + } + + addr := ma.Address() + + script, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, nil, fmt.Errorf("failed to create script: %w", err) + } + + return addr, script, nil +} + // accountInfo returns a copy of the account info map. func (s *ScopedKeyManager) accountInfo() map[uint32]*accountInfo { s.mtx.RLock() diff --git a/waddrmgr/scoped_manager_test.go b/waddrmgr/scoped_manager_test.go new file mode 100644 index 0000000000..c1614c6927 --- /dev/null +++ b/waddrmgr/scoped_manager_test.go @@ -0,0 +1,303 @@ +package waddrmgr + +import ( + "math" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/stretchr/testify/require" +) + +// TestDeriveAddrs verifies that DeriveAddrs correctly derives addresses using +// in-memory state, producing the same results as database-backed derivation. +func TestDeriveAddrs(t *testing.T) { + t.Parallel() + + // Initialize a new address manager with a clean database for testing. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + // Unlock the manager to allow full functionality, although DeriveAddrs + // works without unlocking (tested separately). We unlock here to + // ensure DeriveFromKeyPath (the baseline) has access to private keys + // if needed by its internal logic. + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + // Fetch the default BIP0044 scoped manager. + scope := KeyScopeBIP0044 + acctStore, err := mgr.FetchScopedKeyManager(scope) + require.NoError(t, err) + + // Cast to the concrete type to access the method under test. + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok, "expected *ScopedKeyManager") + + account := uint32(DefaultAccountNum) + + // Pre-load account into cache (required for DeriveAddrs). + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + _, err := scopedMgr.AccountProperties(ns, account) + + return err + }) + require.NoError(t, err) + + // NOTE: We define it here instead of using anonymous struct as this + // struct is needed for the `assertDBCorrectness`. + type testCase struct { + name string + branch uint32 + startIndex uint32 + count uint32 + } + + // We define a set of test cases covering different branches + // (internal/external) and index ranges to ensure robust derivation. We + // also test large batches to verify performance and correctness at + // scale. + tests := []testCase{ + { + name: "External Branch, Index 0-4", + branch: ExternalBranch, + startIndex: 0, + count: 5, + }, + { + name: "Internal Branch, Index 10-14", + branch: InternalBranch, + startIndex: 10, + count: 5, + }, + { + name: "Large Batch", + branch: ExternalBranch, + startIndex: 100, + count: 50, + }, + { + name: "Single Address", + branch: ExternalBranch, + startIndex: 1000, + count: 1, + }, + { + name: "Zero Addresses", + branch: ExternalBranch, + startIndex: 0, + count: 0, + }, + } + + accountNum := hdkeychain.HardenedKeyStart + account + + // assertDBCorrectness is a helper closure that verifies the results + // returned by DeriveAddrs against the baseline DeriveFromKeyPath + // method. This ensures that the in-memory derivation logic produces + // identical addresses and scripts as the database-backed logic. + assertDBCorrectness := func(t *testing.T, tc testCase, + addrs []btcutil.Address) { + + t.Helper() + + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + for i := range tc.count { + index := tc.startIndex + i + + // Construct the derivation path for the + // baseline check. + path := DerivationPath{ + InternalAccount: account, + Account: accountNum, + Branch: tc.branch, + Index: index, + } + + // Derive using the standard DB-backed method. + managedAddr, err := scopedMgr.DeriveFromKeyPath( + ns, path, + ) + require.NoError(t, err) + + // Compare the resulting address string. + expectedAddr := managedAddr.Address() + require.Equal(t, expectedAddr.String(), + addrs[i].String(), "Address mismatch "+ + "at index %d", index) + + // Compare the resulting script. + require.Equal(t, expectedAddr.ScriptAddress(), + addrs[i].ScriptAddress()) + } + + return nil + }) + require.NoError(t, err) + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Call the new in-memory derivation method. This + // should return the derived addresses and scripts + // without further DB access. + addrs, scripts, err := scopedMgr.DeriveAddrs( + account, tc.branch, tc.startIndex, tc.count, + ) + require.NoError(t, err) + require.Len(t, addrs, int(tc.count)) + require.Len(t, scripts, int(tc.count)) + + // Verify the results against the established, + // database-backed DeriveFromKeyPath method to ensure + // correctness. + assertDBCorrectness(t, tc, addrs) + }) + } +} + +// TestDeriveAddrsLocked verifies that DeriveAddrs works even when the wallet +// is locked (using extended public keys). +func TestDeriveAddrsLocked(t *testing.T) { + t.Parallel() + + // Initialize the manager. By default, it is locked. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + // Confirm the manager is indeed locked. + require.True(t, mgr.IsLocked()) + + scope := KeyScopeBIP0044 + acctStore, err := mgr.FetchScopedKeyManager(scope) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + // Pre-load the account into the cache using a read-only transaction. + // AccountProperties only needs public keys, so it works while locked. + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + _, err := scopedMgr.AccountProperties(ns, DefaultAccountNum) + + return err + }) + require.NoError(t, err) + + // Attempt to derive addresses while locked. This should succeed + // because it uses the cached extended public keys. + addrs, _, err := scopedMgr.DeriveAddrs( + DefaultAccountNum, ExternalBranch, 0, 5, + ) + + // Verify success and result count. + require.NoError(t, err, "DeriveAddrs should succeed when locked") + require.Len(t, addrs, 5) +} + +// TestDeriveAddrsOverflow verifies that DeriveAddrs returns an error when the +// requested range of child indexes overflows uint32. +func TestDeriveAddrsOverflow(t *testing.T) { + t.Parallel() + + // Arrange: Setup the environment with a zero-valued ScopedKeyManager, + // as the overflow check is performed before any other logic or state + // access. + var s ScopedKeyManager + + // Act: Execute the function under test with a range that triggers a + // uint32 overflow (startIndex + count > math.MaxUint32). + startIndex := uint32(math.MaxUint32 - 5) + count := uint32(10) + _, _, err := s.DeriveAddrs(0, 0, startIndex, count) + + // Assert: Verify that the expected overflow error is returned. + require.Error(t, err) + require.True(t, IsError(err, ErrTooManyAddresses)) + require.Contains(t, err.Error(), "child index overflow") +} + +// TestDeriveAddr verifies that DeriveAddr correctly derives a single address +// using in-memory state. +func TestDeriveAddr(t *testing.T) { + t.Parallel() + + // Initialize manager. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + // Unlock manager to allow full functionality. + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + // Fetch scoped manager. + scope := KeyScopeBIP0044 + acctStore, err := mgr.FetchScopedKeyManager(scope) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + account := uint32(DefaultAccountNum) + + // Pre-load account into cache. + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + _, err := scopedMgr.AccountProperties(ns, account) + + return err + }) + require.NoError(t, err) + + // Define test parameters. + branch := ExternalBranch + index := uint32(0) + + // Call DeriveAddr (In-Memory). + addr, script, err := scopedMgr.DeriveAddr(account, branch, index) + require.NoError(t, err) + + // Verify against Baseline (DeriveFromKeyPath via DB). + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + path := DerivationPath{ + InternalAccount: account, + Account: hdkeychain.HardenedKeyStart + account, + Branch: branch, + Index: index, + } + + managedAddr, err := scopedMgr.DeriveFromKeyPath(ns, path) + require.NoError(t, err) + + // Compare address string and script hash. + require.Equal(t, managedAddr.Address().String(), + addr.String()) + require.Equal(t, managedAddr.Address().ScriptAddress(), + addr.ScriptAddress()) + + // Verify returned script matches expected P2PKH script. + expectedScript, _ := txscript.PayToAddrScript( + managedAddr.Address(), + ) + require.Equal(t, expectedScript, script) + + return nil + }) + require.NoError(t, err) +} From f5862874d34591eda073a18278cf3c8f8c1d38d8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Dec 2025 20:46:57 +0800 Subject: [PATCH 210/691] chain+wallet: implement `GetCFilter` for chain backends Adds GetCFilter method to the chain.Interface and implements it for all chain backends (bitcoind, btcd, neutrino). This enables efficient block filtering by using compact filters (BIP 157/158) to reduce bandwidth and processing overhead during wallet synchronization and rescans. --- chain/bitcoind_client.go | 57 +++++++++++++++++++++++++++++++++++++++ chain/btcd.go | 26 +++++++++++++++++- chain/interface.go | 3 +++ chain/neutrino.go | 16 +++++++++++ wallet/chain_mock_test.go | 7 +++++ wallet/mock_test.go | 13 +++++++++ 6 files changed, 121 insertions(+), 1 deletion(-) diff --git a/chain/bitcoind_client.go b/chain/bitcoind_client.go index 3136d4c233..754050ee14 100644 --- a/chain/bitcoind_client.go +++ b/chain/bitcoind_client.go @@ -4,6 +4,7 @@ import ( "container/list" "context" "encoding/hex" + "encoding/json" "errors" "fmt" "sync" @@ -12,6 +13,8 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" + "github.com/btcsuite/btcd/btcutil/gcs/builder" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -25,6 +28,10 @@ var ( // to receive a notification for a specific item and the bitcoind client // is in the middle of shutting down. ErrBitcoindClientShuttingDown = errors.New("client is shutting down") + + // ErrOnlyBasicFilters is an error returned when a filter type other + // than basic is requested. + ErrOnlyBasicFilters = errors.New("only basic filters are supported") ) // BitcoindClient represents a persistent client connection to a bitcoind server @@ -113,6 +120,56 @@ func (c *BitcoindClient) BackEnd() string { return "bitcoind" } +// GetCFilter returns a compact filter for the given block hash and filter +// type. +// +// NOTE: This is part of the chain.Interface interface. +func (c *BitcoindClient) GetCFilter(hash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) { + + if filterType != wire.GCSFilterRegular { + return nil, ErrOnlyBasicFilters + } + + // The getblockfilter RPC takes the block hash and the filter type. + // Filter type defaults to "basic" if omitted, but we specify it for + // clarity. + params := []json.RawMessage{ + json.RawMessage(fmt.Sprintf("%q", hash.String())), + json.RawMessage(fmt.Sprintf("%q", "basic")), + } + + resp, err := c.chainConn.client.RawRequest("getblockfilter", params) + if err != nil { + return nil, c.MapRPCErr(err) + } + + var res struct { + Filter string `json:"filter"` + Header string `json:"header"` + } + + err = json.Unmarshal(resp, &res) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal filter: %w", err) + } + + filterBytes, err := hex.DecodeString(res.Filter) + if err != nil { + return nil, fmt.Errorf("failed to decode filter: %w", err) + } + + filter, err := gcs.FromNBytes( + builder.DefaultP, builder.DefaultM, filterBytes, + ) + if err != nil { + return nil, fmt.Errorf("failed to create filter from bytes: %w", + err) + } + + return filter, nil +} + // GetBestBlock returns the highest block known to bitcoind. func (c *BitcoindClient) GetBestBlock() (*chainhash.Hash, int32, error) { bcinfo, err := c.chainConn.client.GetBlockChainInfo() diff --git a/chain/btcd.go b/chain/btcd.go index 35af57a4c0..05a9c48640 100644 --- a/chain/btcd.go +++ b/chain/btcd.go @@ -283,6 +283,28 @@ func (c *RPCClient) Rescan(startHash *chainhash.Hash, addrs []btcutil.Address, return c.Client.Rescan(startHash, addrs, flatOutpoints) // nolint:staticcheck } +// GetCFilter returns a compact filter for the given block hash and filter +// type. It wraps the underlying rpcclient method and converts the result to a +// *gcs.Filter. +func (c *RPCClient) GetCFilter(hash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) { + + rawFilter, err := c.Client.GetCFilter(hash, filterType) + if err != nil { + return nil, fmt.Errorf("failed to get filter: %w", err) + } + + filter, err := gcs.FromNBytes( + builder.DefaultP, builder.DefaultM, rawFilter.Data, + ) + if err != nil { + return nil, fmt.Errorf("failed to create filter from bytes: %w", + err) + } + + return filter, nil +} + // WaitForShutdown blocks until both the client has finished disconnecting // and all handlers have exited. func (c *RPCClient) WaitForShutdown() { @@ -333,7 +355,9 @@ func (c *RPCClient) FilterBlocks( // the filter returns a positive match, the full block is then requested // and scanned for addresses using the block filterer. for i, blk := range req.Blocks { - rawFilter, err := c.GetCFilter(&blk.Hash, wire.GCSFilterRegular) + rawFilter, err := c.Client.GetCFilter( + &blk.Hash, wire.GCSFilterRegular, + ) if err != nil { return nil, err } diff --git a/chain/interface.go b/chain/interface.go index bb3a842e97..7dbe791e70 100644 --- a/chain/interface.go +++ b/chain/interface.go @@ -6,6 +6,7 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/wire" @@ -41,6 +42,8 @@ type Interface interface { GetBlockHash(int64) (*chainhash.Hash, error) GetBlockHeader(*chainhash.Hash) (*wire.BlockHeader, error) IsCurrent() bool + GetCFilter(hash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) FilterBlocks(*FilterBlocksRequest) (*FilterBlocksResponse, error) BlockStamp() (*waddrmgr.BlockStamp, error) SendRawTransaction(*wire.MsgTx, bool) (*chainhash.Hash, error) diff --git a/chain/neutrino.go b/chain/neutrino.go index ea899f40e7..12cb26d5ad 100644 --- a/chain/neutrino.go +++ b/chain/neutrino.go @@ -217,6 +217,22 @@ func (s *NeutrinoClient) IsCurrent() bool { return s.CS.IsCurrent() } +// GetCFilter returns a compact filter for the given block hash and filter +// type. +// +// NOTE: This is part of the chain.Interface interface. +func (s *NeutrinoClient) GetCFilter(blockHash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) { + + filter, err := s.CS.GetCFilter(*blockHash, filterType) + if err != nil { + return nil, fmt.Errorf("failed to get filter from "+ + "neutrino: %w", err) + } + + return filter, nil +} + // SendRawTransaction replicates the RPC client's SendRawTransaction command. func (s *NeutrinoClient) SendRawTransaction(tx *wire.MsgTx, allowHighFees bool) ( *chainhash.Hash, error) { diff --git a/wallet/chain_mock_test.go b/wallet/chain_mock_test.go index fad58fa86b..cf57c36e8c 100644 --- a/wallet/chain_mock_test.go +++ b/wallet/chain_mock_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" @@ -112,6 +113,12 @@ func (m *mockChainClient) IsCurrent() bool { return false } +func (m *mockChainClient) GetCFilter(hash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) { + + return nil, ErrNotImplemented +} + func (m *mockChainClient) FilterBlocks(*chain.FilterBlocksRequest) ( *chain.FilterBlocksResponse, error) { diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 64df84fdd2..198535c794 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -15,6 +15,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -857,6 +858,18 @@ func (m *mockChain) IsCurrent() bool { return args.Bool(0) } +// GetCFilter implements the chain.Interface interface. +func (m *mockChain) GetCFilter(hash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) { + + args := m.Called(hash, filterType) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*gcs.Filter), args.Error(1) +} + // FilterBlocks implements the chain.Interface interface. func (m *mockChain) FilterBlocks(req *chain.FilterBlocksRequest) ( *chain.FilterBlocksResponse, error) { From 0961990790b856f0a96234eed8f7f398e963517d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Dec 2025 22:48:07 +0800 Subject: [PATCH 211/691] chain: use `NextAvailablePort`` for bitcoind test ports Replaced random port assignments with `NextAvailablePort` to prevent collisions and resolve macOS IPC socket path issues by switching ZMQ to TCP. The new `port.go` is a copy of `lnd`'s `port.go', - https://github.com/lightningnetwork/lnd/blob/master/lntest/port/port.go --- chain/bitcoind_events_test.go | 11 ++- chain/port/port.go | 149 ++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 chain/port/port.go diff --git a/chain/bitcoind_events_test.go b/chain/bitcoind_events_test.go index aef18ea0c8..4f64fd7ac9 100644 --- a/chain/bitcoind_events_test.go +++ b/chain/bitcoind_events_test.go @@ -2,7 +2,6 @@ package chain import ( "fmt" - "math/rand" "os/exec" "testing" "time" @@ -15,6 +14,7 @@ import ( "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/chain/port" "github.com/stretchr/testify/require" ) @@ -460,10 +460,13 @@ func setupBitcoind(t *testing.T, minerAddr string, // Start a bitcoind instance and connect it to miner1. tempBitcoindDir := t.TempDir() - zmqBlockHost := "ipc:///" + tempBitcoindDir + "/blocks.socket" - zmqTxHost := "ipc:///" + tempBitcoindDir + "/tx.socket" + zmqBlockPort := port.NextAvailablePort() + zmqTxPort := port.NextAvailablePort() - rpcPort := rand.Int()%(65536-1024) + 1024 + zmqBlockHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqBlockPort) + zmqTxHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqTxPort) + + rpcPort := port.NextAvailablePort() bitcoind := exec.Command( "bitcoind", "-datadir="+tempBitcoindDir, diff --git a/chain/port/port.go b/chain/port/port.go new file mode 100644 index 0000000000..5306b19368 --- /dev/null +++ b/chain/port/port.go @@ -0,0 +1,149 @@ +package port + +import ( + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "sync" + "time" +) + +const ( + // defaultTimeout is the default timeout that is used for the wait + // package. + defaultTimeout = 30 * time.Second + + // ListenerFormat is the format string that is used to generate local + // listener addresses. + ListenerFormat = "127.0.0.1:%d" + + // defaultNodePort is the start of the range for listening ports of + // harness nodes. Ports are monotonically increasing starting from this + // number and are determined by the results of NextAvailablePort(). + defaultNodePort int = 10000 + + // uniquePortFile is the name of the file that is used to store the + // last port that was used by a node. This is used to make sure that + // the same port is not used by multiple nodes at the same time. The + // file is located in the temp directory of a system. + uniquePortFile = "rpctest-port" +) + +var ( + // portFileMutex is a mutex that is used to make sure that the port file + // is not accessed by multiple goroutines of the same process at the + // same time. This is used in conjunction with the lock file to make + // sure that the port file is not accessed by multiple processes at the + // same time either. So the lock file is to guard between processes and + // the mutex is to guard between goroutines of the same process. + portFileMutex sync.Mutex +) + +// NextAvailablePort returns the first port that is available for listening by a +// new node, using a lock file to make sure concurrent access for parallel tasks +// on the same system don't re-use the same port. +func NextAvailablePort() int { + portFileMutex.Lock() + defer portFileMutex.Unlock() + + lockFile := filepath.Join(os.TempDir(), uniquePortFile+".lock") + timeout := time.After(defaultTimeout) + + var ( + lockFileHandle *os.File + err error + ) + for { + // Attempt to acquire the lock file. If it already exists, wait + // for a bit and retry. + lockFileHandle, err = os.OpenFile( + lockFile, os.O_CREATE|os.O_EXCL, 0600, + ) + if err == nil { + // Lock acquired. + break + } + + // Wait for a bit and retry. + select { + case <-timeout: + panic("timeout waiting for lock file") + case <-time.After(10 * time.Millisecond): + } + } + + // Release the lock file when we're done. + defer func() { + // Always close file first, Windows won't allow us to remove it + // otherwise. + _ = lockFileHandle.Close() + + err := os.Remove(lockFile) + if err != nil { + panic(fmt.Errorf("couldn't remove lock file: %w", err)) + } + }() + + portFile := filepath.Join(os.TempDir(), uniquePortFile) + + port, err := os.ReadFile(portFile) + if err != nil { + if !os.IsNotExist(err) { + panic(fmt.Errorf("error reading port file: %w", err)) + } + + port = []byte(strconv.Itoa(defaultNodePort)) + } + + lastPort, err := strconv.Atoi(string(port)) + if err != nil { + panic(fmt.Errorf("error parsing port: %w", err)) + } + + // We take the next one. + lastPort++ + for lastPort < 65535 { + // If there are no errors while attempting to listen on this + // port, close the socket and return it as available. While it + // could be the case that some other process picks up this port + // between the time the socket is closed, and it's reopened in + // the harness node, in practice in CI servers this seems much + // less likely than simply some other process already being + // bound at the start of the tests. + addr := fmt.Sprintf(ListenerFormat, lastPort) + + l, err := net.Listen("tcp4", addr) + if err == nil { + err := l.Close() + if err == nil { + err := os.WriteFile( + portFile, + []byte(strconv.Itoa(lastPort)), 0600, + ) + if err != nil { + panic(fmt.Errorf("error updating "+ + "port file: %w", err)) + } + + return lastPort + } + } + + lastPort++ + + // Start from the beginning if we reached the end of the port + // range. We need to do this because the lock file now is + // persistent across runs on the same machine during the same + // boot/uptime cycle. So in order to make this work on + // developer's machines, we need to reset the port to the + // default value when we reach the end of the range. + if lastPort == 65535 { + lastPort = defaultNodePort + } + } + + // No ports available? Must be a mistake. + panic("no ports available for listening") +} From fcf857c93efb6ad3affb687a77c8ab274a7e0ad6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Dec 2025 02:16:27 +0800 Subject: [PATCH 212/691] chain: add `testBitcoindClientGetCFilter` for bitcoind For `btcd` and `neutrino`, the `GetCFilter` just wraps the existing methods so the tests are skipped. --- chain/bitcoind_events_test.go | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/chain/bitcoind_events_test.go b/chain/bitcoind_events_test.go index 4f64fd7ac9..7192931b4f 100644 --- a/chain/bitcoind_events_test.go +++ b/chain/bitcoind_events_test.go @@ -8,6 +8,8 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" + "github.com/btcsuite/btcd/btcutil/gcs/builder" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/integration/rpctest" @@ -68,7 +70,12 @@ func TestBitcoindEvents(t *testing.T) { testNotifySpentMempool(t, miner1, btcClient) // Test looking up mempool for input spent. + btcClient = setupBitcoind(t, addr, test.rpcPolling) testLookupInputMempoolSpend(t, miner1, btcClient) + + // Test GetCFilter method. + btcClient = setupBitcoind(t, addr, test.rpcPolling) + testBitcoindClientGetCFilter(t, miner1, btcClient) }) } } @@ -480,6 +487,7 @@ func setupBitcoind(t *testing.T, minerAddr string, "-disablewallet", "-zmqpubrawblock="+zmqBlockHost, "-zmqpubrawtx="+zmqTxHost, + "-blockfilterindex=1", ) require.NoError(t, bitcoind.Start()) @@ -559,3 +567,58 @@ func randPubKeyHashScript() ([]byte, *btcec.PrivateKey, error) { return pkScript, privKey, nil } + +// testBitcoindClientGetCFilter verifies the BitcoindClient's GetCFilter +// implementation by interacting with a live bitcoind node. +func testBitcoindClientGetCFilter(t *testing.T, miner *rpctest.Harness, + client *BitcoindClient) { + + t.Helper() + + require := require.New(t) + + // Generate a block to have something to query a filter for. + hashes, err := miner.Client.Generate(1) + require.NoError(err) + + blockHash := hashes[0] + + // Get the CFilter using the BitcoindClient. This might take a few + // attempts as the filter index might not be immediately available. + var gcsFilter *gcs.Filter + require.Eventually(func() bool { + gcsFilter, err = client.GetCFilter( + blockHash, wire.GCSFilterRegular, + ) + + return err == nil + }, 5*time.Second, 100*time.Millisecond, "GetCFilter should succeed") + require.NotNil(gcsFilter, "GCS filter should not be nil") + require.IsType(&gcs.Filter{}, gcsFilter) + + // Verify the filter matches the block data. + block, err := client.GetBlock(blockHash) + require.NoError(err) + + // Use the first transaction's first output script. + script := block.Transactions[0].TxOut[0].PkScript + + // Derive the filter key. + key := builder.DeriveKey(blockHash) + + // Check match. + matched, err := gcsFilter.Match(key, script) + require.NoError(err) + require.True(matched, "Filter should match script from block") + + // Test with an unsupported filter type. + _, err = client.GetCFilter(blockHash, wire.FilterType(99)) + require.ErrorContains(err, "only basic filters are supported", + "Unsupported filter type should return an error") + + // Test GetCFilter for a non-existent block. + dummyHash := &chainhash.Hash{0x01, 0x02, 0x03} + _, err = client.GetCFilter(dummyHash, wire.GCSFilterRegular) + require.ErrorContains(err, "Block not found", + "Non-existent block should return an error") +} From 6efd638cd304d41f0d63f7a462fbd95b07e709fd Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Dec 2025 23:27:20 +0800 Subject: [PATCH 213/691] chain: fix linter errors in port.go --- chain/port/port.go | 87 +++++++++++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/chain/port/port.go b/chain/port/port.go index 5306b19368..88fdb83614 100644 --- a/chain/port/port.go +++ b/chain/port/port.go @@ -1,6 +1,9 @@ +// Package port provides functionality for managing network ports, including +// finding available ports and ensuring exclusive access using lock files. package port import ( + "context" "fmt" "net" "os" @@ -29,6 +32,17 @@ const ( // the same port is not used by multiple nodes at the same time. The // file is located in the temp directory of a system. uniquePortFile = "rpctest-port" + + // filePerms is the file permission used for the lock file and port + // file. + filePerms = 0600 + + // retryInterval is the interval to wait before retrying to acquire the + // lock file. + retryInterval = 10 * time.Millisecond + + // maxPort is the maximum valid port number. + maxPort = 65535 ) var ( @@ -49,30 +63,8 @@ func NextAvailablePort() int { defer portFileMutex.Unlock() lockFile := filepath.Join(os.TempDir(), uniquePortFile+".lock") - timeout := time.After(defaultTimeout) - - var ( - lockFileHandle *os.File - err error - ) - for { - // Attempt to acquire the lock file. If it already exists, wait - // for a bit and retry. - lockFileHandle, err = os.OpenFile( - lockFile, os.O_CREATE|os.O_EXCL, 0600, - ) - if err == nil { - // Lock acquired. - break - } - - // Wait for a bit and retry. - select { - case <-timeout: - panic("timeout waiting for lock file") - case <-time.After(10 * time.Millisecond): - } - } + lockFile = filepath.Clean(lockFile) + lockFileHandle := acquireLockFile(lockFile) // Release the lock file when we're done. defer func() { @@ -87,6 +79,7 @@ func NextAvailablePort() int { }() portFile := filepath.Join(os.TempDir(), uniquePortFile) + portFile = filepath.Clean(portFile) port, err := os.ReadFile(portFile) if err != nil { @@ -104,7 +97,7 @@ func NextAvailablePort() int { // We take the next one. lastPort++ - for lastPort < 65535 { + for lastPort < maxPort { // If there are no errors while attempting to listen on this // port, close the socket and return it as available. While it // could be the case that some other process picks up this port @@ -114,13 +107,16 @@ func NextAvailablePort() int { // bound at the start of the tests. addr := fmt.Sprintf(ListenerFormat, lastPort) - l, err := net.Listen("tcp4", addr) + lc := &net.ListenConfig{} + + l, err := lc.Listen(context.Background(), "tcp4", addr) if err == nil { err := l.Close() if err == nil { err := os.WriteFile( portFile, - []byte(strconv.Itoa(lastPort)), 0600, + []byte(strconv.Itoa(lastPort)), + filePerms, ) if err != nil { panic(fmt.Errorf("error updating "+ @@ -139,7 +135,7 @@ func NextAvailablePort() int { // boot/uptime cycle. So in order to make this work on // developer's machines, we need to reset the port to the // default value when we reach the end of the range. - if lastPort == 65535 { + if lastPort == maxPort { lastPort = defaultNodePort } } @@ -147,3 +143,38 @@ func NextAvailablePort() int { // No ports available? Must be a mistake. panic("no ports available for listening") } + +// acquireLockFile attempts to acquire the lock file. If it already exists, it +// waits for a bit and retries until the timeout is reached. If the process is +// killed before the lock file is removed, this function will timeout and panic. +// In that case, the lock file must be manually removed. +func acquireLockFile(lockFile string) *os.File { + timeout := time.After(defaultTimeout) + + var ( + lockFileHandle *os.File + err error + ) + for { + // Attempt to acquire the lock file. If it already exists, wait + // for a bit and retry. + // + //nolint:gosec // lockFile is constructed from os.TempDir() and + // a constant, not from user input. + lockFileHandle, err = os.OpenFile( + lockFile, os.O_CREATE|os.O_EXCL, filePerms, + ) + if err == nil { + // Lock acquired. + return lockFileHandle + } + + // Wait for a bit and retry. + select { + case <-timeout: + str := "timeout waiting for lock file: " + lockFile + panic(str) + case <-time.After(retryInterval): + } + } +} From a634eff38e3fdd3cb229d1b538a6637f32f78520 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 18 Dec 2025 14:12:03 +0800 Subject: [PATCH 214/691] chain: fix flake in `TestJitterTicker` --- chain/jitter_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chain/jitter_test.go b/chain/jitter_test.go index 9d62eab13f..16d1d5804b 100644 --- a/chain/jitter_test.go +++ b/chain/jitter_test.go @@ -91,8 +91,8 @@ func TestJitterTicker(t *testing.T) { // Tick duration should be between 80ms and 120ms. require.True(t, diff >= 80*time.Millisecond, "diff: %v", diff) - // We give 1ms more to account for the time it takes to run the + // We give 5ms more to account for the time it takes to run the // code. - require.True(t, diff < 121*time.Millisecond, "diff: %v", diff) + require.Less(t, diff, 125*time.Millisecond, "diff: %v", diff) } } From f0615607353267aaccd16266dceb3a975ae704a1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 18 Dec 2025 14:13:57 +0800 Subject: [PATCH 215/691] chain: refactor `TestBitcoindEvents` to fix flakes Each test now has its own fresh setup to avoid flakes. --- chain/bitcoind_events_test.go | 285 +++++++++++++++++++++------------- 1 file changed, 179 insertions(+), 106 deletions(-) diff --git a/chain/bitcoind_events_test.go b/chain/bitcoind_events_test.go index 7192931b4f..6982e1cd6b 100644 --- a/chain/bitcoind_events_test.go +++ b/chain/bitcoind_events_test.go @@ -20,62 +20,83 @@ import ( "github.com/stretchr/testify/require" ) -// TestBitcoindEvents ensures that the BitcoindClient correctly delivers tx and -// block notifications for both the case where a ZMQ subscription is used and -// for the case where RPC polling is used. -func TestBitcoindEvents(t *testing.T) { +const ( + // defaultTestTimeout is the default timeout used for tests in this + // file. It is set to 30 seconds to allow for slow test environments. + defaultTestTimeout = 30 * time.Second +) + +// TestBitcoindEventsZMQ runs all bitcoind event tests using ZMQ subscriptions. +// +// We cannot run these tests in parallel as it involves running multiple +// bitcoind servers and btcd servers in the background. While running multiple +// bitcoind servers is fine, the current integration test setup in `btcd` +// doesn't allow it as the created RPC client will share the same ports. +// +//nolint:paralleltest +func TestBitcoindEventsZMQ(t *testing.T) { + runBitcoindEventsTests(t, false) +} + +// TestBitcoindEventsRPC runs all bitcoind event tests using RPC polling. +// +// We cannot run these tests in parallel as it involves running multiple +// bitcoind servers and btcd servers in the background. While running multiple +// bitcoind servers is fine, the current integration test setup in `btcd` +// doesn't allow it as the created RPC client will share the same ports. +// +//nolint:paralleltest +func TestBitcoindEventsRPC(t *testing.T) { + runBitcoindEventsTests(t, true) +} + +// runBitcoindEventsTests runs the suite of bitcoind event tests with the +// specified polling mode. +func runBitcoindEventsTests(t *testing.T, rpcPolling bool) { + t.Helper() + tests := []struct { - name string - rpcPolling bool + name string + testFn func(*testing.T, *rpctest.Harness, *BitcoindClient) }{ { - name: "Events via ZMQ subscriptions", - rpcPolling: false, + name: "Reorg", + testFn: testReorg, + }, + { + name: "NotifyBlocks", + testFn: testNotifyBlocks, + }, + { + name: "NotifyTx", + testFn: testNotifyTx, }, { - name: "Events via RPC Polling", - rpcPolling: true, + name: "NotifySpentMempool", + testFn: testNotifySpentMempool, + }, + { + name: "LookupInputMempoolSpend", + testFn: testLookupInputMempoolSpend, + }, + { + name: "GetCFilter", + testFn: testBitcoindClientGetCFilter, }, } for _, test := range tests { test := test + t.Run(test.name, func(t *testing.T) { + // Initialize a fresh miner for the test case. + miner1 := setupMiner(t) + addr := miner1.P2PAddress() - // Set up 2 btcd miners. - miner1, miner2 := setupMiners(t) - addr := miner1.P2PAddress() + // Initialize a fresh bitcoind client for EVERY test + // case. + btcClient := setupBitcoind(t, addr, rpcPolling) - t.Run(test.name, func(t *testing.T) { - // Set up a bitcoind node and connect it to miner 1. - btcClient := setupBitcoind(t, addr, test.rpcPolling) - - // Test that the correct block `Connect` and - // `Disconnect` notifications are received during a - // re-org. - testReorg(t, miner1, miner2, btcClient) - - // Test that the expected block notifications are - // received. - btcClient = setupBitcoind(t, addr, test.rpcPolling) - testNotifyBlocks(t, miner1, btcClient) - - // Test that the expected tx notifications are - // received. - btcClient = setupBitcoind(t, addr, test.rpcPolling) - testNotifyTx(t, miner1, btcClient) - - // Test notifications for inputs already found in - // mempool. - btcClient = setupBitcoind(t, addr, test.rpcPolling) - testNotifySpentMempool(t, miner1, btcClient) - - // Test looking up mempool for input spent. - btcClient = setupBitcoind(t, addr, test.rpcPolling) - testLookupInputMempoolSpend(t, miner1, btcClient) - - // Test GetCFilter method. - btcClient = setupBitcoind(t, addr, test.rpcPolling) - testBitcoindClientGetCFilter(t, miner1, btcClient) + test.testFn(t, miner1, btcClient) }) } } @@ -98,31 +119,21 @@ func testNotifyTx(t *testing.T, miner *rpctest.Harness, client *BitcoindClient) err = client.NotifyTx([]chainhash.Hash{hash}) require.NoError(err) - _, err = client.SendRawTransaction(tx, true) - require.NoError(err) + // Send the transaction. This might fail if the bitcoind node hasn't + // synced the inputs yet, so we'll retry until it succeeds. + require.Eventually(func() bool { + _, err = client.SendRawTransaction(tx, true) + return err == nil + }, defaultTestTimeout, 100*time.Millisecond, + "SendRawTransaction failed") ntfns := client.Notifications() // We expect to get a ClientConnected notification. - select { - case ntfn := <-ntfns: - _, ok := ntfn.(ClientConnected) - require.Truef(ok, "Expected type ClientConnected, got %T", ntfn) - - case <-time.After(time.Second): - require.Fail("timed out for ClientConnected notification") - } + waitForClientConnected(t, ntfns) // We expect to get a RelevantTx notification. - select { - case ntfn := <-ntfns: - tx, ok := ntfn.(RelevantTx) - require.Truef(ok, "Expected type RelevantTx, got %T", ntfn) - require.True(tx.TxRecord.Hash.IsEqual(&hash)) - - case <-time.After(time.Second): - require.Fail("timed out waiting for RelevantTx notification") - } + waitForRelevantTx(t, ntfns, &hash) } // testNotifyBlocks tests that the correct notifications are received for @@ -141,14 +152,7 @@ func testNotifyBlocks(t *testing.T, miner *rpctest.Harness, miner.Client.Generate(1) // We expect to get a ClientConnected notification. - select { - case ntfn := <-ntfns: - _, ok := ntfn.(ClientConnected) - require.Truef(ok, "Expected type ClientConnected, got %T", ntfn) - - case <-time.After(time.Second): - require.Fail("timed out for ClientConnected notification") - } + waitForClientConnected(t, ntfns) // We expect to get a FilteredBlockConnected notification. select { @@ -157,7 +161,7 @@ func testNotifyBlocks(t *testing.T, miner *rpctest.Harness, require.Truef(ok, "Expected type FilteredBlockConnected, "+ "got %T", ntfn) - case <-time.After(time.Second): + case <-time.After(defaultTestTimeout): require.Fail("timed out for FilteredBlockConnected " + "notification") } @@ -168,7 +172,7 @@ func testNotifyBlocks(t *testing.T, miner *rpctest.Harness, _, ok := ntfn.(BlockConnected) require.Truef(ok, "Expected type BlockConnected, got %T", ntfn) - case <-time.After(time.Second): + case <-time.After(defaultTestTimeout): require.Fail("timed out for BlockConnected notification") } } @@ -202,25 +206,10 @@ func testNotifySpentMempool(t *testing.T, miner *rpctest.Harness, ntfns := client.Notifications() // We expect to get a ClientConnected notification. - select { - case ntfn := <-ntfns: - _, ok := ntfn.(ClientConnected) - require.Truef(ok, "Expected type ClientConnected, got %T", ntfn) - - case <-time.After(time.Second): - require.Fail("timed out for ClientConnected notification") - } + waitForClientConnected(t, ntfns) // We expect to get a RelevantTx notification. - select { - case ntfn := <-ntfns: - tx, ok := ntfn.(RelevantTx) - require.Truef(ok, "Expected type RelevantTx, got %T", ntfn) - require.True(tx.TxRecord.Hash.IsEqual(&txid)) - - case <-time.After(time.Second): - require.Fail("timed out waiting for RelevantTx notification") - } + waitForRelevantTx(t, ntfns, &txid) } // testLookupInputMempoolSpend tests that LookupInputMempoolSpend returns the @@ -257,7 +246,7 @@ func testLookupInputMempoolSpend(t *testing.T, miner *rpctest.Harness, rt.Eventually(func() bool { txid, found = client.LookupInputMempoolSpend(op) return found - }, 5*time.Second, 100*time.Millisecond) + }, defaultTestTimeout, 100*time.Millisecond) // Check the expected txid is returned. rt.Equal(tx.TxHash(), txid) @@ -265,8 +254,10 @@ func testLookupInputMempoolSpend(t *testing.T, miner *rpctest.Harness, // testReorg tests that the given BitcoindClient correctly responds to a chain // re-org. -func testReorg(t *testing.T, miner1, miner2 *rpctest.Harness, - client *BitcoindClient) { +func testReorg(t *testing.T, miner1 *rpctest.Harness, client *BitcoindClient) { + t.Helper() + + miner2 := setupReorgMiner(t, miner1) require := require.New(t) @@ -288,7 +279,7 @@ func testReorg(t *testing.T, miner1, miner2 *rpctest.Harness, _, ok := ntfn.(ClientConnected) require.Truef(ok, "Expected type ClientConnected, got %T", ntfn) - case <-time.After(time.Second): + case <-time.After(defaultTestTimeout): require.Fail("timed out for ClientConnected notification") } @@ -377,7 +368,7 @@ func testReorg(t *testing.T, miner1, miner2 *rpctest.Harness, func waitForBlockNtfn(t *testing.T, ntfns <-chan interface{}, expectedHeight int32, connected bool) chainhash.Hash { - timer := time.NewTimer(2 * time.Second) + timer := time.NewTimer(defaultTestTimeout) for { select { case nftn := <-ntfns: @@ -421,21 +412,47 @@ func waitForBlockNtfn(t *testing.T, ntfns <-chan interface{}, } } -// setUpMiners sets up two miners that can be used for a re-org test. -func setupMiners(t *testing.T) (*rpctest.Harness, *rpctest.Harness) { - trickle := fmt.Sprintf("--trickleinterval=%v", 10*time.Millisecond) - args := []string{trickle} +// setUpMiner sets up a single miner. +func setupMiner(t *testing.T) *rpctest.Harness { + t.Helper() - miner1, err := rpctest.New( - &chaincfg.RegressionNetParams, nil, args, "", - ) + args := []string{ + fmt.Sprintf("--trickleinterval=%v", 10*time.Millisecond), + // TODO(yy): We should uncomment the following to allow setting + // up ports here in the test. However, this cannot work without + // modifying the rpcclient in the `btcd` first, as the ports + // are overwritten there. + // + // fmt.Sprintf("--listen=%v", port.NextAvailablePort()), + // fmt.Sprintf("--rpclisten=%v", port.NextAvailablePort()), + } + + miner, err := rpctest.New(&chaincfg.RegressionNetParams, nil, args, "") require.NoError(t, err) t.Cleanup(func() { - miner1.TearDown() + require.NoError(t, miner.TearDown()) }) - require.NoError(t, miner1.SetUp(true, 1)) + require.NoError(t, miner.SetUp(true, 101)) + + return miner +} + +// setupReorgMiner sets up a second miner that can be used for a re-org test. +func setupReorgMiner(t *testing.T, miner1 *rpctest.Harness) *rpctest.Harness { + t.Helper() + + args := []string{ + fmt.Sprintf("--trickleinterval=%v", 10*time.Millisecond), + // TODO(yy): We should uncomment the following to allow setting + // up ports here in the test. However, this cannot work without + // modifying the rpcclient in the `btcd` first, as the ports + // are overwritten there. + // + // fmt.Sprintf("--listen=%v", port.NextAvailablePort()), + // fmt.Sprintf("--rpclisten=%v", port.NextAvailablePort()), + } miner2, err := rpctest.New( &chaincfg.RegressionNetParams, nil, args, "", @@ -456,7 +473,7 @@ func setupMiners(t *testing.T) (*rpctest.Harness, *rpctest.Harness) { ) require.NoError(t, err) - return miner1, miner2 + return miner2 } // setupBitcoind starts up a bitcoind node with either a zmq connection or @@ -474,6 +491,7 @@ func setupBitcoind(t *testing.T, minerAddr string, zmqTxHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqTxPort) rpcPort := port.NextAvailablePort() + p2pPort := port.NextAvailablePort() bitcoind := exec.Command( "bitcoind", "-datadir="+tempBitcoindDir, @@ -484,6 +502,7 @@ func setupBitcoind(t *testing.T, minerAddr string, "d$507c670e800a95284294edb5773b05544b"+ "220110063096c221be9933c82d38e1", fmt.Sprintf("-rpcport=%d", rpcPort), + fmt.Sprintf("-port=%d", p2pPort), "-disablewallet", "-zmqpubrawblock="+zmqBlockHost, "-zmqpubrawtx="+zmqTxHost, @@ -541,6 +560,12 @@ func setupBitcoind(t *testing.T, minerAddr string, btcClient.Stop() }) + // Wait for bitcoind to sync with the miner. + require.Eventually(t, func() bool { + _, height, err := btcClient.GetBestBlock() + return err == nil && height >= 101 + }, defaultTestTimeout, 100*time.Millisecond) + return btcClient } @@ -592,7 +617,8 @@ func testBitcoindClientGetCFilter(t *testing.T, miner *rpctest.Harness, ) return err == nil - }, 5*time.Second, 100*time.Millisecond, "GetCFilter should succeed") + }, defaultTestTimeout, 100*time.Millisecond, + "GetCFilter should succeed") require.NotNil(gcsFilter, "GCS filter should not be nil") require.IsType(&gcs.Filter{}, gcsFilter) @@ -622,3 +648,50 @@ func testBitcoindClientGetCFilter(t *testing.T, miner *rpctest.Harness, require.ErrorContains(err, "Block not found", "Non-existent block should return an error") } + +// waitForClientConnected waits for a ClientConnected notification on the passed +// channel. Any other notifications received while waiting are ignored. +func waitForClientConnected(t *testing.T, ntfns <-chan any) { + t.Helper() + + timer := time.NewTimer(defaultTestTimeout) + defer timer.Stop() + + for { + select { + case ntfn := <-ntfns: + if _, ok := ntfn.(ClientConnected); ok { + return + } + + case <-timer.C: + require.FailNow(t, "timed out for ClientConnected "+ + "notification") + } + } +} + +// waitForRelevantTx waits for a RelevantTx notification for the passed tx +// hash on the passed channel. Any other notifications received while waiting +// are ignored. +func waitForRelevantTx(t *testing.T, ntfns <-chan any, hash *chainhash.Hash) { + t.Helper() + + timer := time.NewTimer(defaultTestTimeout) + defer timer.Stop() + + for { + select { + case ntfn := <-ntfns: + if tx, ok := ntfn.(RelevantTx); ok { + if tx.TxRecord.Hash.IsEqual(hash) { + return + } + } + + case <-timer.C: + require.FailNow(t, "timed out waiting for RelevantTx "+ + "notification") + } + } +} From 41fd34208ccab32cc2861a70bfb0cfc48957a6f6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 18 Dec 2025 14:12:57 +0800 Subject: [PATCH 216/691] chain: make tests more verbose So it's easier to catch the flakes, also increased the timeout here. --- chain/pruned_block_dispatcher_test.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/chain/pruned_block_dispatcher_test.go b/chain/pruned_block_dispatcher_test.go index 990d0d3a63..cca5b9aa42 100644 --- a/chain/pruned_block_dispatcher_test.go +++ b/chain/pruned_block_dispatcher_test.go @@ -67,7 +67,7 @@ func newNetworkBlockTestHarness(t *testing.T, numBlocks, localConns: make(map[string]net.Conn, numPeers), remoteConns: make(map[string]net.Conn, numPeers), dialedPeer: make(chan string), - queriedPeer: make(chan struct{}), + queriedPeer: make(chan struct{}, numBlocks*numPeers), blocksQueried: make(map[chainhash.Hash]int), shouldReply: 0, } @@ -327,7 +327,7 @@ func (h *prunedBlockDispatcherHarness) disconnectPeer(addr string, fallback bool h.dispatcher.peerMtx.Lock() defer h.dispatcher.peerMtx.Unlock() return len(h.dispatcher.currentPeers) == numPeers-1 - }, time.Second, 200*time.Millisecond) + }, defaultTestTimeout, 200*time.Millisecond) // Reset the peer connection state to allow connections to them again. h.resetPeer(addr, fallback) @@ -339,7 +339,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerDialed() { select { case <-h.dialedPeer: - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatalf("expected peer to be dialed") } } @@ -351,7 +351,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerDialedWithAddr(addr string) { select { case dialedAddr := <-h.dialedPeer: require.Equal(h.t, addr, dialedAddr) - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatalf("expected peer to be dialed") } } @@ -362,7 +362,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerQueried() { select { case <-h.queriedPeer: - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatalf("expected a peer to be queried") } } @@ -395,7 +395,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerReplied( // We need to check the errChan after a timeout because when a request // was successful a nil error is signaled via the errChan and this // might happen even before the block is received. - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): select { case err := <-errChan: h.t.Fatalf("received unexpected error send: %v", err) @@ -415,7 +415,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerReplied( select { case err := <-errChan: require.NoError(h.t, err) - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatal("expected nil err to signal completion") } } @@ -446,7 +446,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerFailed( case err := <-cancelChan: require.ErrorIs(h.t, err, expectedErr) - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatalf("expected the error for the block request: %v", expectedErr) } @@ -461,7 +461,7 @@ func (h *prunedBlockDispatcherHarness) assertNoPeerDialed() { select { case peer := <-h.dialedPeer: h.t.Fatalf("unexpected connection established with peer %v", peer) - case <-time.After(2 * time.Second): + case <-time.After(1 * time.Second): } } @@ -482,7 +482,7 @@ func (h *prunedBlockDispatcherHarness) assertNoReply( h.t.Fatalf("received unexpected cancel request with error: %v", err) - case <-time.After(2 * time.Second): + case <-time.After(1 * time.Second): } } From deb096a2a5d4908f476f399ed2b863ee964d8768 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 22 Dec 2025 22:23:38 +0800 Subject: [PATCH 217/691] port: make sure max port can be reached --- chain/port/port.go | 60 ++++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/chain/port/port.go b/chain/port/port.go index 88fdb83614..59eab24adf 100644 --- a/chain/port/port.go +++ b/chain/port/port.go @@ -95,9 +95,33 @@ func NextAvailablePort() int { panic(fmt.Errorf("error parsing port: %w", err)) } - // We take the next one. - lastPort++ - for lastPort < maxPort { + // lastPort has reached the max allowed port, we start with the default + // node port. + if lastPort >= maxPort { + lastPort = defaultNodePort + } + + // Determine the first port to try. + nextPort := lastPort + 1 + + availablePort := findAvailablePort(nextPort) + + err = os.WriteFile( + portFile, []byte(strconv.Itoa(availablePort)), filePerms, + ) + if err != nil { + panic(fmt.Errorf("error updating port file: %w", err)) + } + + return availablePort +} + +// findAvailablePort searches for an available port starting from the given +// port. If it reaches the maximum port number, it wraps around to the default +// node port and continues searching until it has checked the entire range. +func findAvailablePort(startPort int) int { + currentPort := startPort + for { // If there are no errors while attempting to listen on this // port, close the socket and return it as available. While it // could be the case that some other process picks up this port @@ -105,29 +129,17 @@ func NextAvailablePort() int { // the harness node, in practice in CI servers this seems much // less likely than simply some other process already being // bound at the start of the tests. - addr := fmt.Sprintf(ListenerFormat, lastPort) + addr := fmt.Sprintf(ListenerFormat, currentPort) lc := &net.ListenConfig{} l, err := lc.Listen(context.Background(), "tcp4", addr) if err == nil { - err := l.Close() - if err == nil { - err := os.WriteFile( - portFile, - []byte(strconv.Itoa(lastPort)), - filePerms, - ) - if err != nil { - panic(fmt.Errorf("error updating "+ - "port file: %w", err)) - } - - return lastPort - } + _ = l.Close() + return currentPort } - lastPort++ + currentPort++ // Start from the beginning if we reached the end of the port // range. We need to do this because the lock file now is @@ -135,8 +147,14 @@ func NextAvailablePort() int { // boot/uptime cycle. So in order to make this work on // developer's machines, we need to reset the port to the // default value when we reach the end of the range. - if lastPort == maxPort { - lastPort = defaultNodePort + if currentPort > maxPort { + currentPort = defaultNodePort + } + + // If we reached the start port again, it means no ports are + // available. + if currentPort == startPort { + break } } From df04e3906d9df4436530ebbdd1b1f76d6bdfd7d2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 16 Dec 2025 14:01:15 +0800 Subject: [PATCH 218/691] chain: add batch RPCs in btcd client --- chain/btcd.go | 169 +++++++++++++++++++++++++++++++++++++++++++++ chain/btcd_test.go | 50 ++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/chain/btcd.go b/chain/btcd.go index 05a9c48640..6d62a52293 100644 --- a/chain/btcd.go +++ b/chain/btcd.go @@ -39,6 +39,8 @@ type RPCClient struct { wg sync.WaitGroup started bool quitMtx sync.Mutex + + batchClient *rpcclient.Client } // A compile-time check to ensure that RPCClient satisfies the chain.Interface @@ -91,7 +93,25 @@ func NewRPCClient(chainParams *chaincfg.Params, connect, user, pass string, cert if err != nil { return nil, err } + + batchConfig := *client.connConfig + + // The batch client is exclusively used for batch RPC calls, which + // require HTTP POST mode. Therefore, we explicitly set HTTPPostMode to + // true and clear the Endpoint field to ensure the batch client is + // correctly configured, regardless of the main client's WebSocket (ws) + // or HTTP POST configuration. + batchConfig.HTTPPostMode = true + batchConfig.Endpoint = "" + + batchClient, err := rpcclient.NewBatch(&batchConfig) + if err != nil { + return nil, fmt.Errorf("failed to create batch client: %w", err) + } + + client.batchClient = batchClient client.Client = rpcClient + return client, nil } @@ -194,7 +214,24 @@ func NewRPCClientWithConfig(cfg *RPCClientConfig) (*RPCClient, error) { return nil, err } + batchConfig := *cfg.Conn + + // The batch client is exclusively used for batch RPC calls, which + // require HTTP POST mode. Therefore, we explicitly set HTTPPostMode to + // true and clear the Endpoint field to ensure the batch client is + // correctly configured, regardless of the main client's WebSocket (ws) + // or HTTP POST configuration. + batchConfig.HTTPPostMode = true + batchConfig.Endpoint = "" + + batchClient, err := rpcclient.NewBatch(&batchConfig) + if err != nil { + return nil, fmt.Errorf("failed to create batch client: %w", err) + } + + client.batchClient = batchClient client.Client = rpcClient + return client, nil } @@ -244,6 +281,8 @@ func (c *RPCClient) Stop() { close(c.quit) c.Client.Shutdown() c.Client.WaitForShutdown() + c.batchClient.Shutdown() + c.batchClient.WaitForShutdown() if !c.started { close(c.dequeueNotification) @@ -664,3 +703,133 @@ func (c *RPCClient) SendRawTransaction(tx *wire.MsgTx, return txid, nil } + +// GetBlockHashes returns a slice of block hashes for the given height range. +func (c *RPCClient) GetBlockHashes(startHeight, + endHeight int64) ([]chainhash.Hash, error) { + + if startHeight > endHeight { + return nil, fmt.Errorf("%w: start height %d, end height %d", + ErrInvalidParam, startHeight, endHeight) + } + + count := endHeight - startHeight + 1 + hashes := make([]chainhash.Hash, 0, count) + futures := make([]rpcclient.FutureGetBlockHashResult, 0, count) + + for h := startHeight; h <= endHeight; h++ { + futures = append(futures, c.batchClient.GetBlockHashAsync(h)) + } + + err := c.batchClient.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + hash, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive block hash: %w", err) + } + + hashes = append(hashes, *hash) + } + + return hashes, nil +} + +// GetCFilters returns a slice of filters for the given block hashes. +func (c *RPCClient) GetCFilters(hashes []chainhash.Hash, + filterType wire.FilterType) ([]*gcs.Filter, error) { + + filters := make([]*gcs.Filter, 0, len(hashes)) + futures := make([]rpcclient.FutureGetCFilterResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append( + futures, + c.batchClient.GetCFilterAsync(&hash, filterType), + ) + } + + err := c.batchClient.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + msgFilter, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive cfilter: %w", err) + } + + filter, err := gcs.FromNBytes( + builder.DefaultP, builder.DefaultM, msgFilter.Data, + ) + if err != nil { + return nil, fmt.Errorf("parse cfilter: %w", err) + } + + filters = append(filters, filter) + } + + return filters, nil +} + +// GetBlocks returns a slice of full blocks for the given block hashes. +func (c *RPCClient) GetBlocks(hashes []chainhash.Hash) ( + []*wire.MsgBlock, error) { + + blocks := make([]*wire.MsgBlock, 0, len(hashes)) + futures := make([]rpcclient.FutureGetBlockResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append(futures, c.batchClient.GetBlockAsync(&hash)) + } + + err := c.batchClient.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + block, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive block: %w", err) + } + + blocks = append(blocks, block) + } + + return blocks, nil +} + +// GetBlockHeaders returns a slice of block headers for the given block hashes. +func (c *RPCClient) GetBlockHeaders(hashes []chainhash.Hash) ( + []*wire.BlockHeader, error) { + + headers := make([]*wire.BlockHeader, 0, len(hashes)) + futures := make([]rpcclient.FutureGetBlockHeaderResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append( + futures, c.batchClient.GetBlockHeaderAsync(&hash), + ) + } + + err := c.batchClient.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + header, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive header: %w", err) + } + + headers = append(headers, header) + } + + return headers, nil +} diff --git a/chain/btcd_test.go b/chain/btcd_test.go index 5d0abb575b..37b12c2859 100644 --- a/chain/btcd_test.go +++ b/chain/btcd_test.go @@ -1,13 +1,63 @@ package chain import ( + "fmt" "testing" + "time" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/integration/rpctest" "github.com/btcsuite/btcd/rpcclient" "github.com/stretchr/testify/require" ) +// setupBtcd starts up a btcd node with cfilters enabled and returns a client +// wrapper of this connection. +func setupBtcd(t *testing.T) (*rpctest.Harness, *RPCClient) { + t.Helper() + + trickle := fmt.Sprintf("--trickleinterval=%v", 10*time.Millisecond) + args := []string{trickle} + + miner, err := rpctest.New( + &chaincfg.RegressionNetParams, nil, args, "", + ) + require.NoError(t, err) + + require.NoError(t, miner.SetUp(true, 1)) + + t.Cleanup(func() { + require.NoError(t, miner.TearDown()) + }) + + rpcConf := miner.RPCConfig() + client, err := NewRPCClientWithConfig(&RPCClientConfig{ + ReconnectAttempts: 1, + Chain: &chaincfg.RegressionNetParams, + Conn: &rpcclient.ConnConfig{ + Host: rpcConf.Host, + User: rpcConf.User, + Pass: rpcConf.Pass, + Certificates: rpcConf.Certificates, + DisableTLS: false, + DisableAutoReconnect: false, + DisableConnectOnNew: true, + HTTPPostMode: false, + Endpoint: "ws", + }, + }) + require.NoError(t, err) + + err = client.Start(t.Context()) + require.NoError(t, err) + + t.Cleanup(func() { + client.Stop() + }) + + return miner, client +} + // TestValidateConfig checks the `validate` method on the RPCClientConfig // behaves as expected. func TestValidateConfig(t *testing.T) { From a922fe0701eb16bb1262105261132fda059a7123 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 16 Dec 2025 14:25:36 +0800 Subject: [PATCH 219/691] chain: add batch RPCs in bitcoind client --- chain/bitcoind_client.go | 167 ++++++++++++++++++++++++++++++++++ chain/bitcoind_conn.go | 22 ++++- chain/bitcoind_events_test.go | 3 +- 3 files changed, 188 insertions(+), 4 deletions(-) diff --git a/chain/bitcoind_client.go b/chain/bitcoind_client.go index 754050ee14..1bf55eadd4 100644 --- a/chain/bitcoind_client.go +++ b/chain/bitcoind_client.go @@ -16,6 +16,7 @@ import ( "github.com/btcsuite/btcd/btcutil/gcs" "github.com/btcsuite/btcd/btcutil/gcs/builder" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -56,6 +57,15 @@ type BitcoindClient struct { // the RPC and ZMQ connections to a bitcoind node. chainConn *BitcoindConn + // batchClient is a secondary RPC client dedicated for batch requests. + // This client is created specifically for batch operations because the + // rpcclient.Client in batch mode is stateful, accumulating requests + // until `Send()` is called. Using a dedicated instance avoids race + // conditions and ensures isolation from other concurrent RPC calls + // made by the main `chainConn.client` or other `BitcoindClient` + // instances. + batchClient *rpcclient.Client + // bestBlock keeps track of the tip of the current best chain. bestBlockMtx sync.RWMutex bestBlock waddrmgr.BlockStamp @@ -604,6 +614,9 @@ func (c *BitcoindClient) Stop() { // prevent sending notifications to it after it's been stopped. c.chainConn.RemoveClient(c.id) + c.batchClient.Shutdown() + c.batchClient.WaitForShutdown() + c.notificationQueue.Stop() } @@ -1447,3 +1460,157 @@ func (c *BitcoindClient) updateWatchedFilters(update any) { } } } + +// GetBlockHashes returns a slice of block hashes for the given height range. +func (c *BitcoindClient) GetBlockHashes(startHeight, + endHeight int64) ([]chainhash.Hash, error) { + + if startHeight > endHeight { + return nil, fmt.Errorf("%w: start height %d, end height %d", + ErrInvalidParam, startHeight, endHeight) + } + + client := c.batchClient + count := endHeight - startHeight + 1 + hashes := make([]chainhash.Hash, 0, count) + futures := make([]rpcclient.FutureGetBlockHashResult, 0, count) + + for h := startHeight; h <= endHeight; h++ { + futures = append(futures, client.GetBlockHashAsync(h)) + } + + err := client.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + hash, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive block hash: %w", err) + } + + hashes = append(hashes, *hash) + } + + return hashes, nil +} + +// GetCFilters returns a slice of filters for the given block hashes. +func (c *BitcoindClient) GetCFilters(hashes []chainhash.Hash, + filterType wire.FilterType) ([]*gcs.Filter, error) { + + if filterType != wire.GCSFilterRegular { + return nil, ErrOnlyBasicFilters + } + + client := c.batchClient + filters := make([]*gcs.Filter, 0, len(hashes)) + futures := make([]rpcclient.FutureRawResult, 0, len(hashes)) + + for _, hash := range hashes { + params := []json.RawMessage{ + json.RawMessage(fmt.Sprintf("%q", hash.String())), + json.RawMessage(fmt.Sprintf("%q", "basic")), + } + futures = append(futures, client.RawRequestAsync( + "getblockfilter", params, + )) + } + + err := client.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + resp, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive cfilter: %w", err) + } + + var res struct { + Filter string `json:"filter"` + Header string `json:"header"` + } + + err = json.Unmarshal(resp, &res) + if err != nil { + return nil, fmt.Errorf("unmarshal cfilter: %w", err) + } + + filterBytes, err := hex.DecodeString(res.Filter) + if err != nil { + return nil, fmt.Errorf("decode cfilter: %w", err) + } + + filter, err := gcs.FromNBytes( + builder.DefaultP, builder.DefaultM, filterBytes, + ) + if err != nil { + return nil, fmt.Errorf("parse cfilter: %w", err) + } + + filters = append(filters, filter) + } + + return filters, nil +} + +// GetBlocks returns a slice of full blocks for the given block hashes. +func (c *BitcoindClient) GetBlocks(hashes []chainhash.Hash) ( + []*wire.MsgBlock, error) { + + client := c.batchClient + blocks := make([]*wire.MsgBlock, 0, len(hashes)) + futures := make([]rpcclient.FutureGetBlockResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append(futures, client.GetBlockAsync(&hash)) + } + + err := client.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + block, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive block: %w", err) + } + + blocks = append(blocks, block) + } + + return blocks, nil +} + +// GetBlockHeaders returns a slice of block headers for the given block hashes. +func (c *BitcoindClient) GetBlockHeaders(hashes []chainhash.Hash) ( + []*wire.BlockHeader, error) { + + client := c.batchClient + headers := make([]*wire.BlockHeader, 0, len(hashes)) + futures := make([]rpcclient.FutureGetBlockHeaderResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append(futures, client.GetBlockHeaderAsync(&hash)) + } + + err := client.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + header, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive header: %w", err) + } + + headers = append(headers, header) + } + + return headers, nil +} diff --git a/chain/bitcoind_conn.go b/chain/bitcoind_conn.go index a03da9242e..12b0459653 100644 --- a/chain/bitcoind_conn.go +++ b/chain/bitcoind_conn.go @@ -401,13 +401,29 @@ func getCurrentNet(client *rpcclient.Client) (wire.BitcoinNet, error) { // NewBitcoindClient returns a bitcoind client using the current bitcoind // connection. This allows us to share the same connection using multiple // clients. -func (c *BitcoindConn) NewBitcoindClient() *BitcoindClient { +func (c *BitcoindConn) NewBitcoindClient() (*BitcoindClient, error) { + clientCfg := &rpcclient.ConnConfig{ + Host: c.cfg.Host, + User: c.cfg.User, + Pass: c.cfg.Pass, + DisableAutoReconnect: false, + DisableConnectOnNew: true, + DisableTLS: true, + HTTPPostMode: true, + } + + batchClient, err := rpcclient.NewBatch(clientCfg) + if err != nil { + return nil, fmt.Errorf("unable to create batch client: %w", err) + } + return &BitcoindClient{ quit: make(chan struct{}), id: atomic.AddUint64(&c.rescanClientCounter, 1), - chainConn: c, + chainConn: c, + batchClient: batchClient, watchedAddresses: make(map[string]struct{}), watchedOutPoints: make(map[wire.OutPoint]struct{}), @@ -419,7 +435,7 @@ func (c *BitcoindConn) NewBitcoindClient() *BitcoindClient { mempool: make(map[chainhash.Hash]struct{}), expiredMempool: make(map[int32]map[chainhash.Hash]struct{}), - } + }, nil } // AddClient adds a client to the set of active rescan clients of the current diff --git a/chain/bitcoind_events_test.go b/chain/bitcoind_events_test.go index 6982e1cd6b..208b078f56 100644 --- a/chain/bitcoind_events_test.go +++ b/chain/bitcoind_events_test.go @@ -553,7 +553,8 @@ func setupBitcoind(t *testing.T, minerAddr string, }) // Create a bitcoind client. - btcClient := chainConn.NewBitcoindClient() + btcClient, err := chainConn.NewBitcoindClient() + require.NoError(t, err) require.NoError(t, btcClient.Start(t.Context())) t.Cleanup(func() { From 52c3e218323d73f5f65502bc5887c6666b9d427e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 16 Dec 2025 14:01:50 +0800 Subject: [PATCH 220/691] chain: add batch RPCs in neutrino client --- chain/mocks_test.go | 93 +++++++++++++++++++++-------------- chain/neutrino.go | 79 +++++++++++++++++++++++++++++ chain/neutrino_test.go | 109 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 239 insertions(+), 42 deletions(-) diff --git a/chain/mocks_test.go b/chain/mocks_test.go index 81c44b20c9..3e99e4972e 100644 --- a/chain/mocks_test.go +++ b/chain/mocks_test.go @@ -67,96 +67,115 @@ func (m *mockRescanner) WaitForShutdown() { // mockChainService is a mock implementation of a chain service for use in // tests. Only the Start, GetBlockHeader and BestBlock methods are implemented. type mockChainService struct { + mock.Mock } func (m *mockChainService) Start(_ context.Context) error { - return nil + args := m.Called() + return args.Error(0) } func (m *mockChainService) BestBlock() (*headerfs.BlockStamp, error) { - return testBestBlock, nil + args := m.Called() + return args.Get(0).(*headerfs.BlockStamp), args.Error(1) } func (m *mockChainService) GetBlockHeader( - *chainhash.Hash) (*wire.BlockHeader, error) { + hash *chainhash.Hash) (*wire.BlockHeader, error) { - return &wire.BlockHeader{}, nil + args := m.Called(hash) + return args.Get(0).(*wire.BlockHeader), args.Error(1) } -func (m *mockChainService) GetBlock(chainhash.Hash, - ...neutrino.QueryOption) (*btcutil.Block, error) { +func (m *mockChainService) GetBlock( + hash chainhash.Hash, + options ...neutrino.QueryOption) (*btcutil.Block, error) { - return nil, errNotImplemented + args := m.Called(hash, options) + return args.Get(0).(*btcutil.Block), args.Error(1) } -func (m *mockChainService) GetBlockHeight(*chainhash.Hash) (int32, error) { - return 0, errNotImplemented +func (m *mockChainService) GetBlockHeight(hash *chainhash.Hash) (int32, error) { + args := m.Called(hash) + return args.Get(0).(int32), args.Error(1) } -func (m *mockChainService) GetBlockHash(int64) (*chainhash.Hash, error) { - return nil, errNotImplemented +func (m *mockChainService) GetBlockHash(height int64) (*chainhash.Hash, error) { + args := m.Called(height) + return args.Get(0).(*chainhash.Hash), args.Error(1) } func (m *mockChainService) IsCurrent() bool { - return false + args := m.Called() + return args.Bool(0) } -func (m *mockChainService) SendTransaction(*wire.MsgTx) error { - return errNotImplemented +func (m *mockChainService) SendTransaction(tx *wire.MsgTx) error { + args := m.Called(tx) + return args.Error(0) } -func (m *mockChainService) GetCFilter(chainhash.Hash, - wire.FilterType, ...neutrino.QueryOption) (*gcs.Filter, error) { +func (m *mockChainService) GetCFilter( + hash chainhash.Hash, filterType wire.FilterType, + options ...neutrino.QueryOption) (*gcs.Filter, error) { - return nil, errNotImplemented + args := m.Called(hash, filterType, options) + return args.Get(0).(*gcs.Filter), args.Error(1) } func (m *mockChainService) GetUtxo( - _ ...neutrino.RescanOption) (*neutrino.SpendReport, error) { + opts ...neutrino.RescanOption) (*neutrino.SpendReport, error) { - return nil, errNotImplemented + args := m.Called(opts) + return args.Get(0).(*neutrino.SpendReport), args.Error(1) } -func (m *mockChainService) BanPeer(string, banman.Reason) error { - return errNotImplemented +func (m *mockChainService) BanPeer(addr string, reason banman.Reason) error { + args := m.Called(addr, reason) + return args.Error(0) } func (m *mockChainService) IsBanned(addr string) bool { - panic(errNotImplemented) + args := m.Called(addr) + return args.Bool(0) } -func (m *mockChainService) AddPeer(*neutrino.ServerPeer) { - panic(errNotImplemented) +func (m *mockChainService) AddPeer(peer *neutrino.ServerPeer) { + m.Called(peer) } -func (m *mockChainService) AddBytesSent(uint64) { - panic(errNotImplemented) +func (m *mockChainService) AddBytesSent(bytes uint64) { + m.Called(bytes) } -func (m *mockChainService) AddBytesReceived(uint64) { - panic(errNotImplemented) +func (m *mockChainService) AddBytesReceived(bytes uint64) { + m.Called(bytes) } func (m *mockChainService) NetTotals() (uint64, uint64) { - panic(errNotImplemented) + args := m.Called() + return args.Get(0).(uint64), args.Get(1).(uint64) } -func (m *mockChainService) UpdatePeerHeights(*chainhash.Hash, - int32, *neutrino.ServerPeer, -) { - panic(errNotImplemented) +func (m *mockChainService) UpdatePeerHeights(hash *chainhash.Hash, + height int32, peer *neutrino.ServerPeer) { + + m.Called(hash, height, peer) } func (m *mockChainService) ChainParams() chaincfg.Params { - panic(errNotImplemented) + args := m.Called() + return args.Get(0).(chaincfg.Params) } func (m *mockChainService) Stop() error { - panic(errNotImplemented) + args := m.Called() + return args.Error(0) } -func (m *mockChainService) PeerByAddr(string) *neutrino.ServerPeer { - panic(errNotImplemented) +func (m *mockChainService) PeerByAddr(addr string) *neutrino.ServerPeer { + args := m.Called(addr) + return args.Get(0).(*neutrino.ServerPeer) } // mockRPCClient mocks the rpcClient interface. diff --git a/chain/neutrino.go b/chain/neutrino.go index 12cb26d5ad..2368da70f7 100644 --- a/chain/neutrino.go +++ b/chain/neutrino.go @@ -581,6 +581,85 @@ func (s *NeutrinoClient) SetStartTime(startTime time.Time) { s.startTime = startTime } +// GetBlockHashes returns a slice of block hashes for the given height range. +func (s *NeutrinoClient) GetBlockHashes(startHeight, endHeight int64) ( + []chainhash.Hash, error) { + + if startHeight > endHeight { + return nil, fmt.Errorf("%w: start height %d, end height %d", + ErrInvalidParam, startHeight, endHeight) + } + + count := endHeight - startHeight + 1 + + hashes := make([]chainhash.Hash, 0, count) + + for h := startHeight; h <= endHeight; h++ { + hash, err := s.CS.GetBlockHash(h) + if err != nil { + return nil, fmt.Errorf("get block hash: %w", err) + } + + hashes = append(hashes, *hash) + } + + return hashes, nil +} + +// GetCFilters returns a slice of filters for the given block hashes. +func (s *NeutrinoClient) GetCFilters(hashes []chainhash.Hash, + filterType wire.FilterType) ([]*gcs.Filter, error) { + + filters := make([]*gcs.Filter, 0, len(hashes)) + + for _, hash := range hashes { + filter, err := s.CS.GetCFilter(hash, filterType) + if err != nil { + return nil, fmt.Errorf("get cfilter: %w", err) + } + + filters = append(filters, filter) + } + + return filters, nil +} + +// GetBlocks returns a slice of full blocks for the given block hashes. +func (s *NeutrinoClient) GetBlocks(hashes []chainhash.Hash) ( + []*wire.MsgBlock, error) { + + blocks := make([]*wire.MsgBlock, 0, len(hashes)) + + for _, hash := range hashes { + block, err := s.CS.GetBlock(hash) + if err != nil { + return nil, fmt.Errorf("get block: %w", err) + } + + blocks = append(blocks, block.MsgBlock()) + } + + return blocks, nil +} + +// GetBlockHeaders returns a slice of block headers for the given block hashes. +func (s *NeutrinoClient) GetBlockHeaders(hashes []chainhash.Hash) ( + []*wire.BlockHeader, error) { + + headers := make([]*wire.BlockHeader, 0, len(hashes)) + + for _, hash := range hashes { + header, err := s.CS.GetBlockHeader(&hash) + if err != nil { + return nil, fmt.Errorf("get block header: %w", err) + } + + headers = append(headers, header) + } + + return headers, nil +} + // onFilteredBlockConnected sends appropriate notifications to the notification // channel. func (s *NeutrinoClient) onFilteredBlockConnected(height int32, diff --git a/chain/neutrino_test.go b/chain/neutrino_test.go index 88d55f0357..fec5be6330 100644 --- a/chain/neutrino_test.go +++ b/chain/neutrino_test.go @@ -7,14 +7,85 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/neutrino/headerfs" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) // maxDur is the max duration a test has to execute successfully. var maxDur = 5 * time.Second +// TestNeutrinoClientBatchFetch verifies that the batch fetching methods +// correctly loop over the range/list and call the underlying service. +func TestNeutrinoClientBatchFetch(t *testing.T) { + t.Parallel() + + nc := newMockNeutrinoClient() + mockCS, ok := nc.CS.(*mockChainService) + require.True(t, ok) + + // Clear default expectations set in newMockNeutrinoClient so we can + // set strict expectations for this test. + + // Test GetBlockHashes + startHeight := int64(100) + endHeight := int64(102) + hash1 := chainhash.Hash{1} + hash2 := chainhash.Hash{2} + hash3 := chainhash.Hash{3} + + mockCS.On("GetBlockHash", int64(100)).Return(&hash1, nil).Once() + mockCS.On("GetBlockHash", int64(101)).Return(&hash2, nil).Once() + mockCS.On("GetBlockHash", int64(102)).Return(&hash3, nil).Once() + + hashes, err := nc.GetBlockHashes(startHeight, endHeight) + require.NoError(t, err) + require.Len(t, hashes, 3) + require.Equal(t, hash1, hashes[0]) + require.Equal(t, hash2, hashes[1]) + require.Equal(t, hash3, hashes[2]) + + // Test GetCFilters + filterType := wire.GCSFilterRegular + filter1 := &gcs.Filter{} // Empty filter + mockCS.On("GetCFilter", hash1, filterType, mock.Anything). + Return(filter1, nil).Once() + mockCS.On("GetCFilter", hash2, filterType, mock.Anything). + Return(filter1, nil).Once() + mockCS.On("GetCFilter", hash3, filterType, mock.Anything). + Return(filter1, nil).Once() + + filters, err := nc.GetCFilters(hashes, filterType) + require.NoError(t, err) + require.Len(t, filters, 3) + + // Test GetBlocks + block1 := btcutil.NewBlock(&wire.MsgBlock{}) + mockCS.On("GetBlock", hash1, mock.Anything).Return(block1, nil).Once() + mockCS.On("GetBlock", hash2, mock.Anything).Return(block1, nil).Once() + mockCS.On("GetBlock", hash3, mock.Anything).Return(block1, nil).Once() + + blocks, err := nc.GetBlocks(hashes) + require.NoError(t, err) + require.Len(t, blocks, 3) + + // Test GetBlockHeaders + header1 := &wire.BlockHeader{} + mockCS.On("GetBlockHeader", &hash1).Return(header1, nil).Once() + mockCS.On("GetBlockHeader", &hash2).Return(header1, nil).Once() + mockCS.On("GetBlockHeader", &hash3).Return(header1, nil).Once() + + headers, err := nc.GetBlockHeaders(hashes) + require.NoError(t, err) + require.Len(t, headers, 3) + + mockCS.AssertExpectations(t) +} + // TestNeutrinoClientSequentialStartStop ensures that the client // can sequentially Start and Stop without errors or races. func TestNeutrinoClientSequentialStartStop(t *testing.T) { @@ -23,6 +94,18 @@ func TestNeutrinoClientSequentialStartStop(t *testing.T) { wantRestarts = 50 ) + mockCS, ok := nc.CS.(*mockChainService) + require.True(t, ok) + + testBestBlock := &headerfs.BlockStamp{ + Hash: chainhash.Hash(make([]byte, 32)), + Height: 1, + } + + mockCS.On("Start").Return(nil).Times(wantRestarts) + mockCS.On("Stop").Return(nil).Times(wantRestarts) + mockCS.On("BestBlock").Return(testBestBlock, nil).Maybe() + // callStartStop starts the neutrino client, requires no error on // startup, immediately stops the client and waits for shutdown. // The returned channel is closed once shutdown is complete. @@ -118,13 +201,29 @@ func TestNeutrinoClientNotifyReceivedRescan(t *testing.T) { gotMsgs = 0 msgCh = make(chan string, wantMsgs) msgPrefix = "successfully called" - - // sendMsg writes a message to the buffered message channel. - sendMsg = func(s string) { - msgCh <- fmt.Sprintf("%s %s", msgPrefix, s) - } ) + mockCS, ok := nc.CS.(*mockChainService) + require.True(t, ok) + + testBestBlock := &headerfs.BlockStamp{ + Hash: chainhash.Hash(make([]byte, 32)), + Height: 1, + } + + testBlockHeader := &wire.BlockHeader{Timestamp: time.Unix(1, 0)} + + mockCS.On("Start").Return(nil).Once() + mockCS.On("Stop").Return(nil).Once() + mockCS.On("BestBlock").Return(testBestBlock, nil).Maybe() + mockCS.On("GetBlockHeader", mock.Anything). + Return(testBlockHeader, nil).Maybe() + + // sendMsg writes a message to the buffered message channel. + sendMsg := func(s string) { + msgCh <- fmt.Sprintf("%s %s", msgPrefix, s) + } + // Define closures to wrap desired neutrino client method calls. // cleanup is the shared cleanup function for a closure executing From 2a4105cb36e9f8032f21cc92f18d1150b2085421 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 16 Dec 2025 14:02:42 +0800 Subject: [PATCH 221/691] chain+wallet: add batch RPCs on `chain.Interface` We also patch unit tests for `btcd` and `bitcoind` clients. --- chain/bitcoind_events_test.go | 9 ++++ chain/btcd_test.go | 88 +++++++++++++++++++++++++++++++++++ chain/interface.go | 26 +++++++++++ wallet/chain_mock_test.go | 24 ++++++++++ wallet/mock_test.go | 24 ++++++++++ 5 files changed, 171 insertions(+) diff --git a/chain/bitcoind_events_test.go b/chain/bitcoind_events_test.go index 208b078f56..c623eff6c4 100644 --- a/chain/bitcoind_events_test.go +++ b/chain/bitcoind_events_test.go @@ -83,6 +83,15 @@ func runBitcoindEventsTests(t *testing.T, rpcPolling bool) { name: "GetCFilter", testFn: testBitcoindClientGetCFilter, }, + { + name: "Batch RPCs", + testFn: func(t *testing.T, h *rpctest.Harness, + bc *BitcoindClient) { + + t.Helper() + testInterfaceBatchMethods(t, h, bc) + }, + }, } for _, test := range tests { diff --git a/chain/btcd_test.go b/chain/btcd_test.go index 37b12c2859..63aa611e5a 100644 --- a/chain/btcd_test.go +++ b/chain/btcd_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/integration/rpctest" "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/wire" "github.com/stretchr/testify/require" ) @@ -106,3 +107,90 @@ func TestValidateConfig(t *testing.T) { _, err := NewRPCClientWithConfig(nil) rt.ErrorContains(err, "missing rpc config") } + +// testInterfaceBatchMethods verifies the batch fetching methods implementation +// for a given chain.Interface client. +func testInterfaceBatchMethods(t *testing.T, miner *rpctest.Harness, + client Interface) { + + t.Helper() + + require := require.New(t) + + // Generate blocks to have a chain to query. + const numBlocks = 5 + + _, err := miner.Client.Generate(numBlocks) + require.NoError(err) + + // Test GetBlockHashes. + // Query from height 1 to 3. + startHeight := int64(1) + endHeight := int64(3) + hashes, err := client.GetBlockHashes(startHeight, endHeight) + require.NoError(err, "GetBlockHashes failed") + require.Len(hashes, 3) + + // Verify hashes match miner. + for i, hash := range hashes { + minerHash, err := miner.Client.GetBlockHash(int64(i) + 1) + require.NoError(err) + require.Equal(*minerHash, hash) + } + + // Test GetBlocks. + blocks, err := client.GetBlocks(hashes) + require.NoError(err, "GetBlocks failed") + require.Len(blocks, 3) + + for i, block := range blocks { + require.Equal(hashes[i], block.BlockHash()) + } + + // Test GetBlockHeaders. + headers, err := client.GetBlockHeaders(hashes) + require.NoError(err, "GetBlockHeaders failed") + require.Len(headers, 3) + + for i, header := range headers { + require.Equal(hashes[i], header.BlockHash()) + } + + // Test GetCFilters. + // Note: bitcoind needs -blockfilterindex=1 for this to work, which is + // set in setupBitcoind. + // We use Eventually because filter indexing is asynchronous. + require.Eventually(func() bool { + filters, err := client.GetCFilters( + hashes, wire.GCSFilterRegular, + ) + if err != nil { + return false + } + + if len(filters) != 3 { + return false + } + // Verify filters are not empty/nil. + for _, f := range filters { + if f == nil || f.N() == 0 { + return false + } + } + + return true + }, defaultTestTimeout, 100*time.Millisecond, + "GetCFilters failed or timed out") +} + +// TestRPCClientBatchMethods verifies the RPCClient's batch fetching methods +// implementation against a live btcd node. +func TestRPCClientBatchMethods(t *testing.T) { + t.Parallel() + + // Set up a miner (btcd node) and client. + miner, client := setupBtcd(t) + + // Run batch method tests. + testInterfaceBatchMethods(t, miner, client) +} diff --git a/chain/interface.go b/chain/interface.go index 7dbe791e70..81f2e81eae 100644 --- a/chain/interface.go +++ b/chain/interface.go @@ -54,6 +54,32 @@ type Interface interface { BackEnd() string TestMempoolAccept([]*wire.MsgTx, float64) ([]*btcjson.TestMempoolAcceptResult, error) MapRPCErr(err error) error + + // Batching methods for optimized scanning. + // + // GetBlockHashes returns a slice of block hashes for the given height + // range (inclusive). + // + // NOTE: This is a batching method, designed for optimized scanning. + GetBlockHashes(startHeight, endHeight int64) ([]chainhash.Hash, error) + + // GetCFilters returns a slice of compact filters for the given block + // hashes and filter type. + // + // NOTE: This is a batching method, designed for optimized scanning. + GetCFilters(hashes []chainhash.Hash, + filterType wire.FilterType) ([]*gcs.Filter, error) + + // GetBlocks returns a slice of full blocks for the given block hashes. + // + // NOTE: This is a batching method, designed for optimized scanning. + GetBlocks(hashes []chainhash.Hash) ([]*wire.MsgBlock, error) + + // GetBlockHeaders returns a slice of block headers for the given block + // hashes. + // + // NOTE: This is a batching method, designed for optimized scanning. + GetBlockHeaders(hashes []chainhash.Hash) ([]*wire.BlockHeader, error) } // Notification types. These are defined here and processed from from reading diff --git a/wallet/chain_mock_test.go b/wallet/chain_mock_test.go index cf57c36e8c..03936c096a 100644 --- a/wallet/chain_mock_test.go +++ b/wallet/chain_mock_test.go @@ -90,6 +90,30 @@ func (m *mockChainClient) GetBlockHeader(*chainhash.Hash) (*wire.BlockHeader, return m.getBlockHeader, nil } +func (m *mockChainClient) GetBlockHashes(int64, + int64) ([]chainhash.Hash, error) { + + return nil, ErrNotImplemented +} + +func (m *mockChainClient) GetBlockHeaders( + []chainhash.Hash) ([]*wire.BlockHeader, error) { + + return nil, ErrNotImplemented +} + +func (m *mockChainClient) GetCFilters([]chainhash.Hash, wire.FilterType) ( + []*gcs.Filter, error) { + + return nil, ErrNotImplemented +} + +func (m *mockChainClient) GetBlocks( + []chainhash.Hash) ([]*wire.MsgBlock, error) { + + return nil, ErrNotImplemented +} + func (m *mockChainClient) GetMempool() (map[chainhash.Hash]*wire.MsgTx, error) { // Acquire read lock non-exclusively. It allows concurrent readers and // blocks writers. diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 198535c794..b4206d22f1 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -852,6 +852,30 @@ func (m *mockChain) GetBlockHeader( return header, args.Error(1) } +func (m *mockChain) GetBlockHashes(int64, int64) ([]chainhash.Hash, error) { + args := m.Called() + return args.Get(0).([]chainhash.Hash), args.Error(1) +} + +func (m *mockChain) GetBlockHeaders( + []chainhash.Hash) ([]*wire.BlockHeader, error) { + + args := m.Called() + return args.Get(0).([]*wire.BlockHeader), args.Error(1) +} + +func (m *mockChain) GetCFilters([]chainhash.Hash, wire.FilterType) ( + []*gcs.Filter, error) { + + args := m.Called() + return args.Get(0).([]*gcs.Filter), args.Error(1) +} + +func (m *mockChain) GetBlocks([]chainhash.Hash) ([]*wire.MsgBlock, error) { + args := m.Called() + return args.Get(0).([]*wire.MsgBlock), args.Error(1) +} + // IsCurrent implements the chain.Interface interface. func (m *mockChain) IsCurrent() bool { args := m.Called() From 2efb1c19af8e124037af66c13ffc6dcd616239c0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 15 Dec 2025 15:28:41 +0800 Subject: [PATCH 222/691] wtxmgr+wallet: add method `InsertConfirmedTx` --- wallet/mock_test.go | 9 +++++++++ wtxmgr/interface.go | 5 +++++ wtxmgr/tx.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index b4206d22f1..708e1195b2 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -72,6 +72,15 @@ func (m *mockTxStore) InsertTxCheckIfExists(ns walletdb.ReadWriteBucket, return args.Bool(0), args.Error(1) } +// InsertConfirmedTx implements the wtxmgr.TxStore interface. +func (m *mockTxStore) InsertConfirmedTx(ns walletdb.ReadWriteBucket, + rec *wtxmgr.TxRecord, block *wtxmgr.BlockMeta, + credits []wtxmgr.CreditEntry) error { + + args := m.Called(ns, rec, block, credits) + return args.Error(0) +} + // AddCredit implements the wtxmgr.TxStore interface. func (m *mockTxStore) AddCredit(ns walletdb.ReadWriteBucket, rec *wtxmgr.TxRecord, block *wtxmgr.BlockMeta, index uint32, diff --git a/wtxmgr/interface.go b/wtxmgr/interface.go index 08b90a8c78..1b140c1169 100644 --- a/wtxmgr/interface.go +++ b/wtxmgr/interface.go @@ -76,6 +76,11 @@ type TxStore interface { InsertTxCheckIfExists(ns walletdb.ReadWriteBucket, rec *TxRecord, block *BlockMeta) (bool, error) + // InsertConfirmedTx records a mined transaction and its associated + // credits in a single operation. + InsertConfirmedTx(ns walletdb.ReadWriteBucket, rec *TxRecord, + block *BlockMeta, credits []CreditEntry) error + // AddCredit marks a transaction record as containing a transaction // output spendable by wallet. The output is added unspent, and is // marked spent when a new transaction spending the output is inserted diff --git a/wtxmgr/tx.go b/wtxmgr/tx.go index c6f2112051..f84d303b1b 100644 --- a/wtxmgr/tx.go +++ b/wtxmgr/tx.go @@ -134,6 +134,13 @@ type LockedOutput struct { Expiration time.Time } +// CreditEntry specifies a transaction output that should be recorded as a +// credit (spendable output) for the wallet. +type CreditEntry struct { + Index uint32 + Change bool +} + // NewTxRecord creates a new transaction record that may be inserted into the // store. It uses memoization to save the transaction hash and the serialized // transaction. @@ -389,6 +396,28 @@ func (s *Store) InsertTxCheckIfExists(ns walletdb.ReadWriteBucket, return false, err } +// InsertConfirmedTx records a mined transaction and its associated credits in +// a single operation. This is more efficient than calling InsertTx followed by +// AddCredit for each output. +func (s *Store) InsertConfirmedTx(ns walletdb.ReadWriteBucket, rec *TxRecord, + block *BlockMeta, credits []CreditEntry) error { + + if err := s.insertMinedTx(ns, rec, block); err != nil && err != ErrDuplicateTx { + return err + } + + for _, c := range credits { + isNew, err := s.addCredit(ns, rec, block, c.Index, c.Change) + if err != nil { + return err + } + if isNew && s.NotifyUnspent != nil { + s.NotifyUnspent(&rec.Hash, c.Index) + } + } + return nil +} + // RemoveUnminedTx attempts to remove an unmined transaction from the // transaction store. This is to be used in the scenario that a transaction // that we attempt to rebroadcast, turns out to double spend one of our From e939738a1d858f433ed03ae5ed1e0b9883a5705c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 15 Dec 2025 15:22:33 +0800 Subject: [PATCH 223/691] waddrmgr+wallet: add more interface methods --- waddrmgr/interface.go | 14 ++++++ waddrmgr/scoped_manager.go | 86 +++++++++++++++++++++++++++++++++ waddrmgr/scoped_manager_test.go | 2 +- wallet/mock_test.go | 33 +++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) diff --git a/waddrmgr/interface.go b/waddrmgr/interface.go index 64aef555cc..e6583e15fc 100644 --- a/waddrmgr/interface.go +++ b/waddrmgr/interface.go @@ -228,6 +228,11 @@ type AccountStore interface { ExtendInternalAddresses(ns walletdb.ReadWriteBucket, account uint32, count uint32) error + // ExtendAddresses ensures that all valid keys through lastIndex are + // derived and stored in the wallet for the specified branch. + ExtendAddresses(ns walletdb.ReadWriteBucket, account uint32, + lastIndex uint32, branch uint32) error + // MarkUsed updates the used flag for the provided address. MarkUsed(ns walletdb.ReadWriteBucket, address btcutil.Address) error @@ -305,4 +310,13 @@ type AccountStore interface { // ImportScript imports a script. ImportScript(ns walletdb.ReadWriteBucket, script []byte, bs *BlockStamp) (ManagedScriptAddress, error) + + // ActiveAccounts returns the account numbers of all accounts currently + // loaded in memory. + ActiveAccounts() []uint32 + + // DeriveAddr derives a single address for the given account, branch, + // and index. + DeriveAddr(account uint32, branch uint32, index uint32) ( + btcutil.Address, []byte, error) } diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 61d8d69342..510e4344c2 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -131,6 +131,60 @@ func (k KeyScope) String() string { return fmt.Sprintf("m/%v'/%v'", k.Purpose, k.Coin) } +// AccountScope uniquely identifies a specific account within a key scope. +type AccountScope struct { + // Scope is the BIP44 account' used to derive the child key. + Scope KeyScope + + // Account is the account number. + Account uint32 +} + +// String returns a human readable version describing the account scope. +func (as AccountScope) String() string { + return fmt.Sprintf("%s/%d'", as.Scope, as.Account) +} + +// BranchScope uniquely identifies a specific derivation branch within an +// account. +type BranchScope struct { + // Scope is the key scope of the branch. + Scope KeyScope + + // Account is the account number of the branch. + Account uint32 + + // Branch is the branch number (e.g. waddrmgr.ExternalBranch or + // waddrmgr.InternalBranch). + Branch uint32 +} + +// String returns a human readable version describing the branch scope. +func (bs BranchScope) String() string { + return fmt.Sprintf("%s/%d/%d'", bs.Scope, bs.Account, bs.Branch) +} + +// IsChange returns true if the branch matches the internal (change) branch. +func (bs BranchScope) IsChange() bool { + return bs.Branch == InternalBranch +} + +// AddrScope uniquely identifies a specific address within a derivation branch. +type AddrScope struct { + BranchScope BranchScope + Index uint32 +} + +// String returns a human readable version describing the address scope. +func (as AddrScope) String() string { + return fmt.Sprintf("%s/%d", as.BranchScope, as.Index) +} + +// IsChange returns true if the address belongs to an internal (change) branch. +func (as AddrScope) IsChange() bool { + return as.BranchScope.IsChange() +} + // Identity is a closure that returns the identifier of an address. type Identity func() []byte @@ -307,6 +361,20 @@ type ScopedKeyManager struct { // AccountStore interface. var _ AccountStore = (*ScopedKeyManager)(nil) +// ActiveAccounts returns the account numbers of all accounts currently loaded +// in memory. +func (s *ScopedKeyManager) ActiveAccounts() []uint32 { + s.mtx.RLock() + defer s.mtx.RUnlock() + + accounts := make([]uint32, 0, len(s.acctInfo)) + for account := range s.acctInfo { + accounts = append(accounts, account) + } + + return accounts +} + // Scope returns the exact KeyScope of this scoped key manager. func (s *ScopedKeyManager) Scope() KeyScope { return s.scope @@ -1449,6 +1517,24 @@ func (s *ScopedKeyManager) extendAddresses(ns walletdb.ReadWriteBucket, return nil } +// ExtendAddresses ensures that all valid keys through lastIndex are +// derived and stored in the wallet for the specified branch. +func (s *ScopedKeyManager) ExtendAddresses(ns walletdb.ReadWriteBucket, + account uint32, lastIndex uint32, branch uint32) error { + + if account > MaxAccountNum { + err := managerError(ErrAccountNumTooHigh, errAcctTooHigh, nil) + return err + } + + s.mtx.Lock() + defer s.mtx.Unlock() + + return s.extendAddresses( + ns, account, lastIndex, branch == InternalBranch, + ) +} + // NextExternalAddresses returns the specified number of next chained addresses // that are intended for external use from the address manager. func (s *ScopedKeyManager) NextExternalAddresses(ns walletdb.ReadWriteBucket, diff --git a/waddrmgr/scoped_manager_test.go b/waddrmgr/scoped_manager_test.go index c1614c6927..494343f307 100644 --- a/waddrmgr/scoped_manager_test.go +++ b/waddrmgr/scoped_manager_test.go @@ -107,7 +107,7 @@ func TestDeriveAddrs(t *testing.T) { t.Helper() - err = walletdb.View(db, func(tx walletdb.ReadTx) error { + err := walletdb.View(db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(waddrmgrNamespaceKey) for i := range tc.count { diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 708e1195b2..1ebeb20e44 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -672,6 +672,39 @@ func (m *mockAccountStore) ImportPrivateKey(ns walletdb.ReadWriteBucket, return args.Get(0).(waddrmgr.ManagedPubKeyAddress), args.Error(1) } +// ActiveAccounts implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ActiveAccounts() []uint32 { + args := m.Called() + return args.Get(0).([]uint32) +} + +// ExtendAddresses implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) ExtendAddresses(ns walletdb.ReadWriteBucket, + account uint32, lastIndex uint32, branch uint32) error { + + args := m.Called(ns, account, lastIndex, branch) + return args.Error(0) +} + +// DeriveAddr implements the waddrmgr.AccountStore interface. +func (m *mockAccountStore) DeriveAddr(account, branch, index uint32) ( + btcutil.Address, []byte, error) { + + args := m.Called(account, branch, index) + + var addr btcutil.Address + if args.Get(0) != nil { + addr = args.Get(0).(btcutil.Address) + } + + var script []byte + if args.Get(1) != nil { + script = args.Get(1).([]byte) + } + + return addr, script, args.Error(2) +} + // AddrAccount implements the waddrmgr.AccountStore interface. func (m *mockAccountStore) AddrAccount(ns walletdb.ReadBucket, address btcutil.Address) (uint32, error) { From b9bfa9cbf71fc74707724008d177f2d228603115 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 19 Dec 2025 17:31:39 +0800 Subject: [PATCH 224/691] wallet: fix data race in `TestPopulatePsbtPacketErrors`` This commit fixes a data race in TestPopulatePsbtPacketErrors where multiple parallel sub-tests were concurrently accessing and modifying the shared 'packet' variable. By instantiating a new 'psbt.Packet' within each sub-test, we ensure each test execution works with its own independent packet, while the shared 'authoredTx' remains safely read-only. --- wallet/psbt_manager_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 7de67d53b5..08dfa907dc 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -1357,11 +1357,11 @@ func TestPopulatePsbtPacketErrors(t *testing.T) { }, ChangeIndex: 0, // Output 0 is change } - packet := &psbt.Packet{} t.Run("DecorateInputs fails", func(t *testing.T) { t.Parallel() w, mocks := testWalletWithMocks(t) + packet := &psbt.Packet{} // Mock TxDetails failure (DecorateInputs -> // fetchAndValidateUtxo) @@ -1377,6 +1377,7 @@ func TestPopulatePsbtPacketErrors(t *testing.T) { t.Run("addChangeOutputInfo fails", func(t *testing.T) { t.Parallel() w, mocks := testWalletWithMocks(t) + packet := &psbt.Packet{} // Mock TxDetails success (DecorateInputs) txDetails := &wtxmgr.TxDetails{ From 14e8dc6ca9be2de074a14592eff39e38232cc662 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 2 Dec 2025 20:48:08 +0800 Subject: [PATCH 225/691] wallet: rename lifecycle methods to *Deprecated Renames Start, Stop, Unlock, Lock to *Deprecated in preparation for introducing the WalletController interface. Moves the deprecated implementations to wallet/deprecated.go. --- btcwallet.go | 12 ++++-- rpc/legacyrpc/methods.go | 4 +- rpc/legacyrpc/server.go | 2 +- rpc/rpcserver/server.go | 18 +++++++-- wallet/deprecated.go | 75 +++++++++++++++++++++++++++++++++++++- wallet/example_test.go | 15 ++++---- wallet/interface.go | 26 +++++++------ wallet/loader.go | 5 ++- wallet/wallet.go | 79 +++------------------------------------- walletsetup.go | 17 ++++++++- 10 files changed, 148 insertions(+), 105 deletions(-) diff --git a/btcwallet.go b/btcwallet.go index 898ab90e15..c709ac8d5e 100644 --- a/btcwallet.go +++ b/btcwallet.go @@ -237,10 +237,16 @@ func rpcClientConnectLoop(legacyRPCServer *legacyrpc.Server, loader *wallet.Load loadedWallet.SetChainSynced(false) // TODO: Rework the wallet so changing the RPC client - // does not require stopping and restarting everything. - loadedWallet.Stop() + //nolint:staticcheck // This should be fixed once + // the interface refactor is finished, and new wallet + // RPC is built. + loadedWallet.StopDeprecated() loadedWallet.WaitForShutdown() - loadedWallet.Start() + + //nolint:staticcheck // This should be fixed once + // the interface refactor is finished, and new wallet + // RPC is built. + loadedWallet.StartDeprecated() } } } diff --git a/rpc/legacyrpc/methods.go b/rpc/legacyrpc/methods.go index cbbb1f5d4b..5c1652557b 100644 --- a/rpc/legacyrpc/methods.go +++ b/rpc/legacyrpc/methods.go @@ -1897,7 +1897,7 @@ func walletIsLocked(icmd interface{}, w *wallet.Wallet) (interface{}, error) { // wallets, returning an error if any wallet is not encrypted (for example, // a watching-only wallet). func walletLock(icmd interface{}, w *wallet.Wallet) (interface{}, error) { - w.Lock() + w.LockDeprecated() return nil, nil } @@ -1912,7 +1912,7 @@ func walletPassphrase(icmd interface{}, w *wallet.Wallet) (interface{}, error) { if timeout != 0 { unlockAfter = time.After(timeout) } - err := w.Unlock([]byte(cmd.Passphrase), unlockAfter) + err := w.UnlockDeprecated([]byte(cmd.Passphrase), unlockAfter) return nil, err } diff --git a/rpc/legacyrpc/server.go b/rpc/legacyrpc/server.go index 63c87bcdc5..fe7f43a7e2 100644 --- a/rpc/legacyrpc/server.go +++ b/rpc/legacyrpc/server.go @@ -219,7 +219,7 @@ func (s *Server) Stop() { chainClient := s.chainClient s.handlerMu.Unlock() if wallet != nil { - wallet.Stop() + wallet.StopDeprecated() } if chainClient != nil { chainClient.Stop() diff --git a/rpc/rpcserver/server.go b/rpc/rpcserver/server.go index c1e893a2bd..7309574532 100644 --- a/rpc/rpcserver/server.go +++ b/rpc/rpcserver/server.go @@ -212,7 +212,11 @@ func (s *walletServer) NextAccount(ctx context.Context, req *pb.NextAccountReque defer func() { lock <- time.Time{} // send matters, not the value }() - err := s.wallet.Unlock(req.Passphrase, lock) + + //nolint:staticcheck // This should be fixed once the interface + // refactor is finished, and new wallet RPC is built. + err := s.wallet.UnlockDeprecated(req.GetPassphrase(), + lock) if err != nil { return nil, translateError(err) } @@ -264,7 +268,11 @@ func (s *walletServer) ImportPrivateKey(ctx context.Context, req *pb.ImportPriva defer func() { lock <- time.Time{} // send matters, not the value }() - err = s.wallet.Unlock(req.Passphrase, lock) + + //nolint:staticcheck // This should be fixed once the interface + // refactor is finished, and new wallet RPC is built. + err = s.wallet.UnlockDeprecated(req.GetPassphrase(), + lock) if err != nil { return nil, translateError(err) } @@ -460,7 +468,11 @@ func (s *walletServer) SignTransaction(ctx context.Context, req *pb.SignTransact defer func() { lock <- time.Time{} // send matters, not the value }() - err = s.wallet.Unlock(req.Passphrase, lock) + + //nolint:staticcheck // This should be fixed once the interface + // refactor is finished, and new wallet RPC is built. + err = s.wallet.UnlockDeprecated(req.GetPassphrase(), + lock) if err != nil { return nil, translateError(err) } diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 5bc7fb6c28..97f35c5096 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" @@ -813,7 +814,7 @@ func (w *Wallet) DecorateInputsDeprecated(packet *psbt.Packet, failOnUnknown boo return nil } -// FinalizePsbt expects a partial transaction with all inputs and outputs fully +// FinalizePsbtDeprecated expects a partial transaction with all inputs and outputs fully // declared and tries to sign all inputs that belong to the wallet. Our wallet // must be the last signer of the transaction. That means, if there are any // unsigned non-witness inputs or inputs without UTXO information attached or @@ -960,3 +961,75 @@ func (w *Wallet) FinalizePsbtDeprecated(keyScope *waddrmgr.KeyScope, account uin return nil } + +// StartDeprecated starts the goroutines necessary to manage a wallet. +// +// Deprecated: Use WalletController.Start instead. +func (w *Wallet) StartDeprecated() { + w.quitMu.Lock() + select { + case <-w.quit: + // Restart the wallet goroutines after shutdown finishes. + w.WaitForShutdown() + w.quit = make(chan struct{}) + default: + // Ignore when the wallet is still running. + if w.started { + w.quitMu.Unlock() + return + } + w.started = true + } + w.quitMu.Unlock() + + w.wg.Add(2) + go w.txCreator() + go w.walletLocker() +} + +// StopDeprecated signals all wallet goroutines to shutdown. +// +// Deprecated: Use WalletController.Stop instead. +func (w *Wallet) StopDeprecated() { + <-w.endRecovery() + + w.quitMu.Lock() + quit := w.quit + w.quitMu.Unlock() + + select { + case <-quit: + default: + close(quit) + w.chainClientLock.Lock() + if w.chainClient != nil { + w.chainClient.Stop() + w.chainClient = nil + } + w.chainClientLock.Unlock() + } +} + +// UnlockDeprecated unlocks the wallet's address manager and relocks it after timeout has +// expired. If the wallet is already unlocked and the new passphrase is +// correct, the current timeout is replaced with the new one. The wallet will +// be locked if the passphrase is incorrect or any other error occurs during the +// unlock. +// +// Deprecated: Use WalletController.Unlock instead. +func (w *Wallet) UnlockDeprecated(passphrase []byte, lock <-chan time.Time) error { + err := make(chan error, 1) + w.unlockRequests <- unlockRequest{ + passphrase: passphrase, + lockAfter: lock, + err: err, + } + return <-err +} + +// LockDeprecated locks the wallet's address manager. +// +// Deprecated: Use WalletController.Lock instead. +func (w *Wallet) LockDeprecated() { + w.lockRequests <- struct{}{} +} diff --git a/wallet/example_test.go b/wallet/example_test.go index 431815f7bc..7c18e01f6d 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -42,15 +42,16 @@ func testWallet(t *testing.T) *Wallet { w.chainClient = chainClient // Start the wallet. - w.Start() + w.StartDeprecated() // Add the shutdown to the test's cleanup process. t.Cleanup(func() { - w.Stop() + w.StopDeprecated() w.WaitForShutdown() }) - if err := w.Unlock(privPass, time.After(10*time.Minute)); err != nil { + err = w.UnlockDeprecated(privPass, time.After(10*time.Minute)) + if err != nil { t.Fatalf("unable to unlock wallet: %v", err) } @@ -115,9 +116,9 @@ func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { w.addrStore = addrStore // Start the wallet. - w.Start() + w.StartDeprecated() - err = w.Unlock(privPass, time.After(60*time.Minute)) + err = w.UnlockDeprecated(privPass, time.After(60*time.Minute)) require.NoError(t, err) // Create the mockers struct so it can be used by the tests to mock @@ -180,9 +181,9 @@ func testWalletWatchingOnly(t *testing.T) *Wallet { t.Fatalf("unable to create default scopes: %v", err) } - w.Start() + w.StartDeprecated() t.Cleanup(func() { - w.Stop() + w.StopDeprecated() w.WaitForShutdown() }) diff --git a/wallet/interface.go b/wallet/interface.go index 7b0216b4d3..0fd8ca0b84 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -29,11 +29,15 @@ import ( // //nolint:interfacebloat type Interface interface { - // Start starts the goroutines necessary to manage a wallet. - Start() + // StartDeprecated starts the goroutines necessary to manage a wallet. + // + // Deprecated: Use WalletController.Start instead. + StartDeprecated() - // Stop signals all wallet goroutines to shutdown. - Stop() + // StopDeprecated signals all wallet goroutines to shutdown. + // + // Deprecated: Use WalletController.Stop instead. + StopDeprecated() // WaitForShutdown blocks until all wallet goroutines have finished. WaitForShutdown() @@ -46,14 +50,14 @@ type Interface interface { // will fail. Locked() bool - // Unlock unlocks the wallet with a passphrase. The wallet will - // automatically re-lock after the timeout has expired. If the timeout - // channel is nil, the wallet remains unlocked indefinitely. - Unlock(passphrase []byte, lock <-chan time.Time) error + // UnlockDeprecated unlocks the wallet with a passphrase. The wallet + // will remain unlocked until the returned lock channel is closed or + // the timeout expires. + UnlockDeprecated(passphrase []byte, lock <-chan time.Time) error - // Lock locks the wallet. Any operations that require private keys will - // fail until the wallet is unlocked again. - Lock() + // LockDeprecated locks the wallet. Any operations that require private + // keys will fail if the wallet is locked. + LockDeprecated() // ChainSynced returns whether the wallet is synchronized with the // blockchain. Certain operations may fail if the wallet is not synced. diff --git a/wallet/loader.go b/wallet/loader.go index 792417ae8c..a3e6bdb726 100644 --- a/wallet/loader.go +++ b/wallet/loader.go @@ -367,7 +367,8 @@ func (l *Loader) OpenExistingWallet(pubPassphrase []byte, return nil, err } - w.Start() + + w.StartDeprecated() l.onLoaded(w) return w, nil @@ -406,7 +407,7 @@ func (l *Loader) UnloadWallet() error { return ErrNotLoaded } - l.wallet.Stop() + l.wallet.StopDeprecated() l.wallet.WaitForShutdown() if l.localDB { err := l.db.Close() diff --git a/wallet/wallet.go b/wallet/wallet.go index d972a9bac3..22d255aef9 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -63,11 +63,6 @@ const ( ) var ( - // ErrNotSynced describes an error where an operation cannot complete - // due wallet being out of sync (and perhaps currently syncing with) - // the remote chain server. - ErrNotSynced = errors.New("wallet is not synchronized with the chain server") - // ErrWalletShuttingDown is an error returned when we attempt to make a // request to the wallet but it is in the process of or has already shut // down. @@ -187,29 +182,6 @@ type Wallet struct { syncRetryInterval time.Duration } -// Start starts the goroutines necessary to manage a wallet. -func (w *Wallet) Start() { - w.quitMu.Lock() - select { - case <-w.quit: - // Restart the wallet goroutines after shutdown finishes. - w.WaitForShutdown() - w.quit = make(chan struct{}) - default: - // Ignore when the wallet is still running. - if w.started { - w.quitMu.Unlock() - return - } - w.started = true - } - w.quitMu.Unlock() - - w.wg.Add(2) - go w.txCreator() - go w.walletLocker() -} - // SynchronizeRPC associates the wallet with the consensus RPC client, // synchronizes the wallet with the latest changes to the blockchain, and // continuously updates the wallet through RPC notifications. @@ -290,27 +262,6 @@ func (w *Wallet) quitChan() <-chan struct{} { return c } -// Stop signals all wallet goroutines to shutdown. -func (w *Wallet) Stop() { - <-w.endRecovery() - - w.quitMu.Lock() - quit := w.quit - w.quitMu.Unlock() - - select { - case <-quit: - default: - close(quit) - w.chainClientLock.Lock() - if w.chainClient != nil { - w.chainClient.Stop() - w.chainClient = nil - } - w.chainClientLock.Unlock() - } -} - // ShuttingDown returns whether the wallet is currently in the process of // shutting down or not. func (w *Wallet) ShuttingDown() bool { @@ -1489,26 +1440,6 @@ out: w.wg.Done() } -// Unlock unlocks the wallet's address manager and relocks it after timeout has -// expired. If the wallet is already unlocked and the new passphrase is -// correct, the current timeout is replaced with the new one. The wallet will -// be locked if the passphrase is incorrect or any other error occurs during the -// unlock. -func (w *Wallet) Unlock(passphrase []byte, lock <-chan time.Time) error { - err := make(chan error, 1) - w.unlockRequests <- unlockRequest{ - passphrase: passphrase, - lockAfter: lock, - err: err, - } - return <-err -} - -// Lock locks the wallet's address manager. -func (w *Wallet) Lock() { - w.lockRequests <- struct{}{} -} - // Locked returns whether the account manager for a wallet is locked. func (w *Wallet) Locked() bool { return <-w.lockState @@ -4196,10 +4127,12 @@ func CreateWatchingOnlyWithCallback(db walletdb.DB, pubPass []byte, ) } -// Create creates an new wallet, writing it to an empty database. If the passed -// root key is non-nil, it is used. Otherwise, a secure random seed of the -// recommended length is generated. -func Create(db walletdb.DB, pubPass, privPass []byte, +// CreateDeprecated creates an new wallet, writing it to an empty database. +// If the passed root key is non-nil, it is used. Otherwise, a secure +// random seed of the recommended length is generated. +// +// Deprecated: Use wallet.Create instead. +func CreateDeprecated(db walletdb.DB, pubPass, privPass []byte, rootKey *hdkeychain.ExtendedKey, params *chaincfg.Params, birthday time.Time) error { diff --git a/walletsetup.go b/walletsetup.go index d5316f7d17..08af95e540 100644 --- a/walletsetup.go +++ b/walletsetup.go @@ -152,7 +152,14 @@ func createWallet(cfg *config) error { defer func() { lockChan <- time.Time{} }() - err := w.Unlock(privPass, lockChan) + + //nolint:staticcheck // This should be fixed once + // the interface refactor is finished, and new wallet + // RPC is built. + err := w.UnlockDeprecated( + privPass, + lockChan, + ) if err != nil { fmt.Printf("ERR: Failed to unlock new wallet "+ "during old wallet key import: %v", err) @@ -221,7 +228,13 @@ func createSimulationWallet(cfg *config) error { defer db.Close() // Create the wallet. - err = wallet.Create(db, pubPass, privPass, nil, activeNet.Params, time.Now()) + // + //nolint:staticcheck // This should be fixed once the interface + // refactor is finished, and new wallet RPC is built. + err = wallet.CreateDeprecated( + db, pubPass, privPass, nil, activeNet.Params, + time.Now(), + ) if err != nil { return err } From 86b1edc405e0c8459dc857a39a5fb43e6b558ea7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 20 Dec 2025 15:06:20 +0800 Subject: [PATCH 226/691] wallet: mark fields as deprecated We introduce a new struct `walletDeprecated` to hold all the deprecated fields, paving the way for the incoming wallet controller. --- wallet/deprecated.go | 63 +++++++++++++++++++++++++++ wallet/psbt_manager_test.go | 6 ++- wallet/wallet.go | 85 ++++++++++++++++--------------------- 3 files changed, 104 insertions(+), 50 deletions(-) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 97f35c5096..f91b622346 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -6,14 +6,18 @@ import ( "context" "errors" "fmt" + "sync" + "sync/atomic" "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" @@ -1033,3 +1037,62 @@ func (w *Wallet) UnlockDeprecated(passphrase []byte, lock <-chan time.Time) erro func (w *Wallet) LockDeprecated() { w.lockRequests <- struct{}{} } + +// walletDeprecated encapsulates the legacy state and communication channels +// that are being phased out in favor of the modern Controller and Syncer +// architecture. +// +// Embedding this struct in the Wallet allows old logic to continue functioning +// while clearly marking the fields as legacy. Access to these fields should +// ideally be restricted to methods moved to this file. +type walletDeprecated struct { + // Deprecated fields. + // + // NOTE: Listing below are deprecated fields and will be removed once + // the sqlization series is finished. + started bool + quit chan struct{} + quitMu sync.Mutex + + chainClient chain.Interface + chainClientLock sync.Mutex + chainClientSynced bool + chainClientSyncMtx sync.Mutex + + newAddrMtx sync.Mutex + + lockedOutpoints map[wire.OutPoint]struct{} + lockedOutpointsMtx sync.Mutex + + chainParams *chaincfg.Params + + recovering atomic.Value + + // Channels for rescan processing. Requests are added and merged with + // any waiting requests, before being sent to another goroutine to + // call the rescan RPC. + rescanAddJob chan *RescanJob + rescanBatch chan *rescanBatch + rescanNotifications chan any // From chain server + rescanProgress chan *RescanProgressMsg + rescanFinished chan *RescanFinishedMsg + + // Channels for the manager locker. + unlockRequests chan unlockRequest + lockRequests chan struct{} + holdUnlockRequests chan chan heldUnlock + lockState chan bool + changePassphrase chan changePassphraseRequest + changePassphrases chan changePassphrasesRequest + + // Channel for transaction creation requests. + createTxRequests chan createTxRequest + + // rescanFinishedChan is a channel used to signal the completion of a + // rescan operation from the main loop to the rescan loop. + rescanFinishedChan chan *chain.RescanFinished + + // syncRetryInterval is the amount of time to wait between re-tries on + // errors during initial sync. + syncRetryInterval time.Duration +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 08dfa907dc..d5d245f6e7 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -1837,7 +1837,11 @@ func TestParseBip32Path(t *testing.T) { // Use mainnet params for testing (HDCoinType = 0). chainParams := &chaincfg.MainNetParams - w := &Wallet{chainParams: chainParams} + w := &Wallet{ + walletDeprecated: &walletDeprecated{ + chainParams: chainParams, + }, + } hardened := func(i uint32) uint32 { return i + hdkeychain.HardenedKeyStart diff --git a/wallet/wallet.go b/wallet/wallet.go index 22d255aef9..905b4a40e1 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -127,59 +127,42 @@ var ( // Wallet is a structure containing all the components for a // complete wallet. It contains the Armory-style key store // addresses and keys), +// Wallet is a structure containing all the components for a complete wallet. +// It manages the cryptographic keys, transaction history, and synchronization +// with the blockchain. type Wallet struct { - publicPassphrase []byte + // walletDeprecated embeds the legacy state and channels. Access to these + // should be phased out as refactoring progresses. + *walletDeprecated - // Data stores - db walletdb.DB - addrStore waddrmgr.AddrStore - txStore wtxmgr.TxStore + // publicPassphrase is the passphrase used to encrypt and decrypt public + // data in the address manager. + publicPassphrase []byte - chainClient chain.Interface - chainClientLock sync.Mutex - chainClientSynced bool - chainClientSyncMtx sync.Mutex + // db is the underlying key-value database where all wallet data is + // persisted. + db walletdb.DB - newAddrMtx sync.Mutex + // addrStore is the address and key manager responsible for hierarchical + // deterministic (HD) derivation and storage of cryptographic keys. + addrStore waddrmgr.AddrStore - lockedOutpoints map[wire.OutPoint]struct{} - lockedOutpointsMtx sync.Mutex + // txStore is the transaction manager responsible for storing and + // querying the wallet's transaction history and unspent outputs. + txStore wtxmgr.TxStore - recovering atomic.Value + // recoveryWindow specifies the number of additional keys to derive + // beyond the last used one to look for previously used addresses + // during a rescan or recovery. recoveryWindow uint32 - // Channels for rescan processing. Requests are added and merged with - // any waiting requests, before being sent to another goroutine to - // call the rescan RPC. - rescanAddJob chan *RescanJob - rescanBatch chan *rescanBatch - rescanNotifications chan interface{} // From chain server - rescanProgress chan *RescanProgressMsg - rescanFinished chan *RescanFinishedMsg - - // Channel for transaction creation requests. - createTxRequests chan createTxRequest - - // Channels for the manager locker. - unlockRequests chan unlockRequest - lockRequests chan struct{} - holdUnlockRequests chan chan heldUnlock - lockState chan bool - changePassphrase chan changePassphraseRequest - changePassphrases chan changePassphrasesRequest - + // NtfnServer handles the delivery of wallet-related events (e.g., new + // transactions, block connections) to connected clients. NtfnServer *NotificationServer - chainParams *chaincfg.Params - wg sync.WaitGroup - - started bool - quit chan struct{} - quitMu sync.Mutex - - // syncRetryInterval is the amount of time to wait between re-tries on - // errors during initial sync. - syncRetryInterval time.Duration + // wg is a wait group used to track and wait for all long-running + // background goroutines to finish during a graceful shutdown. + wg sync.WaitGroup } // SynchronizeRPC associates the wallet with the consensus RPC client, @@ -4273,13 +4256,8 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, log.Infof("Opened wallet") // TODO: log balance? last sync height? - w := &Wallet{ - publicPassphrase: pubPass, - db: db, - addrStore: addrMgr, - txStore: txMgr, + deprecated := &walletDeprecated{ lockedOutpoints: map[wire.OutPoint]struct{}{}, - recoveryWindow: recoveryWindow, rescanAddJob: make(chan *RescanJob), rescanBatch: make(chan *rescanBatch), rescanNotifications: make(chan interface{}), @@ -4297,6 +4275,15 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, syncRetryInterval: syncRetryInterval, } + w := &Wallet{ + publicPassphrase: pubPass, + db: db, + addrStore: addrMgr, + txStore: txMgr, + recoveryWindow: recoveryWindow, + walletDeprecated: deprecated, + } + w.NtfnServer = newNotificationServer(w) txMgr.NotifyUnspent = func(hash *chainhash.Hash, index uint32) { w.NtfnServer.notifyUnspentOutput(0, hash, index) From 0be40c7a8e2baf75225c722f101bc70a77bfc051 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 20 Dec 2025 15:34:09 +0800 Subject: [PATCH 227/691] wallet: move methods to deprecated.go This is a cleanup commit to move all deprecated methods into `deprecated.go` - we still keep them to make sure the benchmark tests can be run. --- wallet/deprecated.go | 1272 ++++++++++++++++++++++++++++++++++++ wallet/wallet.go | 1452 +++--------------------------------------- 2 files changed, 1361 insertions(+), 1363 deletions(-) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index f91b622346..f14973dded 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -1038,6 +1038,1278 @@ func (w *Wallet) LockDeprecated() { w.lockRequests <- struct{}{} } +// AddressInfoDeprecated returns detailed information regarding a wallet +// address. +func (w *Wallet) AddressInfoDeprecated(a btcutil.Address) ( + waddrmgr.ManagedAddress, error) { + + var managedAddress waddrmgr.ManagedAddress + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + var err error + + managedAddress, err = w.addrStore.Address(addrmgrNs, a) + return err + }) + return managedAddress, err +} + +// SynchronizeRPC associates the wallet with the consensus RPC client, +// synchronizes the wallet with the latest changes to the blockchain, and +// continuously updates the wallet through RPC notifications. +// +// This method is unstable and will be removed when all syncing logic is moved +// outside of the wallet package. +func (w *Wallet) SynchronizeRPC(chainClient chain.Interface) { + w.quitMu.Lock() + select { + case <-w.quit: + w.quitMu.Unlock() + return + default: + } + w.quitMu.Unlock() + + // TODO: Ignoring the new client when one is already set breaks callers + // who are replacing the client, perhaps after a disconnect. + w.chainClientLock.Lock() + if w.chainClient != nil { + w.chainClientLock.Unlock() + return + } + w.chainClient = chainClient + + // If the chain client is a NeutrinoClient instance, set a birthday so + // we don't download all the filters as we go. + switch cc := chainClient.(type) { + case *chain.NeutrinoClient: + cc.SetStartTime(w.addrStore.Birthday()) + case *chain.BitcoindClient: + cc.SetBirthday(w.addrStore.Birthday()) + } + w.chainClientLock.Unlock() + + // TODO: It would be preferable to either run these goroutines + // separately from the wallet (use wallet mutator functions to + // make changes from the RPC client) and not have to stop and + // restart them each time the client disconnects and reconnets. + w.wg.Add(4) + go w.handleChainNotifications() + go w.rescanBatchHandler() + go w.rescanProgressHandler() + go w.rescanRPCHandler() +} + +// requireChainClient marks that a wallet method can only be completed when the +// consensus RPC server is set. This function and all functions that call it +// are unstable and will need to be moved when the syncing code is moved out of +// the wallet. +func (w *Wallet) requireChainClient() (chain.Interface, error) { + w.chainClientLock.Lock() + chainClient := w.chainClient + w.chainClientLock.Unlock() + if chainClient == nil { + return nil, errors.New("blockchain RPC is inactive") + } + return chainClient, nil +} + +// ChainClient returns the optional consensus RPC client associated with the +// wallet. +// +// This function is unstable and will be removed once sync logic is moved out of +// the wallet. +func (w *Wallet) ChainClient() chain.Interface { + w.chainClientLock.Lock() + chainClient := w.chainClient + w.chainClientLock.Unlock() + return chainClient +} + +// quitChan atomically reads the quit channel. +func (w *Wallet) quitChan() <-chan struct{} { + w.quitMu.Lock() + c := w.quit + w.quitMu.Unlock() + return c +} + +// ShuttingDown returns whether the wallet is currently in the process of +// shutting down or not. +func (w *Wallet) ShuttingDown() bool { + select { + case <-w.quitChan(): + return true + default: + return false + } +} + +// WaitForShutdown blocks until all wallet goroutines have finished executing. +func (w *Wallet) WaitForShutdown() { + w.chainClientLock.Lock() + if w.chainClient != nil { + w.chainClient.WaitForShutdown() + } + w.chainClientLock.Unlock() + w.wg.Wait() +} + +// SynchronizingToNetwork returns whether the wallet is currently synchronizing +// with the Bitcoin network. +func (w *Wallet) SynchronizingToNetwork() bool { + // At the moment, RPC is the only synchronization method. In the + // future, when SPV is added, a separate check will also be needed, or + // SPV could always be enabled if RPC was not explicitly specified when + // creating the wallet. + w.chainClientSyncMtx.Lock() + syncing := w.chainClient != nil + w.chainClientSyncMtx.Unlock() + return syncing +} + +// ChainSynced returns whether the wallet has been attached to a chain server +// and synced up to the best block on the main chain. +func (w *Wallet) ChainSynced() bool { + w.chainClientSyncMtx.Lock() + synced := w.chainClientSynced + w.chainClientSyncMtx.Unlock() + return synced +} + +// SetChainSynced marks whether the wallet is connected to and currently in sync +// with the latest block notified by the chain server. +// +// NOTE: Due to an API limitation with rpcclient, this may return true after +// the client disconnected (and is attempting a reconnect). This will be unknown +// until the reconnect notification is received, at which point the wallet can be +// marked out of sync again until after the next rescan completes. +func (w *Wallet) SetChainSynced(synced bool) { + w.chainClientSyncMtx.Lock() + w.chainClientSynced = synced + w.chainClientSyncMtx.Unlock() +} + +// activeData returns the currently-active receiving addresses and all unspent +// outputs. This is primarely intended to provide the parameters for a +// rescan request. +func (w *Wallet) activeData(dbtx walletdb.ReadWriteTx) ([]btcutil.Address, []wtxmgr.Credit, error) { + addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) + + var addrs []btcutil.Address + + err := w.addrStore.ForEachRelevantActiveAddress( + addrmgrNs, func(addr btcutil.Address) error { + addrs = append(addrs, addr) + return nil + }, + ) + if err != nil { + return nil, nil, err + } + + // Before requesting the list of spendable UTXOs, we'll delete any + // expired output locks. + err = w.txStore.DeleteExpiredLockedOutputs( + dbtx.ReadWriteBucket(wtxmgrNamespaceKey), + ) + if err != nil { + return nil, nil, err + } + + unspent, err := w.txStore.OutputsToWatch(txmgrNs) + return addrs, unspent, err +} + +// syncWithChain brings the wallet up to date with the current chain server +// connection. It creates a rescan request and blocks until the rescan has +// finished. The birthday block can be passed in, if set, to ensure we can +// properly detect if it gets rolled back. +func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { + chainClient, err := w.requireChainClient() + if err != nil { + return err + } + + // Neutrino relies on the information given to it by the cfheader server + // so it knows exactly whether it's synced up to the server's state or + // not, even on dev chains. To recover a Neutrino wallet, we need to + // make sure it's synced before we start scanning for addresses, + // otherwise we might miss some if we only scan up to its current sync + // point. + neutrinoRecovery := chainClient.BackEnd() == "neutrino" && + w.recoveryWindow > 0 + + // We'll wait until the backend is synced to ensure we get the latest + // MaxReorgDepth blocks to store. We don't do this for development + // environments as we can't guarantee a lively chain, except for + // Neutrino, where the cfheader server tells us what it believes the + // chain tip is. + if !w.isDevEnv() || neutrinoRecovery { + log.Debug("Waiting for chain backend to sync to tip") + if err := w.waitUntilBackendSynced(chainClient); err != nil { + return err + } + log.Debug("Chain backend synced to tip!") + } + + // If we've yet to find our birthday block, we'll do so now. + if birthdayStamp == nil { + var err error + birthdayStamp, err = locateBirthdayBlock( + chainClient, w.addrStore.Birthday(), + ) + if err != nil { + return fmt.Errorf("unable to locate birthday block: %w", + err) + } + + // We'll also determine our initial sync starting height. This + // is needed as the wallet can now begin storing blocks from an + // arbitrary height, rather than all the blocks from genesis, so + // we persist this height to ensure we don't store any blocks + // before it. + startHeight := birthdayStamp.Height + + // With the starting height obtained, get the remaining block + // details required by the wallet. + startHash, err := chainClient.GetBlockHash(int64(startHeight)) + if err != nil { + return err + } + startHeader, err := chainClient.GetBlockHeader(startHash) + if err != nil { + return err + } + + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + err := w.addrStore.SetSyncedTo(ns, &waddrmgr.BlockStamp{ + Hash: *startHash, + Height: startHeight, + Timestamp: startHeader.Timestamp, + }) + if err != nil { + return err + } + + return w.addrStore.SetBirthdayBlock( + ns, *birthdayStamp, true, + ) + }) + if err != nil { + return fmt.Errorf("unable to persist initial sync "+ + "data: %w", err) + } + } + + // If the wallet requested an on-chain recovery of its funds, we'll do + // so now. + if w.recoveryWindow > 0 { + if err := w.recovery(chainClient, birthdayStamp); err != nil { + return fmt.Errorf("unable to perform wallet recovery: "+ + "%w", err) + } + } + + // Compare previously-seen blocks against the current chain. If any of + // these blocks no longer exist, rollback all of the missing blocks + // before catching up with the rescan. + rollback := false + rollbackStamp := w.addrStore.SyncedTo() + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + + for height := rollbackStamp.Height; true; height-- { + hash, err := w.addrStore.BlockHash(addrmgrNs, height) + if err != nil { + return err + } + chainHash, err := chainClient.GetBlockHash(int64(height)) + if err != nil { + return err + } + header, err := chainClient.GetBlockHeader(chainHash) + if err != nil { + return err + } + + rollbackStamp.Hash = *chainHash + rollbackStamp.Height = height + rollbackStamp.Timestamp = header.Timestamp + + if bytes.Equal(hash[:], chainHash[:]) { + break + } + rollback = true + } + + // If a rollback did not happen, we can proceed safely. + if !rollback { + return nil + } + + // Otherwise, we'll mark this as our new synced height. + err := w.addrStore.SetSyncedTo(addrmgrNs, &rollbackStamp) + if err != nil { + return err + } + + // If the rollback happened to go beyond our birthday stamp, + // we'll need to find a new one by syncing with the chain again + // until finding one. + if rollbackStamp.Height <= birthdayStamp.Height && + rollbackStamp.Hash != birthdayStamp.Hash { + + err := w.addrStore.SetBirthdayBlock( + addrmgrNs, rollbackStamp, true, + ) + if err != nil { + return err + } + } + + // Finally, we'll roll back our transaction store to reflect the + // stale state. `Rollback` unconfirms transactions at and beyond + // the passed height, so add one to the new synced-to height to + // prevent unconfirming transactions in the synced-to block. + return w.txStore.Rollback(txmgrNs, rollbackStamp.Height+1) + }) + if err != nil { + return err + } + + // Request notifications for connected and disconnected blocks. + // + // TODO(jrick): Either request this notification only once, or when + // rpcclient is modified to allow some notification request to not + // automatically resent on reconnect, include the notifyblocks request + // as well. I am leaning towards allowing off all rpcclient + // notification re-registrations, in which case the code here should be + // left as is. + if err := chainClient.NotifyBlocks(); err != nil { + return err + } + + // Finally, we'll trigger a wallet rescan and request notifications for + // transactions sending to all wallet addresses and spending all wallet + // UTXOs. + var ( + addrs []btcutil.Address + unspent []wtxmgr.Credit + ) + err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { + addrs, unspent, err = w.activeData(dbtx) + return err + }) + if err != nil { + return err + } + + return w.rescanWithTarget(addrs, unspent, nil) +} + +// isDevEnv determines whether the wallet is currently under a local developer +// environment, e.g. simnet or regtest. +func (w *Wallet) isDevEnv() bool { + switch uint32(w.ChainParams().Net) { + case uint32(chaincfg.RegressionNetParams.Net): + case uint32(chaincfg.SimNetParams.Net): + default: + return false + } + return true +} + +// waitUntilBackendSynced blocks until the chain backend considers itself +// "current". +func (w *Wallet) waitUntilBackendSynced(chainClient chain.Interface) error { + // We'll poll every second to determine if our chain considers itself + // "current". + t := time.NewTicker(time.Second) + defer t.Stop() + + for { + select { + case <-t.C: + if chainClient.IsCurrent() { + return nil + } + case <-w.quitChan(): + return ErrWalletShuttingDown + } + } +} + +// recoverySyncer is used to synchronize wallet and address manager locking +// with the end of recovery. (*Wallet).recovery will store a recoverySyncer +// when invoked, and will close the done chan upon exit. Setting the quit flag +// will cause recovery to end after the current batch of blocks. +type recoverySyncer struct { + done chan struct{} + quit uint32 // atomic +} + +// recovery attempts to recover any unspent outputs that pay to any of our +// addresses starting from our birthday, or the wallet's tip (if higher), which +// would indicate resuming a recovery after a restart. +func (w *Wallet) recovery(chainClient chain.Interface, + birthdayBlock *waddrmgr.BlockStamp) error { + + log.Infof("RECOVERY MODE ENABLED -- rescanning for used addresses "+ + "with recovery_window=%d", w.recoveryWindow) + + // Wallet locking must synchronize with the end of recovery, since use of + // keys in recovery is racy with manager IsLocked checks, which could + // result in enrypting data with a zeroed key. + syncer := &recoverySyncer{done: make(chan struct{})} + w.recovering.Store(syncer) + defer close(syncer.done) + + // We'll initialize the recovery manager with a default batch size of + // 2000. + recoveryMgr := NewRecoveryManager( + w.recoveryWindow, recoveryBatchSize, w.chainParams, + ) + + // In the event that this recovery is being resumed, we will need to + // repopulate all found addresses from the database. Ideally, for basic + // recovery, we would only do so for the default scopes, but due to a + // bug in which the wallet would create change addresses outside of the + // default scopes, it's necessary to attempt all registered key scopes. + scopedMgrs := make(map[waddrmgr.KeyScope]waddrmgr.AccountStore) + for _, scopedMgr := range w.addrStore.ActiveScopedKeyManagers() { + scopedMgrs[scopedMgr.Scope()] = scopedMgr + } + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txMgrNS := tx.ReadBucket(wtxmgrNamespaceKey) + + credits, err := w.txStore.UnspentOutputs(txMgrNS) + if err != nil { + return err + } + addrMgrNS := tx.ReadBucket(waddrmgrNamespaceKey) + return recoveryMgr.Resurrect(addrMgrNS, scopedMgrs, credits) + }) + if err != nil { + return err + } + + // Fetch the best height from the backend to determine when we should + // stop. + _, bestHeight, err := chainClient.GetBestBlock() + if err != nil { + return err + } + + // Now we can begin scanning the chain from the wallet's current tip to + // ensure we properly handle restarts. Since the recovery process itself + // acts as rescan, we'll also update our wallet's synced state along the + // way to reflect the blocks we process and prevent rescanning them + // later on. + // + // NOTE: We purposefully don't update our best height since we assume + // that a wallet rescan will be performed from the wallet's tip, which + // will be of bestHeight after completing the recovery process. + var blocks []*waddrmgr.BlockStamp + + startHeight := w.addrStore.SyncedTo().Height + 1 + for height := startHeight; height <= bestHeight; height++ { + if atomic.LoadUint32(&syncer.quit) == 1 { + return errors.New("recovery: forced shutdown") + } + + hash, err := chainClient.GetBlockHash(int64(height)) + if err != nil { + return err + } + header, err := chainClient.GetBlockHeader(hash) + if err != nil { + return err + } + blocks = append(blocks, &waddrmgr.BlockStamp{ + Hash: *hash, + Height: height, + Timestamp: header.Timestamp, + }) + + // It's possible for us to run into blocks before our birthday + // if our birthday is after our reorg safe height, so we'll make + // sure to not add those to the batch. + if height >= birthdayBlock.Height { + recoveryMgr.AddToBlockBatch( + hash, height, header.Timestamp, + ) + } + + // We'll perform our recovery in batches of 2000 blocks. It's + // possible for us to reach our best height without exceeding + // the recovery batch size, so we can proceed to commit our + // state to disk. + recoveryBatch := recoveryMgr.BlockBatch() + if len(recoveryBatch) == recoveryBatchSize || height == bestHeight { + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + if err := w.recoverScopedAddresses( + chainClient, tx, ns, recoveryBatch, + recoveryMgr.State(), scopedMgrs, + ); err != nil { + return err + } + + // TODO: Any error here will roll back this + // entire tx. This may cause the in memory sync + // point to become desyncronized. Refactor so + // that this cannot happen. + for _, block := range blocks { + err := w.addrStore.SetSyncedTo( + ns, block, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + return err + } + + if len(recoveryBatch) > 0 { + log.Infof("Recovered addresses from blocks "+ + "%d-%d", recoveryBatch[0].Height, + recoveryBatch[len(recoveryBatch)-1].Height) + } + + // Clear the batch of all processed blocks to reuse the + // same memory for future batches. + blocks = blocks[:0] + recoveryMgr.ResetBlockBatch() + } + } + + return nil +} + +// recoverScopedAddresses scans a range of blocks in attempts to recover any +// previously used addresses for a particular account derivation path. At a high +// level, the algorithm works as follows: +// +// 1. Ensure internal and external branch horizons are fully expanded. +// 2. Filter the entire range of blocks, stopping if a non-zero number of +// address are contained in a particular block. +// 3. Record all internal and external addresses found in the block. +// 4. Record any outpoints found in the block that should be watched for spends +// 5. Trim the range of blocks up to and including the one reporting the addrs. +// 6. Repeat from (1) if there are still more blocks in the range. +// +// TODO(conner): parallelize/pipeline/cache intermediate network requests +func (w *Wallet) recoverScopedAddresses( + chainClient chain.Interface, + tx walletdb.ReadWriteTx, + ns walletdb.ReadWriteBucket, + batch []wtxmgr.BlockMeta, + recoveryState *RecoveryState, + scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore) error { + + // If there are no blocks in the batch, we are done. + if len(batch) == 0 { + return nil + } + + log.Infof("Scanning %d blocks for recoverable addresses", len(batch)) + +expandHorizons: + for scope, scopedMgr := range scopedMgrs { + scopeState := recoveryState.StateForScope(scope) + err := expandScopeHorizons(ns, scopedMgr, scopeState) + if err != nil { + return err + } + } + + // With the internal and external horizons properly expanded, we now + // construct the filter blocks request. The request includes the range + // of blocks we intend to scan, in addition to the scope-index -> addr + // map for all internal and external branches. + filterReq := newFilterBlocksRequest(batch, scopedMgrs, recoveryState) + + // Initiate the filter blocks request using our chain backend. If an + // error occurs, we are unable to proceed with the recovery. + filterResp, err := chainClient.FilterBlocks(filterReq) + if err != nil { + return err + } + + // If the filter response is empty, this signals that the rest of the + // batch was completed, and no other addresses were discovered. As a + // result, no further modifications to our recovery state are required + // and we can proceed to the next batch. + if filterResp == nil { + return nil + } + + // Otherwise, retrieve the block info for the block that detected a + // non-zero number of address matches. + block := batch[filterResp.BatchIndex] + + // Log any non-trivial findings of addresses or outpoints. + logFilterBlocksResp(block, filterResp) + + // Report any external or internal addresses found as a result of the + // appropriate branch recovery state. Adding indexes above the + // last-found index of either will result in the horizons being expanded + // upon the next iteration. Any found addresses are also marked used + // using the scoped key manager. + err = extendFoundAddresses(ns, filterResp, scopedMgrs, recoveryState) + if err != nil { + return err + } + + // Update the global set of watched outpoints with any that were found + // in the block. + for outPoint, addr := range filterResp.FoundOutPoints { + outPoint := outPoint + recoveryState.AddWatchedOutPoint(&outPoint, addr) + } + + // Finally, record all of the relevant transactions that were returned + // in the filter blocks response. This ensures that these transactions + // and their outputs are tracked when the final rescan is performed. + for _, txn := range filterResp.RelevantTxns { + txRecord, err := wtxmgr.NewTxRecordFromMsgTx( + txn, filterResp.BlockMeta.Time, + ) + if err != nil { + return err + } + + err = w.addRelevantTx(tx, txRecord, &filterResp.BlockMeta) + if err != nil { + return err + } + } + + // Update the batch to indicate that we've processed all block through + // the one that returned found addresses. + batch = batch[filterResp.BatchIndex+1:] + + // If this was not the last block in the batch, we will repeat the + // filtering process again after expanding our horizons. + if len(batch) > 0 { + goto expandHorizons + } + + return nil +} + +// expandScopeHorizons ensures that the ScopeRecoveryState has an adequately +// sized look ahead for both its internal and external branches. The keys +// derived here are added to the scope's recovery state, but do not affect the +// persistent state of the wallet. If any invalid child keys are detected, the +// horizon will be properly extended such that our lookahead always includes the +// proper number of valid child keys. +func expandScopeHorizons(ns walletdb.ReadWriteBucket, + scopedMgr waddrmgr.AccountStore, + scopeState *ScopeRecoveryState) error { + + // Compute the current external horizon and the number of addresses we + // must derive to ensure we maintain a sufficient recovery window for + // the external branch. + exHorizon, exWindow := scopeState.ExternalBranch.ExtendHorizon() + count, childIndex := uint32(0), exHorizon + for count < exWindow { + keyPath := externalKeyPath(childIndex) + addr, err := scopedMgr.DeriveFromKeyPath(ns, keyPath) + switch { + case err == hdkeychain.ErrInvalidChild: + // Record the existence of an invalid child with the + // external branch's recovery state. This also + // increments the branch's horizon so that it accounts + // for this skipped child index. + scopeState.ExternalBranch.MarkInvalidChild(childIndex) + childIndex++ + continue + + case err != nil: + return err + } + + // Register the newly generated external address and child index + // with the external branch recovery state. + scopeState.ExternalBranch.AddAddr(childIndex, addr.Address()) + + childIndex++ + count++ + } + + // Compute the current internal horizon and the number of addresses we + // must derive to ensure we maintain a sufficient recovery window for + // the internal branch. + inHorizon, inWindow := scopeState.InternalBranch.ExtendHorizon() + count, childIndex = 0, inHorizon + for count < inWindow { + keyPath := internalKeyPath(childIndex) + addr, err := scopedMgr.DeriveFromKeyPath(ns, keyPath) + switch { + case err == hdkeychain.ErrInvalidChild: + // Record the existence of an invalid child with the + // internal branch's recovery state. This also + // increments the branch's horizon so that it accounts + // for this skipped child index. + scopeState.InternalBranch.MarkInvalidChild(childIndex) + childIndex++ + continue + + case err != nil: + return err + } + + // Register the newly generated internal address and child index + // with the internal branch recovery state. + scopeState.InternalBranch.AddAddr(childIndex, addr.Address()) + + childIndex++ + count++ + } + + return nil +} + +// externalKeyPath returns the relative external derivation path /0/0/index. +func externalKeyPath(index uint32) waddrmgr.DerivationPath { + return waddrmgr.DerivationPath{ + InternalAccount: waddrmgr.DefaultAccountNum, + Account: waddrmgr.DefaultAccountNum, + Branch: waddrmgr.ExternalBranch, + Index: index, + } +} + +// internalKeyPath returns the relative internal derivation path /0/1/index. +func internalKeyPath(index uint32) waddrmgr.DerivationPath { + return waddrmgr.DerivationPath{ + InternalAccount: waddrmgr.DefaultAccountNum, + Account: waddrmgr.DefaultAccountNum, + Branch: waddrmgr.InternalBranch, + Index: index, + } +} + +// newFilterBlocksRequest constructs FilterBlocksRequests using our current +// block range, scoped managers, and recovery state. +func newFilterBlocksRequest(batch []wtxmgr.BlockMeta, + scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, + recoveryState *RecoveryState) *chain.FilterBlocksRequest { + + filterReq := &chain.FilterBlocksRequest{ + Blocks: batch, + ExternalAddrs: make(map[waddrmgr.ScopedIndex]btcutil.Address), + InternalAddrs: make(map[waddrmgr.ScopedIndex]btcutil.Address), + WatchedOutPoints: recoveryState.WatchedOutPoints(), + } + + // Populate the external and internal addresses by merging the addresses + // sets belong to all currently tracked scopes. + for scope := range scopedMgrs { + scopeState := recoveryState.StateForScope(scope) + for index, addr := range scopeState.ExternalBranch.Addrs() { + scopedIndex := waddrmgr.ScopedIndex{ + Scope: scope, + Index: index, + } + filterReq.ExternalAddrs[scopedIndex] = addr + } + for index, addr := range scopeState.InternalBranch.Addrs() { + scopedIndex := waddrmgr.ScopedIndex{ + Scope: scope, + Index: index, + } + filterReq.InternalAddrs[scopedIndex] = addr + } + } + + return filterReq +} + +// extendFoundAddresses accepts a filter blocks response that contains addresses +// found on chain, and advances the state of all relevant derivation paths to +// match the highest found child index for each branch. +func extendFoundAddresses(ns walletdb.ReadWriteBucket, + filterResp *chain.FilterBlocksResponse, + scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, + recoveryState *RecoveryState) error { + + // Mark all recovered external addresses as used. This will be done only + // for scopes that reported a non-zero number of external addresses in + // this block. + for scope, indexes := range filterResp.FoundExternalAddrs { + // First, report all external child indexes found for this + // scope. This ensures that the external last-found index will + // be updated to include the maximum child index seen thus far. + scopeState := recoveryState.StateForScope(scope) + for index := range indexes { + scopeState.ExternalBranch.ReportFound(index) + } + + scopedMgr := scopedMgrs[scope] + + // Now, with all found addresses reported, derive and extend all + // external addresses up to and including the current last found + // index for this scope. + exNextUnfound := scopeState.ExternalBranch.NextUnfound() + + exLastFound := exNextUnfound + if exLastFound > 0 { + exLastFound-- + } + + err := scopedMgr.ExtendExternalAddresses( + ns, waddrmgr.DefaultAccountNum, exLastFound, + ) + if err != nil { + return err + } + + // Finally, with the scope's addresses extended, we mark used + // the external addresses that were found in the block and + // belong to this scope. + for index := range indexes { + addr := scopeState.ExternalBranch.GetAddr(index) + err := scopedMgr.MarkUsed(ns, addr) + if err != nil { + return err + } + } + } + + // Mark all recovered internal addresses as used. This will be done only + // for scopes that reported a non-zero number of internal addresses in + // this block. + for scope, indexes := range filterResp.FoundInternalAddrs { + // First, report all internal child indexes found for this + // scope. This ensures that the internal last-found index will + // be updated to include the maximum child index seen thus far. + scopeState := recoveryState.StateForScope(scope) + for index := range indexes { + scopeState.InternalBranch.ReportFound(index) + } + + scopedMgr := scopedMgrs[scope] + + // Now, with all found addresses reported, derive and extend all + // internal addresses up to and including the current last found + // index for this scope. + inNextUnfound := scopeState.InternalBranch.NextUnfound() + + inLastFound := inNextUnfound + if inLastFound > 0 { + inLastFound-- + } + err := scopedMgr.ExtendInternalAddresses( + ns, waddrmgr.DefaultAccountNum, inLastFound, + ) + if err != nil { + return err + } + + // Finally, with the scope's addresses extended, we mark used + // the internal addresses that were found in the blockand belong + // to this scope. + for index := range indexes { + addr := scopeState.InternalBranch.GetAddr(index) + err := scopedMgr.MarkUsed(ns, addr) + if err != nil { + return err + } + } + } + + return nil +} + +// logFilterBlocksResp provides useful logging information when filtering +// succeeded in finding relevant transactions. +func logFilterBlocksResp(block wtxmgr.BlockMeta, + resp *chain.FilterBlocksResponse) { + + // Log the number of external addresses found in this block. + var nFoundExternal int + for _, indexes := range resp.FoundExternalAddrs { + nFoundExternal += len(indexes) + } + if nFoundExternal > 0 { + log.Infof("Recovered %d external addrs at height=%d hash=%v", + nFoundExternal, block.Height, block.Hash) + } + + // Log the number of internal addresses found in this block. + var nFoundInternal int + for _, indexes := range resp.FoundInternalAddrs { + nFoundInternal += len(indexes) + } + if nFoundInternal > 0 { + log.Infof("Recovered %d internal addrs at height=%d hash=%v", + nFoundInternal, block.Height, block.Hash) + } + + // Log the number of outpoints found in this block. + nFoundOutPoints := len(resp.FoundOutPoints) + if nFoundOutPoints > 0 { + log.Infof("Found %d spends from watched outpoints at "+ + "height=%d hash=%v", + nFoundOutPoints, block.Height, block.Hash) + } +} + +type ( + createTxRequest struct { + coinSelectKeyScope *waddrmgr.KeyScope + changeKeyScope *waddrmgr.KeyScope + account uint32 + outputs []*wire.TxOut + minconf int32 + feeSatPerKB btcutil.Amount + coinSelectionStrategy CoinSelectionStrategy + dryRun bool + resp chan createTxResponse + selectUtxos []wire.OutPoint + allowUtxo func(wtxmgr.Credit) bool + } + createTxResponse struct { + tx *txauthor.AuthoredTx + err error + } +) + +// txCreator is responsible for the input selection and creation of +// transactions. These functions are the responsibility of this method +// (designed to be run as its own goroutine) since input selection must be +// serialized, or else it is possible to create double spends by choosing the +// same inputs for multiple transactions. Along with input selection, this +// method is also responsible for the signing of transactions, since we don't +// want to end up in a situation where we run out of inputs as multiple +// transactions are being created. In this situation, it would then be possible +// for both requests, rather than just one, to fail due to not enough available +// inputs. +func (w *Wallet) txCreator() { + quit := w.quitChan() +out: + for { + select { + case txr := <-w.createTxRequests: + // If the wallet can be locked because it contains + // private key material, we need to prevent it from + // doing so while we are assembling the transaction. + release := func() {} + if !w.addrStore.WatchOnly() { + heldUnlock, err := w.holdUnlock() + if err != nil { + txr.resp <- createTxResponse{nil, err} + continue + } + + release = heldUnlock.release + } + + tx, err := w.txToOutputs( + txr.outputs, txr.coinSelectKeyScope, + txr.changeKeyScope, txr.account, txr.minconf, + txr.feeSatPerKB, txr.coinSelectionStrategy, + txr.dryRun, txr.selectUtxos, txr.allowUtxo, + ) + + release() + txr.resp <- createTxResponse{tx, err} + case <-quit: + break out + } + } + w.wg.Done() +} + +// txCreateOptions is a set of optional arguments to modify the tx creation +// process. This can be used to do things like use a custom coin selection +// scope, which otherwise will default to the specified coin selection scope. +type txCreateOptions struct { + changeKeyScope *waddrmgr.KeyScope + selectUtxos []wire.OutPoint + allowUtxo func(wtxmgr.Credit) bool +} + +// TxCreateOption is a set of optional arguments to modify the tx creation +// process. This can be used to do things like use a custom coin selection +// scope, which otherwise will default to the specified coin selection scope. +type TxCreateOption func(*txCreateOptions) + +// defaultTxCreateOptions is the default set of options. +func defaultTxCreateOptions() *txCreateOptions { + return &txCreateOptions{} +} + +// WithCustomChangeScope can be used to specify a change scope for the change +// address. If unspecified, then the same scope will be used for both inputs +// and the change addr. Not specifying any scope at all (nil) will use all +// available coins and the default change scope (P2TR). +func WithCustomChangeScope(changeScope *waddrmgr.KeyScope) TxCreateOption { + return func(opts *txCreateOptions) { + opts.changeKeyScope = changeScope + } +} + +// WithCustomSelectUtxos is used to specify the inputs to be used while +// creating txns. +func WithCustomSelectUtxos(utxos []wire.OutPoint) TxCreateOption { + return func(opts *txCreateOptions) { + opts.selectUtxos = utxos + } +} + +// WithUtxoFilter is used to restrict the selection of the internal wallet +// inputs by further external conditions. Utxos which pass the filter are +// considered when creating the transaction. +func WithUtxoFilter(allowUtxo func(utxo wtxmgr.Credit) bool) TxCreateOption { + return func(opts *txCreateOptions) { + opts.allowUtxo = allowUtxo + } +} + +type ( + unlockRequest struct { + passphrase []byte + lockAfter <-chan time.Time // nil prevents the timeout. + err chan error + } + + changePassphraseRequest struct { + old, new []byte + private bool + err chan error + } + + changePassphrasesRequest struct { + publicOld, publicNew []byte + privateOld, privateNew []byte + err chan error + } + + // heldUnlock is a tool to prevent the wallet from automatically + // locking after some timeout before an operation which needed + // the unlocked wallet has finished. Any acquired heldUnlock + // *must* be released (preferably with a defer) or the wallet + // will forever remain unlocked. + heldUnlock chan struct{} +) + +// endRecovery tells (*Wallet).recovery to stop, if running, and returns a +// channel that will be closed when the recovery routine exits. +func (w *Wallet) endRecovery() <-chan struct{} { + if recoverySyncI := w.recovering.Load(); recoverySyncI != nil { + recoverySync := recoverySyncI.(*recoverySyncer) + + // If recovery is still running, it will end early with an error + // once we set the quit flag. + atomic.StoreUint32(&recoverySync.quit, 1) + + return recoverySync.done + } + c := make(chan struct{}) + close(c) + return c +} + +// walletLocker manages the locked/unlocked state of a wallet. +func (w *Wallet) walletLocker() { + var timeout <-chan time.Time + holdChan := make(heldUnlock) + quit := w.quitChan() +out: + for { + select { + case req := <-w.unlockRequests: + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + return w.addrStore.Unlock( + addrmgrNs, req.passphrase, + ) + }) + if err != nil { + req.err <- err + continue + } + timeout = req.lockAfter + if timeout == nil { + log.Info("The wallet has been unlocked without a time limit") + } else { + log.Info("The wallet has been temporarily unlocked") + } + req.err <- nil + continue + + case req := <-w.changePassphrase: + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + return w.addrStore.ChangePassphrase( + addrmgrNs, req.old, req.new, req.private, + &waddrmgr.DefaultScryptOptions, + ) + }) + req.err <- err + continue + + case req := <-w.changePassphrases: + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + err := w.addrStore.ChangePassphrase( + addrmgrNs, req.publicOld, req.publicNew, + false, &waddrmgr.DefaultScryptOptions, + ) + if err != nil { + return err + } + + return w.addrStore.ChangePassphrase( + addrmgrNs, req.privateOld, req.privateNew, + true, &waddrmgr.DefaultScryptOptions, + ) + }) + req.err <- err + continue + + case req := <-w.holdUnlockRequests: + if w.addrStore.IsLocked() { + close(req) + continue + } + + req <- holdChan + <-holdChan // Block until the lock is released. + + // If, after holding onto the unlocked wallet for some + // time, the timeout has expired, lock it now instead + // of hoping it gets unlocked next time the top level + // select runs. + select { + case <-timeout: + // Let the top level select fallthrough so the + // wallet is locked. + default: + continue + } + + case w.lockState <- w.addrStore.IsLocked(): + continue + + case <-quit: + break out + + case <-w.lockRequests: + case <-timeout: + } + + // Select statement fell through by an explicit lock or the + // timer expiring. Lock the manager here. + + // We can't lock the manager if recovery is active because we use + // cryptoKeyPriv and cryptoKeyScript in recovery. + <-w.endRecovery() + + timeout = nil + + err := w.addrStore.Lock() + if err != nil && !waddrmgr.IsError(err, waddrmgr.ErrLocked) { + log.Errorf("Could not lock wallet: %v", err) + } else { + log.Info("The wallet has been locked") + } + } + w.wg.Done() +} + +// Locked returns whether the account manager for a wallet is locked. +func (w *Wallet) Locked() bool { + return <-w.lockState +} + +// holdUnlock prevents the wallet from being locked. The heldUnlock object +// *must* be released, or the wallet will forever remain unlocked. +// +// TODO: To prevent the above scenario, perhaps closures should be passed +// to the walletLocker goroutine and disallow callers from explicitly + +// handling the locking mechanism. +func (w *Wallet) holdUnlock() (heldUnlock, error) { + req := make(chan heldUnlock) + w.holdUnlockRequests <- req + hl, ok := <-req + if !ok { + // TODO(davec): This should be defined and exported from + // waddrmgr. + return nil, waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrLocked, + Description: "address manager is locked", + } + } + return hl, nil +} + +// release releases the hold on the unlocked-state of the wallet and allows the +// wallet to be locked again. If a lock timeout has already expired, the +// wallet is locked again as soon as release is called. +func (c heldUnlock) release() { + c <- struct{}{} +} + +// ChangePrivatePassphrase attempts to change the passphrase for a wallet from +// old to new. Changing the passphrase is synchronized with all other address +// manager locking and unlocking. The lock state will be the same as it was +// before the password change. +func (w *Wallet) ChangePrivatePassphrase(old, new []byte) error { + err := make(chan error, 1) + w.changePassphrase <- changePassphraseRequest{ + old: old, + new: new, + private: true, + err: err, + } + return <-err +} + +// ChangePublicPassphrase modifies the public passphrase of the wallet. +func (w *Wallet) ChangePublicPassphrase(old, new []byte) error { + err := make(chan error, 1) + w.changePassphrase <- changePassphraseRequest{ + old: old, + new: new, + private: false, + err: err, + } + return <-err +} + +// ChangePassphrases modifies the public and private passphrase of the wallet +// atomically. +func (w *Wallet) ChangePassphrases(publicOld, publicNew, privateOld, + privateNew []byte) error { + + err := make(chan error, 1) + w.changePassphrases <- changePassphrasesRequest{ + publicOld: publicOld, + publicNew: publicNew, + privateOld: privateOld, + privateNew: privateNew, + err: err, + } + return <-err +} + // walletDeprecated encapsulates the legacy state and communication channels // that are being phased out in favor of the modern Controller and Syncer // architecture. diff --git a/wallet/wallet.go b/wallet/wallet.go index 905b4a40e1..455c8edb6d 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -19,7 +19,6 @@ import ( "fmt" "sort" "sync" - "sync/atomic" "time" "github.com/btcsuite/btcd/blockchain" @@ -33,7 +32,6 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/walletdb/migration" @@ -124,1380 +122,124 @@ var ( CoinSelectionRandom CoinSelectionStrategy = &RandomCoinSelector{} ) -// Wallet is a structure containing all the components for a -// complete wallet. It contains the Armory-style key store -// addresses and keys), -// Wallet is a structure containing all the components for a complete wallet. -// It manages the cryptographic keys, transaction history, and synchronization -// with the blockchain. -type Wallet struct { - // walletDeprecated embeds the legacy state and channels. Access to these - // should be phased out as refactoring progresses. - *walletDeprecated - - // publicPassphrase is the passphrase used to encrypt and decrypt public - // data in the address manager. - publicPassphrase []byte - - // db is the underlying key-value database where all wallet data is - // persisted. - db walletdb.DB - - // addrStore is the address and key manager responsible for hierarchical - // deterministic (HD) derivation and storage of cryptographic keys. - addrStore waddrmgr.AddrStore - - // txStore is the transaction manager responsible for storing and - // querying the wallet's transaction history and unspent outputs. - txStore wtxmgr.TxStore - - // recoveryWindow specifies the number of additional keys to derive - // beyond the last used one to look for previously used addresses - // during a rescan or recovery. - recoveryWindow uint32 - - // NtfnServer handles the delivery of wallet-related events (e.g., new - // transactions, block connections) to connected clients. - NtfnServer *NotificationServer - - // wg is a wait group used to track and wait for all long-running - // background goroutines to finish during a graceful shutdown. - wg sync.WaitGroup -} - -// SynchronizeRPC associates the wallet with the consensus RPC client, -// synchronizes the wallet with the latest changes to the blockchain, and -// continuously updates the wallet through RPC notifications. -// -// This method is unstable and will be removed when all syncing logic is moved -// outside of the wallet package. -func (w *Wallet) SynchronizeRPC(chainClient chain.Interface) { - w.quitMu.Lock() - select { - case <-w.quit: - w.quitMu.Unlock() - return - default: - } - w.quitMu.Unlock() - - // TODO: Ignoring the new client when one is already set breaks callers - // who are replacing the client, perhaps after a disconnect. - w.chainClientLock.Lock() - if w.chainClient != nil { - w.chainClientLock.Unlock() - return - } - w.chainClient = chainClient - - // If the chain client is a NeutrinoClient instance, set a birthday so - // we don't download all the filters as we go. - switch cc := chainClient.(type) { - case *chain.NeutrinoClient: - cc.SetStartTime(w.addrStore.Birthday()) - case *chain.BitcoindClient: - cc.SetBirthday(w.addrStore.Birthday()) - } - w.chainClientLock.Unlock() - - // TODO: It would be preferable to either run these goroutines - // separately from the wallet (use wallet mutator functions to - // make changes from the RPC client) and not have to stop and - // restart them each time the client disconnects and reconnets. - w.wg.Add(4) - go w.handleChainNotifications() - go w.rescanBatchHandler() - go w.rescanProgressHandler() - go w.rescanRPCHandler() -} - -// requireChainClient marks that a wallet method can only be completed when the -// consensus RPC server is set. This function and all functions that call it -// are unstable and will need to be moved when the syncing code is moved out of -// the wallet. -func (w *Wallet) requireChainClient() (chain.Interface, error) { - w.chainClientLock.Lock() - chainClient := w.chainClient - w.chainClientLock.Unlock() - if chainClient == nil { - return nil, errors.New("blockchain RPC is inactive") - } - return chainClient, nil -} - -// ChainClient returns the optional consensus RPC client associated with the -// wallet. -// -// This function is unstable and will be removed once sync logic is moved out of -// the wallet. -func (w *Wallet) ChainClient() chain.Interface { - w.chainClientLock.Lock() - chainClient := w.chainClient - w.chainClientLock.Unlock() - return chainClient -} - -// quitChan atomically reads the quit channel. -func (w *Wallet) quitChan() <-chan struct{} { - w.quitMu.Lock() - c := w.quit - w.quitMu.Unlock() - return c -} - -// ShuttingDown returns whether the wallet is currently in the process of -// shutting down or not. -func (w *Wallet) ShuttingDown() bool { - select { - case <-w.quitChan(): - return true - default: - return false - } -} - -// WaitForShutdown blocks until all wallet goroutines have finished executing. -func (w *Wallet) WaitForShutdown() { - w.chainClientLock.Lock() - if w.chainClient != nil { - w.chainClient.WaitForShutdown() - } - w.chainClientLock.Unlock() - w.wg.Wait() -} - -// SynchronizingToNetwork returns whether the wallet is currently synchronizing -// with the Bitcoin network. -func (w *Wallet) SynchronizingToNetwork() bool { - // At the moment, RPC is the only synchronization method. In the - // future, when SPV is added, a separate check will also be needed, or - // SPV could always be enabled if RPC was not explicitly specified when - // creating the wallet. - w.chainClientSyncMtx.Lock() - syncing := w.chainClient != nil - w.chainClientSyncMtx.Unlock() - return syncing -} - -// ChainSynced returns whether the wallet has been attached to a chain server -// and synced up to the best block on the main chain. -func (w *Wallet) ChainSynced() bool { - w.chainClientSyncMtx.Lock() - synced := w.chainClientSynced - w.chainClientSyncMtx.Unlock() - return synced -} - -// SetChainSynced marks whether the wallet is connected to and currently in sync -// with the latest block notified by the chain server. -// -// NOTE: Due to an API limitation with rpcclient, this may return true after -// the client disconnected (and is attempting a reconnect). This will be unknown -// until the reconnect notification is received, at which point the wallet can be -// marked out of sync again until after the next rescan completes. -func (w *Wallet) SetChainSynced(synced bool) { - w.chainClientSyncMtx.Lock() - w.chainClientSynced = synced - w.chainClientSyncMtx.Unlock() -} - -// activeData returns the currently-active receiving addresses and all unspent -// outputs. This is primarely intended to provide the parameters for a -// rescan request. -func (w *Wallet) activeData(dbtx walletdb.ReadWriteTx) ([]btcutil.Address, []wtxmgr.Credit, error) { - addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) - - var addrs []btcutil.Address - - err := w.addrStore.ForEachRelevantActiveAddress( - addrmgrNs, func(addr btcutil.Address) error { - addrs = append(addrs, addr) - return nil - }, - ) - if err != nil { - return nil, nil, err - } - - // Before requesting the list of spendable UTXOs, we'll delete any - // expired output locks. - err = w.txStore.DeleteExpiredLockedOutputs( - dbtx.ReadWriteBucket(wtxmgrNamespaceKey), - ) - if err != nil { - return nil, nil, err - } - - unspent, err := w.txStore.OutputsToWatch(txmgrNs) - return addrs, unspent, err -} - -// syncWithChain brings the wallet up to date with the current chain server -// connection. It creates a rescan request and blocks until the rescan has -// finished. The birthday block can be passed in, if set, to ensure we can -// properly detect if it gets rolled back. -func (w *Wallet) syncWithChain(birthdayStamp *waddrmgr.BlockStamp) error { - chainClient, err := w.requireChainClient() - if err != nil { - return err - } - - // Neutrino relies on the information given to it by the cfheader server - // so it knows exactly whether it's synced up to the server's state or - // not, even on dev chains. To recover a Neutrino wallet, we need to - // make sure it's synced before we start scanning for addresses, - // otherwise we might miss some if we only scan up to its current sync - // point. - neutrinoRecovery := chainClient.BackEnd() == "neutrino" && - w.recoveryWindow > 0 - - // We'll wait until the backend is synced to ensure we get the latest - // MaxReorgDepth blocks to store. We don't do this for development - // environments as we can't guarantee a lively chain, except for - // Neutrino, where the cfheader server tells us what it believes the - // chain tip is. - if !w.isDevEnv() || neutrinoRecovery { - log.Debug("Waiting for chain backend to sync to tip") - if err := w.waitUntilBackendSynced(chainClient); err != nil { - return err - } - log.Debug("Chain backend synced to tip!") - } - - // If we've yet to find our birthday block, we'll do so now. - if birthdayStamp == nil { - var err error - birthdayStamp, err = locateBirthdayBlock( - chainClient, w.addrStore.Birthday(), - ) - if err != nil { - return fmt.Errorf("unable to locate birthday block: %w", - err) - } - - // We'll also determine our initial sync starting height. This - // is needed as the wallet can now begin storing blocks from an - // arbitrary height, rather than all the blocks from genesis, so - // we persist this height to ensure we don't store any blocks - // before it. - startHeight := birthdayStamp.Height - - // With the starting height obtained, get the remaining block - // details required by the wallet. - startHash, err := chainClient.GetBlockHash(int64(startHeight)) - if err != nil { - return err - } - startHeader, err := chainClient.GetBlockHeader(startHash) - if err != nil { - return err - } - - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - - err := w.addrStore.SetSyncedTo(ns, &waddrmgr.BlockStamp{ - Hash: *startHash, - Height: startHeight, - Timestamp: startHeader.Timestamp, - }) - if err != nil { - return err - } - - return w.addrStore.SetBirthdayBlock( - ns, *birthdayStamp, true, - ) - }) - if err != nil { - return fmt.Errorf("unable to persist initial sync "+ - "data: %w", err) - } - } - - // If the wallet requested an on-chain recovery of its funds, we'll do - // so now. - if w.recoveryWindow > 0 { - if err := w.recovery(chainClient, birthdayStamp); err != nil { - return fmt.Errorf("unable to perform wallet recovery: "+ - "%w", err) - } - } - - // Compare previously-seen blocks against the current chain. If any of - // these blocks no longer exist, rollback all of the missing blocks - // before catching up with the rescan. - rollback := false - rollbackStamp := w.addrStore.SyncedTo() - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) - - for height := rollbackStamp.Height; true; height-- { - hash, err := w.addrStore.BlockHash(addrmgrNs, height) - if err != nil { - return err - } - chainHash, err := chainClient.GetBlockHash(int64(height)) - if err != nil { - return err - } - header, err := chainClient.GetBlockHeader(chainHash) - if err != nil { - return err - } - - rollbackStamp.Hash = *chainHash - rollbackStamp.Height = height - rollbackStamp.Timestamp = header.Timestamp - - if bytes.Equal(hash[:], chainHash[:]) { - break - } - rollback = true - } - - // If a rollback did not happen, we can proceed safely. - if !rollback { - return nil - } - - // Otherwise, we'll mark this as our new synced height. - err := w.addrStore.SetSyncedTo(addrmgrNs, &rollbackStamp) - if err != nil { - return err - } - - // If the rollback happened to go beyond our birthday stamp, - // we'll need to find a new one by syncing with the chain again - // until finding one. - if rollbackStamp.Height <= birthdayStamp.Height && - rollbackStamp.Hash != birthdayStamp.Hash { - - err := w.addrStore.SetBirthdayBlock( - addrmgrNs, rollbackStamp, true, - ) - if err != nil { - return err - } - } - - // Finally, we'll roll back our transaction store to reflect the - // stale state. `Rollback` unconfirms transactions at and beyond - // the passed height, so add one to the new synced-to height to - // prevent unconfirming transactions in the synced-to block. - return w.txStore.Rollback(txmgrNs, rollbackStamp.Height+1) - }) - if err != nil { - return err - } - - // Request notifications for connected and disconnected blocks. - // - // TODO(jrick): Either request this notification only once, or when - // rpcclient is modified to allow some notification request to not - // automatically resent on reconnect, include the notifyblocks request - // as well. I am leaning towards allowing off all rpcclient - // notification re-registrations, in which case the code here should be - // left as is. - if err := chainClient.NotifyBlocks(); err != nil { - return err - } - - // Finally, we'll trigger a wallet rescan and request notifications for - // transactions sending to all wallet addresses and spending all wallet - // UTXOs. - var ( - addrs []btcutil.Address - unspent []wtxmgr.Credit - ) - err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { - addrs, unspent, err = w.activeData(dbtx) - return err - }) - if err != nil { - return err - } - - return w.rescanWithTarget(addrs, unspent, nil) -} - -// isDevEnv determines whether the wallet is currently under a local developer -// environment, e.g. simnet or regtest. -func (w *Wallet) isDevEnv() bool { - switch uint32(w.ChainParams().Net) { - case uint32(chaincfg.RegressionNetParams.Net): - case uint32(chaincfg.SimNetParams.Net): - default: - return false - } - return true -} - -// waitUntilBackendSynced blocks until the chain backend considers itself -// "current". -func (w *Wallet) waitUntilBackendSynced(chainClient chain.Interface) error { - // We'll poll every second to determine if our chain considers itself - // "current". - t := time.NewTicker(time.Second) - defer t.Stop() - - for { - select { - case <-t.C: - if chainClient.IsCurrent() { - return nil - } - case <-w.quitChan(): - return ErrWalletShuttingDown - } - } -} - -// locateBirthdayBlock returns a block that meets the given birthday timestamp -// by a margin of +/-2 hours. This is safe to do as the timestamp is already 2 -// days in the past of the actual timestamp. -func locateBirthdayBlock(chainClient chainConn, - birthday time.Time) (*waddrmgr.BlockStamp, error) { - - // Retrieve the lookup range for our block. - startHeight := int32(0) - _, bestHeight, err := chainClient.GetBestBlock() - if err != nil { - return nil, err - } - - log.Debugf("Locating suitable block for birthday %v between blocks "+ - "%v-%v", birthday, startHeight, bestHeight) - - var ( - birthdayBlock *waddrmgr.BlockStamp - left, right = startHeight, bestHeight - ) - - // Binary search for a block that meets the birthday timestamp by a - // margin of +/-2 hours. - for { - // Retrieve the timestamp for the block halfway through our - // range. - mid := left + (right-left)/2 - hash, err := chainClient.GetBlockHash(int64(mid)) - if err != nil { - return nil, err - } - header, err := chainClient.GetBlockHeader(hash) - if err != nil { - return nil, err - } - - log.Debugf("Checking candidate block: height=%v, hash=%v, "+ - "timestamp=%v", mid, hash, header.Timestamp) - - // If the search happened to reach either of our range extremes, - // then we'll just use that as there's nothing left to search. - if mid == startHeight || mid == bestHeight || mid == left { - birthdayBlock = &waddrmgr.BlockStamp{ - Hash: *hash, - Height: mid, - Timestamp: header.Timestamp, - } - break - } - - // The block's timestamp is more than 2 hours after the - // birthday, so look for a lower block. - if header.Timestamp.Sub(birthday) > birthdayBlockDelta { - right = mid - continue - } - - // The birthday is more than 2 hours before the block's - // timestamp, so look for a higher block. - if header.Timestamp.Sub(birthday) < -birthdayBlockDelta { - left = mid - continue - } - - birthdayBlock = &waddrmgr.BlockStamp{ - Hash: *hash, - Height: mid, - Timestamp: header.Timestamp, - } - break - } - - log.Debugf("Found birthday block: height=%d, hash=%v, timestamp=%v", - birthdayBlock.Height, birthdayBlock.Hash, - birthdayBlock.Timestamp) - - return birthdayBlock, nil -} - -// recoverySyncer is used to synchronize wallet and address manager locking -// with the end of recovery. (*Wallet).recovery will store a recoverySyncer -// when invoked, and will close the done chan upon exit. Setting the quit flag -// will cause recovery to end after the current batch of blocks. -type recoverySyncer struct { - done chan struct{} - quit uint32 // atomic -} - -// recovery attempts to recover any unspent outputs that pay to any of our -// addresses starting from our birthday, or the wallet's tip (if higher), which -// would indicate resuming a recovery after a restart. -func (w *Wallet) recovery(chainClient chain.Interface, - birthdayBlock *waddrmgr.BlockStamp) error { - - log.Infof("RECOVERY MODE ENABLED -- rescanning for used addresses "+ - "with recovery_window=%d", w.recoveryWindow) - - // Wallet locking must synchronize with the end of recovery, since use of - // keys in recovery is racy with manager IsLocked checks, which could - // result in enrypting data with a zeroed key. - syncer := &recoverySyncer{done: make(chan struct{})} - w.recovering.Store(syncer) - defer close(syncer.done) - - // We'll initialize the recovery manager with a default batch size of - // 2000. - recoveryMgr := NewRecoveryManager( - w.recoveryWindow, recoveryBatchSize, w.chainParams, - ) - - // In the event that this recovery is being resumed, we will need to - // repopulate all found addresses from the database. Ideally, for basic - // recovery, we would only do so for the default scopes, but due to a - // bug in which the wallet would create change addresses outside of the - // default scopes, it's necessary to attempt all registered key scopes. - scopedMgrs := make(map[waddrmgr.KeyScope]waddrmgr.AccountStore) - for _, scopedMgr := range w.addrStore.ActiveScopedKeyManagers() { - scopedMgrs[scopedMgr.Scope()] = scopedMgr - } - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - txMgrNS := tx.ReadBucket(wtxmgrNamespaceKey) - - credits, err := w.txStore.UnspentOutputs(txMgrNS) - if err != nil { - return err - } - addrMgrNS := tx.ReadBucket(waddrmgrNamespaceKey) - return recoveryMgr.Resurrect(addrMgrNS, scopedMgrs, credits) - }) - if err != nil { - return err - } - - // Fetch the best height from the backend to determine when we should - // stop. - _, bestHeight, err := chainClient.GetBestBlock() - if err != nil { - return err - } - - // Now we can begin scanning the chain from the wallet's current tip to - // ensure we properly handle restarts. Since the recovery process itself - // acts as rescan, we'll also update our wallet's synced state along the - // way to reflect the blocks we process and prevent rescanning them - // later on. - // - // NOTE: We purposefully don't update our best height since we assume - // that a wallet rescan will be performed from the wallet's tip, which - // will be of bestHeight after completing the recovery process. - var blocks []*waddrmgr.BlockStamp - - startHeight := w.addrStore.SyncedTo().Height + 1 - for height := startHeight; height <= bestHeight; height++ { - if atomic.LoadUint32(&syncer.quit) == 1 { - return errors.New("recovery: forced shutdown") - } - - hash, err := chainClient.GetBlockHash(int64(height)) - if err != nil { - return err - } - header, err := chainClient.GetBlockHeader(hash) - if err != nil { - return err - } - blocks = append(blocks, &waddrmgr.BlockStamp{ - Hash: *hash, - Height: height, - Timestamp: header.Timestamp, - }) - - // It's possible for us to run into blocks before our birthday - // if our birthday is after our reorg safe height, so we'll make - // sure to not add those to the batch. - if height >= birthdayBlock.Height { - recoveryMgr.AddToBlockBatch( - hash, height, header.Timestamp, - ) - } - - // We'll perform our recovery in batches of 2000 blocks. It's - // possible for us to reach our best height without exceeding - // the recovery batch size, so we can proceed to commit our - // state to disk. - recoveryBatch := recoveryMgr.BlockBatch() - if len(recoveryBatch) == recoveryBatchSize || height == bestHeight { - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - if err := w.recoverScopedAddresses( - chainClient, tx, ns, recoveryBatch, - recoveryMgr.State(), scopedMgrs, - ); err != nil { - return err - } - - // TODO: Any error here will roll back this - // entire tx. This may cause the in memory sync - // point to become desyncronized. Refactor so - // that this cannot happen. - for _, block := range blocks { - err := w.addrStore.SetSyncedTo( - ns, block, - ) - if err != nil { - return err - } - } - - return nil - }) - if err != nil { - return err - } - - if len(recoveryBatch) > 0 { - log.Infof("Recovered addresses from blocks "+ - "%d-%d", recoveryBatch[0].Height, - recoveryBatch[len(recoveryBatch)-1].Height) - } - - // Clear the batch of all processed blocks to reuse the - // same memory for future batches. - blocks = blocks[:0] - recoveryMgr.ResetBlockBatch() - } - } - - return nil -} - -// recoverScopedAddresses scans a range of blocks in attempts to recover any -// previously used addresses for a particular account derivation path. At a high -// level, the algorithm works as follows: -// -// 1. Ensure internal and external branch horizons are fully expanded. -// 2. Filter the entire range of blocks, stopping if a non-zero number of -// address are contained in a particular block. -// 3. Record all internal and external addresses found in the block. -// 4. Record any outpoints found in the block that should be watched for spends -// 5. Trim the range of blocks up to and including the one reporting the addrs. -// 6. Repeat from (1) if there are still more blocks in the range. -// -// TODO(conner): parallelize/pipeline/cache intermediate network requests -func (w *Wallet) recoverScopedAddresses( - chainClient chain.Interface, - tx walletdb.ReadWriteTx, - ns walletdb.ReadWriteBucket, - batch []wtxmgr.BlockMeta, - recoveryState *RecoveryState, - scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore) error { - - // If there are no blocks in the batch, we are done. - if len(batch) == 0 { - return nil - } - - log.Infof("Scanning %d blocks for recoverable addresses", len(batch)) - -expandHorizons: - for scope, scopedMgr := range scopedMgrs { - scopeState := recoveryState.StateForScope(scope) - err := expandScopeHorizons(ns, scopedMgr, scopeState) - if err != nil { - return err - } - } - - // With the internal and external horizons properly expanded, we now - // construct the filter blocks request. The request includes the range - // of blocks we intend to scan, in addition to the scope-index -> addr - // map for all internal and external branches. - filterReq := newFilterBlocksRequest(batch, scopedMgrs, recoveryState) - - // Initiate the filter blocks request using our chain backend. If an - // error occurs, we are unable to proceed with the recovery. - filterResp, err := chainClient.FilterBlocks(filterReq) - if err != nil { - return err - } - - // If the filter response is empty, this signals that the rest of the - // batch was completed, and no other addresses were discovered. As a - // result, no further modifications to our recovery state are required - // and we can proceed to the next batch. - if filterResp == nil { - return nil - } - - // Otherwise, retrieve the block info for the block that detected a - // non-zero number of address matches. - block := batch[filterResp.BatchIndex] - - // Log any non-trivial findings of addresses or outpoints. - logFilterBlocksResp(block, filterResp) - - // Report any external or internal addresses found as a result of the - // appropriate branch recovery state. Adding indexes above the - // last-found index of either will result in the horizons being expanded - // upon the next iteration. Any found addresses are also marked used - // using the scoped key manager. - err = extendFoundAddresses(ns, filterResp, scopedMgrs, recoveryState) - if err != nil { - return err - } - - // Update the global set of watched outpoints with any that were found - // in the block. - for outPoint, addr := range filterResp.FoundOutPoints { - outPoint := outPoint - recoveryState.AddWatchedOutPoint(&outPoint, addr) - } - - // Finally, record all of the relevant transactions that were returned - // in the filter blocks response. This ensures that these transactions - // and their outputs are tracked when the final rescan is performed. - for _, txn := range filterResp.RelevantTxns { - txRecord, err := wtxmgr.NewTxRecordFromMsgTx( - txn, filterResp.BlockMeta.Time, - ) - if err != nil { - return err - } - - err = w.addRelevantTx(tx, txRecord, &filterResp.BlockMeta) - if err != nil { - return err - } - } - - // Update the batch to indicate that we've processed all block through - // the one that returned found addresses. - batch = batch[filterResp.BatchIndex+1:] - - // If this was not the last block in the batch, we will repeat the - // filtering process again after expanding our horizons. - if len(batch) > 0 { - goto expandHorizons - } - - return nil -} - -// expandScopeHorizons ensures that the ScopeRecoveryState has an adequately -// sized look ahead for both its internal and external branches. The keys -// derived here are added to the scope's recovery state, but do not affect the -// persistent state of the wallet. If any invalid child keys are detected, the -// horizon will be properly extended such that our lookahead always includes the -// proper number of valid child keys. -func expandScopeHorizons(ns walletdb.ReadWriteBucket, - scopedMgr waddrmgr.AccountStore, - scopeState *ScopeRecoveryState) error { - - // Compute the current external horizon and the number of addresses we - // must derive to ensure we maintain a sufficient recovery window for - // the external branch. - exHorizon, exWindow := scopeState.ExternalBranch.ExtendHorizon() - count, childIndex := uint32(0), exHorizon - for count < exWindow { - keyPath := externalKeyPath(childIndex) - addr, err := scopedMgr.DeriveFromKeyPath(ns, keyPath) - switch { - case err == hdkeychain.ErrInvalidChild: - // Record the existence of an invalid child with the - // external branch's recovery state. This also - // increments the branch's horizon so that it accounts - // for this skipped child index. - scopeState.ExternalBranch.MarkInvalidChild(childIndex) - childIndex++ - continue - - case err != nil: - return err - } - - // Register the newly generated external address and child index - // with the external branch recovery state. - scopeState.ExternalBranch.AddAddr(childIndex, addr.Address()) - - childIndex++ - count++ - } - - // Compute the current internal horizon and the number of addresses we - // must derive to ensure we maintain a sufficient recovery window for - // the internal branch. - inHorizon, inWindow := scopeState.InternalBranch.ExtendHorizon() - count, childIndex = 0, inHorizon - for count < inWindow { - keyPath := internalKeyPath(childIndex) - addr, err := scopedMgr.DeriveFromKeyPath(ns, keyPath) - switch { - case err == hdkeychain.ErrInvalidChild: - // Record the existence of an invalid child with the - // internal branch's recovery state. This also - // increments the branch's horizon so that it accounts - // for this skipped child index. - scopeState.InternalBranch.MarkInvalidChild(childIndex) - childIndex++ - continue - - case err != nil: - return err - } - - // Register the newly generated internal address and child index - // with the internal branch recovery state. - scopeState.InternalBranch.AddAddr(childIndex, addr.Address()) - - childIndex++ - count++ - } - - return nil -} - -// externalKeyPath returns the relative external derivation path /0/0/index. -func externalKeyPath(index uint32) waddrmgr.DerivationPath { - return waddrmgr.DerivationPath{ - InternalAccount: waddrmgr.DefaultAccountNum, - Account: waddrmgr.DefaultAccountNum, - Branch: waddrmgr.ExternalBranch, - Index: index, - } -} - -// internalKeyPath returns the relative internal derivation path /0/1/index. -func internalKeyPath(index uint32) waddrmgr.DerivationPath { - return waddrmgr.DerivationPath{ - InternalAccount: waddrmgr.DefaultAccountNum, - Account: waddrmgr.DefaultAccountNum, - Branch: waddrmgr.InternalBranch, - Index: index, - } -} - -// newFilterBlocksRequest constructs FilterBlocksRequests using our current -// block range, scoped managers, and recovery state. -func newFilterBlocksRequest(batch []wtxmgr.BlockMeta, - scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, - recoveryState *RecoveryState) *chain.FilterBlocksRequest { - - filterReq := &chain.FilterBlocksRequest{ - Blocks: batch, - ExternalAddrs: make(map[waddrmgr.ScopedIndex]btcutil.Address), - InternalAddrs: make(map[waddrmgr.ScopedIndex]btcutil.Address), - WatchedOutPoints: recoveryState.WatchedOutPoints(), - } +// locateBirthdayBlock returns a block that meets the given birthday timestamp +// by a margin of +/-2 hours. This is safe to do as the timestamp is already 2 +// days in the past of the actual timestamp. +func locateBirthdayBlock(chainClient chainConn, + birthday time.Time) (*waddrmgr.BlockStamp, error) { - // Populate the external and internal addresses by merging the addresses - // sets belong to all currently tracked scopes. - for scope := range scopedMgrs { - scopeState := recoveryState.StateForScope(scope) - for index, addr := range scopeState.ExternalBranch.Addrs() { - scopedIndex := waddrmgr.ScopedIndex{ - Scope: scope, - Index: index, - } - filterReq.ExternalAddrs[scopedIndex] = addr - } - for index, addr := range scopeState.InternalBranch.Addrs() { - scopedIndex := waddrmgr.ScopedIndex{ - Scope: scope, - Index: index, - } - filterReq.InternalAddrs[scopedIndex] = addr - } + // Retrieve the lookup range for our block. + startHeight := int32(0) + _, bestHeight, err := chainClient.GetBestBlock() + if err != nil { + return nil, err } - return filterReq -} - -// extendFoundAddresses accepts a filter blocks response that contains addresses -// found on chain, and advances the state of all relevant derivation paths to -// match the highest found child index for each branch. -func extendFoundAddresses(ns walletdb.ReadWriteBucket, - filterResp *chain.FilterBlocksResponse, - scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, - recoveryState *RecoveryState) error { - - // Mark all recovered external addresses as used. This will be done only - // for scopes that reported a non-zero number of external addresses in - // this block. - for scope, indexes := range filterResp.FoundExternalAddrs { - // First, report all external child indexes found for this - // scope. This ensures that the external last-found index will - // be updated to include the maximum child index seen thus far. - scopeState := recoveryState.StateForScope(scope) - for index := range indexes { - scopeState.ExternalBranch.ReportFound(index) - } - - scopedMgr := scopedMgrs[scope] - - // Now, with all found addresses reported, derive and extend all - // external addresses up to and including the current last found - // index for this scope. - exNextUnfound := scopeState.ExternalBranch.NextUnfound() + log.Debugf("Locating suitable block for birthday %v between blocks "+ + "%v-%v", birthday, startHeight, bestHeight) - exLastFound := exNextUnfound - if exLastFound > 0 { - exLastFound-- - } + var ( + birthdayBlock *waddrmgr.BlockStamp + left, right = startHeight, bestHeight + ) - err := scopedMgr.ExtendExternalAddresses( - ns, waddrmgr.DefaultAccountNum, exLastFound, - ) + // Binary search for a block that meets the birthday timestamp by a + // margin of +/-2 hours. + for { + // Retrieve the timestamp for the block halfway through our + // range. + mid := left + (right-left)/2 + hash, err := chainClient.GetBlockHash(int64(mid)) if err != nil { - return err - } - - // Finally, with the scope's addresses extended, we mark used - // the external addresses that were found in the block and - // belong to this scope. - for index := range indexes { - addr := scopeState.ExternalBranch.GetAddr(index) - err := scopedMgr.MarkUsed(ns, addr) - if err != nil { - return err - } - } - } - - // Mark all recovered internal addresses as used. This will be done only - // for scopes that reported a non-zero number of internal addresses in - // this block. - for scope, indexes := range filterResp.FoundInternalAddrs { - // First, report all internal child indexes found for this - // scope. This ensures that the internal last-found index will - // be updated to include the maximum child index seen thus far. - scopeState := recoveryState.StateForScope(scope) - for index := range indexes { - scopeState.InternalBranch.ReportFound(index) - } - - scopedMgr := scopedMgrs[scope] - - // Now, with all found addresses reported, derive and extend all - // internal addresses up to and including the current last found - // index for this scope. - inNextUnfound := scopeState.InternalBranch.NextUnfound() - - inLastFound := inNextUnfound - if inLastFound > 0 { - inLastFound-- + return nil, err } - err := scopedMgr.ExtendInternalAddresses( - ns, waddrmgr.DefaultAccountNum, inLastFound, - ) + header, err := chainClient.GetBlockHeader(hash) if err != nil { - return err - } - - // Finally, with the scope's addresses extended, we mark used - // the internal addresses that were found in the blockand belong - // to this scope. - for index := range indexes { - addr := scopeState.InternalBranch.GetAddr(index) - err := scopedMgr.MarkUsed(ns, addr) - if err != nil { - return err - } + return nil, err } - } - - return nil -} - -// logFilterBlocksResp provides useful logging information when filtering -// succeeded in finding relevant transactions. -func logFilterBlocksResp(block wtxmgr.BlockMeta, - resp *chain.FilterBlocksResponse) { - - // Log the number of external addresses found in this block. - var nFoundExternal int - for _, indexes := range resp.FoundExternalAddrs { - nFoundExternal += len(indexes) - } - if nFoundExternal > 0 { - log.Infof("Recovered %d external addrs at height=%d hash=%v", - nFoundExternal, block.Height, block.Hash) - } - - // Log the number of internal addresses found in this block. - var nFoundInternal int - for _, indexes := range resp.FoundInternalAddrs { - nFoundInternal += len(indexes) - } - if nFoundInternal > 0 { - log.Infof("Recovered %d internal addrs at height=%d hash=%v", - nFoundInternal, block.Height, block.Hash) - } - - // Log the number of outpoints found in this block. - nFoundOutPoints := len(resp.FoundOutPoints) - if nFoundOutPoints > 0 { - log.Infof("Found %d spends from watched outpoints at "+ - "height=%d hash=%v", - nFoundOutPoints, block.Height, block.Hash) - } -} - -type ( - createTxRequest struct { - coinSelectKeyScope *waddrmgr.KeyScope - changeKeyScope *waddrmgr.KeyScope - account uint32 - outputs []*wire.TxOut - minconf int32 - feeSatPerKB btcutil.Amount - coinSelectionStrategy CoinSelectionStrategy - dryRun bool - resp chan createTxResponse - selectUtxos []wire.OutPoint - allowUtxo func(wtxmgr.Credit) bool - } - createTxResponse struct { - tx *txauthor.AuthoredTx - err error - } -) -// txCreator is responsible for the input selection and creation of -// transactions. These functions are the responsibility of this method -// (designed to be run as its own goroutine) since input selection must be -// serialized, or else it is possible to create double spends by choosing the -// same inputs for multiple transactions. Along with input selection, this -// method is also responsible for the signing of transactions, since we don't -// want to end up in a situation where we run out of inputs as multiple -// transactions are being created. In this situation, it would then be possible -// for both requests, rather than just one, to fail due to not enough available -// inputs. -func (w *Wallet) txCreator() { - quit := w.quitChan() -out: - for { - select { - case txr := <-w.createTxRequests: - // If the wallet can be locked because it contains - // private key material, we need to prevent it from - // doing so while we are assembling the transaction. - release := func() {} - if !w.addrStore.WatchOnly() { - heldUnlock, err := w.holdUnlock() - if err != nil { - txr.resp <- createTxResponse{nil, err} - continue - } + log.Debugf("Checking candidate block: height=%v, hash=%v, "+ + "timestamp=%v", mid, hash, header.Timestamp) - release = heldUnlock.release + // If the search happened to reach either of our range extremes, + // then we'll just use that as there's nothing left to search. + if mid == startHeight || mid == bestHeight || mid == left { + birthdayBlock = &waddrmgr.BlockStamp{ + Hash: *hash, + Height: mid, + Timestamp: header.Timestamp, } - - tx, err := w.txToOutputs( - txr.outputs, txr.coinSelectKeyScope, - txr.changeKeyScope, txr.account, txr.minconf, - txr.feeSatPerKB, txr.coinSelectionStrategy, - txr.dryRun, txr.selectUtxos, txr.allowUtxo, - ) - - release() - txr.resp <- createTxResponse{tx, err} - case <-quit: - break out + break } - } - w.wg.Done() -} - -// txCreateOptions is a set of optional arguments to modify the tx creation -// process. This can be used to do things like use a custom coin selection -// scope, which otherwise will default to the specified coin selection scope. -type txCreateOptions struct { - changeKeyScope *waddrmgr.KeyScope - selectUtxos []wire.OutPoint - allowUtxo func(wtxmgr.Credit) bool -} - -// TxCreateOption is a set of optional arguments to modify the tx creation -// process. This can be used to do things like use a custom coin selection -// scope, which otherwise will default to the specified coin selection scope. -type TxCreateOption func(*txCreateOptions) - -// defaultTxCreateOptions is the default set of options. -func defaultTxCreateOptions() *txCreateOptions { - return &txCreateOptions{} -} - -// WithCustomChangeScope can be used to specify a change scope for the change -// address. If unspecified, then the same scope will be used for both inputs -// and the change addr. Not specifying any scope at all (nil) will use all -// available coins and the default change scope (P2TR). -func WithCustomChangeScope(changeScope *waddrmgr.KeyScope) TxCreateOption { - return func(opts *txCreateOptions) { - opts.changeKeyScope = changeScope - } -} - -// WithCustomSelectUtxos is used to specify the inputs to be used while -// creating txns. -func WithCustomSelectUtxos(utxos []wire.OutPoint) TxCreateOption { - return func(opts *txCreateOptions) { - opts.selectUtxos = utxos - } -} - -// WithUtxoFilter is used to restrict the selection of the internal wallet -// inputs by further external conditions. Utxos which pass the filter are -// considered when creating the transaction. -func WithUtxoFilter(allowUtxo func(utxo wtxmgr.Credit) bool) TxCreateOption { - return func(opts *txCreateOptions) { - opts.allowUtxo = allowUtxo - } -} - -type ( - unlockRequest struct { - passphrase []byte - lockAfter <-chan time.Time // nil prevents the timeout. - err chan error - } - - changePassphraseRequest struct { - old, new []byte - private bool - err chan error - } - - changePassphrasesRequest struct { - publicOld, publicNew []byte - privateOld, privateNew []byte - err chan error - } - - // heldUnlock is a tool to prevent the wallet from automatically - // locking after some timeout before an operation which needed - // the unlocked wallet has finished. Any acquired heldUnlock - // *must* be released (preferably with a defer) or the wallet - // will forever remain unlocked. - heldUnlock chan struct{} -) - -// endRecovery tells (*Wallet).recovery to stop, if running, and returns a -// channel that will be closed when the recovery routine exits. -func (w *Wallet) endRecovery() <-chan struct{} { - if recoverySyncI := w.recovering.Load(); recoverySyncI != nil { - recoverySync := recoverySyncI.(*recoverySyncer) - - // If recovery is still running, it will end early with an error - // once we set the quit flag. - atomic.StoreUint32(&recoverySync.quit, 1) - - return recoverySync.done - } - c := make(chan struct{}) - close(c) - return c -} - -// walletLocker manages the locked/unlocked state of a wallet. -func (w *Wallet) walletLocker() { - var timeout <-chan time.Time - holdChan := make(heldUnlock) - quit := w.quitChan() -out: - for { - select { - case req := <-w.unlockRequests: - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - - return w.addrStore.Unlock( - addrmgrNs, req.passphrase, - ) - }) - if err != nil { - req.err <- err - continue - } - timeout = req.lockAfter - if timeout == nil { - log.Info("The wallet has been unlocked without a time limit") - } else { - log.Info("The wallet has been temporarily unlocked") - } - req.err <- nil - continue - - case req := <-w.changePassphrase: - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - - return w.addrStore.ChangePassphrase( - addrmgrNs, req.old, req.new, req.private, - &waddrmgr.DefaultScryptOptions, - ) - }) - req.err <- err - continue - - case req := <-w.changePassphrases: - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - - err := w.addrStore.ChangePassphrase( - addrmgrNs, req.publicOld, req.publicNew, - false, &waddrmgr.DefaultScryptOptions, - ) - if err != nil { - return err - } - return w.addrStore.ChangePassphrase( - addrmgrNs, req.privateOld, req.privateNew, - true, &waddrmgr.DefaultScryptOptions, - ) - }) - req.err <- err + // The block's timestamp is more than 2 hours after the + // birthday, so look for a lower block. + if header.Timestamp.Sub(birthday) > birthdayBlockDelta { + right = mid continue + } - case req := <-w.holdUnlockRequests: - if w.addrStore.IsLocked() { - close(req) - continue - } - - req <- holdChan - <-holdChan // Block until the lock is released. - - // If, after holding onto the unlocked wallet for some - // time, the timeout has expired, lock it now instead - // of hoping it gets unlocked next time the top level - // select runs. - select { - case <-timeout: - // Let the top level select fallthrough so the - // wallet is locked. - default: - continue - } - - case w.lockState <- w.addrStore.IsLocked(): + // The birthday is more than 2 hours before the block's + // timestamp, so look for a higher block. + if header.Timestamp.Sub(birthday) < -birthdayBlockDelta { + left = mid continue + } - case <-quit: - break out - - case <-w.lockRequests: - case <-timeout: + birthdayBlock = &waddrmgr.BlockStamp{ + Hash: *hash, + Height: mid, + Timestamp: header.Timestamp, } + break + } - // Select statement fell through by an explicit lock or the - // timer expiring. Lock the manager here. + log.Debugf("Found birthday block: height=%d, hash=%v, timestamp=%v", + birthdayBlock.Height, birthdayBlock.Hash, + birthdayBlock.Timestamp) - // We can't lock the manager if recovery is active because we use - // cryptoKeyPriv and cryptoKeyScript in recovery. - <-w.endRecovery() + return birthdayBlock, nil +} - timeout = nil +// Wallet is a structure containing all the components for a +// complete wallet. It contains the Armory-style key store +// addresses and keys), +// Wallet is a structure containing all the components for a complete wallet. +// It manages the cryptographic keys, transaction history, and synchronization +// with the blockchain. +type Wallet struct { + // walletDeprecated embeds the legacy state and channels. Access to + // these should be phased out as refactoring progresses. + *walletDeprecated - err := w.addrStore.Lock() - if err != nil && !waddrmgr.IsError(err, waddrmgr.ErrLocked) { - log.Errorf("Could not lock wallet: %v", err) - } else { - log.Info("The wallet has been locked") - } - } - w.wg.Done() -} + // publicPassphrase is the passphrase used to encrypt and decrypt public + // data in the address manager. + publicPassphrase []byte -// Locked returns whether the account manager for a wallet is locked. -func (w *Wallet) Locked() bool { - return <-w.lockState -} + // db is the underlying key-value database where all wallet data is + // persisted. + db walletdb.DB -// holdUnlock prevents the wallet from being locked. The heldUnlock object -// *must* be released, or the wallet will forever remain unlocked. -// -// TODO: To prevent the above scenario, perhaps closures should be passed -// to the walletLocker goroutine and disallow callers from explicitly - -// handling the locking mechanism. -func (w *Wallet) holdUnlock() (heldUnlock, error) { - req := make(chan heldUnlock) - w.holdUnlockRequests <- req - hl, ok := <-req - if !ok { - // TODO(davec): This should be defined and exported from - // waddrmgr. - return nil, waddrmgr.ManagerError{ - ErrorCode: waddrmgr.ErrLocked, - Description: "address manager is locked", - } - } - return hl, nil -} + // addrStore is the address and key manager responsible for hierarchical + // deterministic (HD) derivation and storage of cryptographic keys. + addrStore waddrmgr.AddrStore -// release releases the hold on the unlocked-state of the wallet and allows the -// wallet to be locked again. If a lock timeout has already expired, the -// wallet is locked again as soon as release is called. -func (c heldUnlock) release() { - c <- struct{}{} -} + // txStore is the transaction manager responsible for storing and + // querying the wallet's transaction history and unspent outputs. + txStore wtxmgr.TxStore -// ChangePrivatePassphrase attempts to change the passphrase for a wallet from -// old to new. Changing the passphrase is synchronized with all other address -// manager locking and unlocking. The lock state will be the same as it was -// before the password change. -func (w *Wallet) ChangePrivatePassphrase(old, new []byte) error { - err := make(chan error, 1) - w.changePassphrase <- changePassphraseRequest{ - old: old, - new: new, - private: true, - err: err, - } - return <-err -} + // recoveryWindow specifies the number of additional keys to derive + // beyond the last used one to look for previously used addresses + // during a rescan or recovery. + recoveryWindow uint32 -// ChangePublicPassphrase modifies the public passphrase of the wallet. -func (w *Wallet) ChangePublicPassphrase(old, new []byte) error { - err := make(chan error, 1) - w.changePassphrase <- changePassphraseRequest{ - old: old, - new: new, - private: false, - err: err, - } - return <-err -} + // NtfnServer handles the delivery of wallet-related events (e.g., new + // transactions, block connections) to connected clients. + NtfnServer *NotificationServer -// ChangePassphrases modifies the public and private passphrase of the wallet -// atomically. -func (w *Wallet) ChangePassphrases(publicOld, publicNew, privateOld, - privateNew []byte) error { - - err := make(chan error, 1) - w.changePassphrases <- changePassphrasesRequest{ - publicOld: publicOld, - publicNew: publicNew, - privateOld: privateOld, - privateNew: privateNew, - err: err, - } - return <-err + // wg is a wait group used to track and wait for all long-running + // background goroutines to finish during a graceful shutdown. + wg sync.WaitGroup } // AccountAddresses returns the addresses for every created address for an @@ -1831,22 +573,6 @@ func (w *Wallet) AccountOfAddress(a btcutil.Address) (uint32, error) { return account, err } -// AddressInfoDeprecated returns detailed information regarding a wallet -// address. -func (w *Wallet) AddressInfoDeprecated(a btcutil.Address) ( - waddrmgr.ManagedAddress, error) { - - var managedAddress waddrmgr.ManagedAddress - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - var err error - - managedAddress, err = w.addrStore.Address(addrmgrNs, a) - return err - }) - return managedAddress, err -} - // AccountNumber returns the account number for an account name under a // particular key scope. func (w *Wallet) AccountNumber(scope waddrmgr.KeyScope, accountName string) (uint32, error) { From 37e48a5192957cea5092f696c60207962ce3f72b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 20 Dec 2025 16:25:31 +0800 Subject: [PATCH 228/691] wallet: refactor wallet.go by moving legacy methods to deprecated.go This commit refactors wallet.go to be a lean central coordination point, containing only the lifecycle (Open/Create), core configuration, and modern/stable API methods. All legacy methods, including RPC-specific logic, deprecated helpers (like InitAccounts, AddScopeManager, DeriveFromKeyPath), and methods replaced by new Managers, have been moved to deprecated.go. The walletDeprecated struct is used to encapsulate legacy state. --- wallet/deprecated.go | 2462 ++++++++++++++++++++++++++++++++++++++++ wallet/wallet.go | 2559 +----------------------------------------- 2 files changed, 2517 insertions(+), 2504 deletions(-) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index f14973dded..983791a71f 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -4,12 +4,17 @@ package wallet import ( "bytes" "context" + "encoding/hex" "errors" "fmt" + "sort" "sync" "sync/atomic" "time" + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/psbt" @@ -23,6 +28,7 @@ import ( "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/davecgh/go-spew/spew" ) // NextAccount creates the next account and returns its account number. The @@ -2310,6 +2316,2462 @@ func (w *Wallet) ChangePassphrases(publicOld, publicNew, privateOld, return <-err } +// AccountAddresses returns the addresses for every created address for an +// account. +func (w *Wallet) AccountAddresses(account uint32) (addrs []btcutil.Address, err error) { + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + return w.addrStore.ForEachAccountAddress( + addrmgrNs, account, + func(maddr waddrmgr.ManagedAddress) error { + addrs = append(addrs, maddr.Address()) + return nil + }) + }) + return +} + +// AccountManagedAddresses returns the managed addresses for every created +// address for an account. +func (w *Wallet) AccountManagedAddresses(scope waddrmgr.KeyScope, + accountNum uint32) ([]waddrmgr.ManagedAddress, error) { + + scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + addrs := make([]waddrmgr.ManagedAddress, 0) + + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + return scopedMgr.ForEachAccountAddress( + addrmgrNs, accountNum, + func(a waddrmgr.ManagedAddress) error { + addrs = append(addrs, a) + + return nil + }, + ) + }, + ) + if err != nil { + return nil, err + } + + return addrs, nil +} + +// CalculateBalance sums the amounts of all unspent transaction +// outputs to addresses of a wallet and returns the balance. +// +// If confirmations is 0, all UTXOs, even those not present in a +// block (height -1), will be used to get the balance. Otherwise, +// a UTXO must be in a block. If confirmations is 1 or greater, +// the balance will be calculated based on how many how many blocks +// include a UTXO. +func (w *Wallet) CalculateBalance(confirms int32) (btcutil.Amount, error) { + var balance btcutil.Amount + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + var err error + + blk := w.addrStore.SyncedTo() + balance, err = w.txStore.Balance(txmgrNs, confirms, blk.Height) + + return err + }) + return balance, err +} + +// Balances records total, spendable (by policy), and immature coinbase +// reward balance amounts. +type Balances struct { + Total btcutil.Amount + Spendable btcutil.Amount + ImmatureReward btcutil.Amount +} + +// CalculateAccountBalances sums the amounts of all unspent transaction +// outputs to the given account of a wallet and returns the balance. +// +// This function is much slower than it needs to be since transactions outputs +// are not indexed by the accounts they credit to, and all unspent transaction +// outputs must be iterated. +func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balances, error) { + var bals Balances + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // Get current block. The block height used for calculating + // the number of tx confirmations. + syncBlock := w.addrStore.SyncedTo() + + unspent, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + for i := range unspent { + output := &unspent[i] + + var outputAcct uint32 + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + output.PkScript, w.chainParams) + if err == nil && len(addrs) > 0 { + _, outputAcct, err = w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) + } + if err != nil || outputAcct != account { + continue + } + + bals.Total += output.Amount + if output.FromCoinBase && !hasMinConfs( + uint32(w.chainParams.CoinbaseMaturity), + output.Height, syncBlock.Height, + ) { + + bals.ImmatureReward += output.Amount + } else if hasMinConfs( + //nolint:gosec + uint32(confirms), output.Height, + syncBlock.Height, + ) { + + bals.Spendable += output.Amount + } + } + return nil + }) + return bals, err +} + +// CurrentAddress gets the most recently requested Bitcoin payment address +// from a wallet for a particular key-chain scope. If the address has already +// been used (there is at least one transaction spending to it in the +// blockchain or btcd mempool), the next chained address is returned. +func (w *Wallet) CurrentAddress(account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) { + chainClient, err := w.requireChainClient() + if err != nil { + return nil, err + } + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + // The address manager uses OnCommit on the walletdb tx to update the + // in-memory state of the account state. But because the commit happens + // _after_ the account manager internal lock has been released, there + // is a chance for the address index to be accessed concurrently, even + // though the closure in OnCommit re-acquires the lock. To avoid this + // issue, we surround the whole address creation process with a lock. + w.newAddrMtx.Lock() + defer w.newAddrMtx.Unlock() + + var ( + addr btcutil.Address + props *waddrmgr.AccountProperties + ) + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + maddr, err := manager.LastExternalAddress(addrmgrNs, account) + if err != nil { + // If no address exists yet, create the first external + // address. + if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { + addr, props, err = w.newAddressDeprecated( + addrmgrNs, account, scope, + ) + } + return err + } + + // Get next chained address if the last one has already been + // used. + if maddr.Used(addrmgrNs) { + addr, props, err = w.newAddressDeprecated( + addrmgrNs, account, scope, + ) + return err + } + + addr = maddr.Address() + return nil + }) + if err != nil { + return nil, err + } + + // If the props have been initially, then we had to create a new address + // to satisfy the query. Notify the rpc server about the new address. + if props != nil { + err = chainClient.NotifyReceived([]btcutil.Address{addr}) + if err != nil { + return nil, err + } + + w.NtfnServer.notifyAccountProperties(props) + } + + return addr, nil +} + +// PubKeyForAddress looks up the associated public key for a P2PKH address. +func (w *Wallet) PubKeyForAddress(a btcutil.Address) (*btcec.PublicKey, error) { + var pubKey *btcec.PublicKey + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddr, err := w.addrStore.Address(addrmgrNs, a) + if err != nil { + return err + } + managedPubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return errors.New("address does not have an associated public key") + } + pubKey = managedPubKeyAddr.PubKey() + return nil + }) + return pubKey, err +} + +// LabelTransaction adds a label to the transaction with the hash provided. The +// call will fail if the label is too long, or if the transaction already has +// a label and the overwrite boolean is not set. +func (w *Wallet) LabelTransaction(hash chainhash.Hash, label string, + overwrite bool) error { + + // Check that the transaction is known to the wallet, and fail if it is + // unknown. If the transaction is known, check whether it already has + // a label. + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + dbTx, err := w.txStore.TxDetails(txmgrNs, &hash) + if err != nil { + return err + } + + // If the transaction looked up is nil, it was not found. We + // do not allow labelling of unknown transactions so we fail. + if dbTx == nil { + return ErrUnknownTransaction + } + + _, err = w.txStore.FetchTxLabel(txmgrNs, hash) + return err + }) + + switch err { + // If no labels have been written yet, we can silence the error. + // Likewise if there is no label, we do not need to do any overwrite + // checks. + case wtxmgr.ErrNoLabelBucket: + case wtxmgr.ErrTxLabelNotFound: + + // If we successfully looked up a label, fail if the overwrite param + // is not set. + case nil: + if !overwrite { + return ErrTxLabelExists + } + + // In another unrelated error occurred, return it. + default: + return err + } + + return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + return w.txStore.PutTxLabel(txmgrNs, hash, label) + }) +} + +// HaveAddress returns whether the wallet is the owner of the address a. +func (w *Wallet) HaveAddress(a btcutil.Address) (bool, error) { + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + _, err := w.addrStore.Address(addrmgrNs, a) + return err + }) + if err == nil { + return true, nil + } + if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { + return false, nil + } + return false, err +} + +// AccountOfAddress finds the account that an address is associated with. +func (w *Wallet) AccountOfAddress(a btcutil.Address) (uint32, error) { + var account uint32 + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + var err error + + _, account, err = w.addrStore.AddrAccount(addrmgrNs, a) + return err + }) + return account, err +} + +// DumpPrivKeys returns the WIF-encoded private keys for all addresses with +// private keys in a wallet. +func (w *Wallet) DumpPrivKeys() ([]string, error) { + var privkeys []string + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + // Iterate over each active address, appending the private key to + return w.addrStore.ForEachActiveAddress( + addrmgrNs, func(addr btcutil.Address) error { + ma, err := w.addrStore.Address(addrmgrNs, addr) + if err != nil { + return err + } + + // Only those addresses with keys needed. + pka, ok := ma.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil + } + + wif, err := pka.ExportPrivKey() + if err != nil { + // It would be nice to zero out the + // array here. However, since strings + // in go are immutable, and we have no + // control over the caller I don't + // think we can. :( + return err + } + + privkeys = append(privkeys, wif.String()) + + return nil + }) + }) + return privkeys, err +} + +// DumpWIFPrivateKey returns the WIF encoded private key for a +// single wallet address. +func (w *Wallet) DumpWIFPrivateKey(addr btcutil.Address) (string, error) { + var maddr waddrmgr.ManagedAddress + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + waddrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + // Get private key from wallet if it exists. + var err error + + maddr, err = w.addrStore.Address(waddrmgrNs, addr) + return err + }) + if err != nil { + return "", err + } + + pka, ok := maddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return "", fmt.Errorf("address %s is not a key type", addr) + } + + wif, err := pka.ExportPrivKey() + if err != nil { + return "", err + } + return wif.String(), nil +} + +// LockOutpoint marks an outpoint as locked, that is, it should not be used as +// an input for newly created transactions. +func (w *Wallet) LockOutpoint(op wire.OutPoint) { + w.lockedOutpointsMtx.Lock() + defer w.lockedOutpointsMtx.Unlock() + + w.lockedOutpoints[op] = struct{}{} +} + +// UnlockOutpoint marks an outpoint as unlocked, that is, it may be used as an +// input for newly created transactions. +func (w *Wallet) UnlockOutpoint(op wire.OutPoint) { + w.lockedOutpointsMtx.Lock() + defer w.lockedOutpointsMtx.Unlock() + + delete(w.lockedOutpoints, op) +} + +// ResetLockedOutpoints resets the set of locked outpoints so all may be used +// as inputs for new transactions. +func (w *Wallet) ResetLockedOutpoints() { + w.lockedOutpointsMtx.Lock() + defer w.lockedOutpointsMtx.Unlock() + + w.lockedOutpoints = map[wire.OutPoint]struct{}{} +} + +// LockedOutpoints returns a slice of currently locked outpoints. This is +// intended to be used by marshaling the result as a JSON array for +// listlockunspent RPC results. +func (w *Wallet) LockedOutpoints() []btcjson.TransactionInput { + w.lockedOutpointsMtx.Lock() + defer w.lockedOutpointsMtx.Unlock() + + locked := make([]btcjson.TransactionInput, len(w.lockedOutpoints)) + i := 0 + for op := range w.lockedOutpoints { + locked[i] = btcjson.TransactionInput{ + Txid: op.Hash.String(), + Vout: op.Index, + } + i++ + } + return locked +} + +// LeaseOutputDeprecated locks an output to the given ID, preventing it from +// being available for coin selection. The absolute time of the lock's +// expiration is +// returned. The expiration of the lock can be extended by successive +// invocations of this call. +// +// Outputs can be unlocked before their expiration through `UnlockOutput`. +// Otherwise, they are unlocked lazily through calls which iterate through all +// known outputs, e.g., `CalculateBalance`, `ListUnspent`. +// +// If the output is not known, ErrUnknownOutput is returned. If the output has +// already been locked to a different ID, then ErrOutputAlreadyLocked is +// returned. +// +// NOTE: This differs from LockOutpoint in that outputs are locked for a limited +// amount of time and their locks are persisted to disk. +// +// Deprecated: Use UtxoManager.LeaseOutput instead. +func (w *Wallet) LeaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint, + duration time.Duration) (time.Time, error) { + + var expiry time.Time + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + var err error + + expiry, err = w.txStore.LockOutput(ns, id, op, duration) + return err + }) + return expiry, err +} + +// ReleaseOutputDeprecated unlocks an output, allowing it to be available for +// coin selection if it remains unspent. The ID should match the one used to +// originally lock the output. +// +// Deprecated: Use UtxoManager.ReleaseOutput instead. +func (w *Wallet) ReleaseOutputDeprecated( + id wtxmgr.LockID, op wire.OutPoint) error { + + return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + return w.txStore.UnlockOutput(ns, id, op) + }) +} + +// resendUnminedTxs iterates through all transactions that spend from wallet +// credits that are not known to have been mined into a block, and attempts +// to send each to the chain server for relay. +func (w *Wallet) resendUnminedTxs() { + var txs []*wire.MsgTx + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + var err error + + txs, err = w.txStore.UnminedTxs(txmgrNs) + return err + }) + if err != nil { + log.Errorf("Unable to retrieve unconfirmed transactions to "+ + "resend: %v", err) + return + } + + for _, tx := range txs { + txHash, err := w.publishTransaction(tx) + if err != nil { + log.Debugf("Unable to rebroadcast transaction %v: %v", + tx.TxHash(), err) + continue + } + + log.Debugf("Successfully rebroadcast unconfirmed transaction %v", + txHash) + } +} + +// SortedActivePaymentAddresses returns a slice of all active payment +// addresses in a wallet. +func (w *Wallet) SortedActivePaymentAddresses() ([]string, error) { + var addrStrs []string + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + return w.addrStore.ForEachActiveAddress( + addrmgrNs, func(addr btcutil.Address) error { + addrStrs = append( + addrStrs, addr.EncodeAddress(), + ) + + return nil + }) + }) + if err != nil { + return nil, err + } + + sort.Strings(addrStrs) + return addrStrs, nil +} + +// NewAddressDeprecated returns the next external chained address for a wallet. +func (w *Wallet) NewAddressDeprecated(account uint32, + scope waddrmgr.KeyScope) (btcutil.Address, error) { + + chainClient, err := w.requireChainClient() + if err != nil { + return nil, err + } + + // The address manager uses OnCommit on the walletdb tx to update the + // in-memory state of the account state. But because the commit happens + // _after_ the account manager internal lock has been released, there + // is a chance for the address index to be accessed concurrently, even + // though the closure in OnCommit re-acquires the lock. To avoid this + // issue, we surround the whole address creation process with a lock. + w.newAddrMtx.Lock() + defer w.newAddrMtx.Unlock() + + var ( + addr btcutil.Address + props *waddrmgr.AccountProperties + ) + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + var err error + + addr, props, err = w.newAddressDeprecated( + addrmgrNs, account, scope, + ) + return err + }) + if err != nil { + return nil, err + } + + // Notify the rpc server about the newly created address. + err = chainClient.NotifyReceived([]btcutil.Address{addr}) + if err != nil { + return nil, err + } + + w.NtfnServer.notifyAccountProperties(props) + + return addr, nil +} + +// NewChangeAddress returns a new change address for a wallet. +func (w *Wallet) NewChangeAddress(account uint32, + scope waddrmgr.KeyScope) (btcutil.Address, error) { + + chainClient, err := w.requireChainClient() + if err != nil { + return nil, err + } + + // The address manager uses OnCommit on the walletdb tx to update the + // in-memory state of the account state. But because the commit happens + // _after_ the account manager internal lock has been released, there + // is a chance for the address index to be accessed concurrently, even + // though the closure in OnCommit re-acquires the lock. To avoid this + // issue, we surround the whole address creation process with a lock. + w.newAddrMtx.Lock() + defer w.newAddrMtx.Unlock() + + var addr btcutil.Address + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + var err error + addr, err = w.newChangeAddress(addrmgrNs, account, scope) + return err + }) + if err != nil { + return nil, err + } + + // Notify the rpc server about the newly created address. + err = chainClient.NotifyReceived([]btcutil.Address{addr}) + if err != nil { + return nil, err + } + + return addr, nil +} + +// newChangeAddress returns a new change address for the wallet. +// +// NOTE: This method requires the caller to use the backend's NotifyReceived +// method in order to detect when an on-chain transaction pays to the address +// being created. +func (w *Wallet) newChangeAddress(addrmgrNs walletdb.ReadWriteBucket, + account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + // Get next chained change address from wallet for account. + addrs, err := manager.NextInternalAddresses(addrmgrNs, account, 1) + if err != nil { + return nil, err + } + + return addrs[0].Address(), nil +} + + +// AccountTotalReceivedResult is a single result for the +// Wallet.TotalReceivedForAccounts method. +type AccountTotalReceivedResult struct { + AccountNumber uint32 + AccountName string + TotalReceived btcutil.Amount + LastConfirmation int32 +} + +// TotalReceivedForAccounts iterates through a wallet's transaction history, +// returning the total amount of Bitcoin received for all accounts. +func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, + minConf int32) ([]AccountTotalReceivedResult, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + var results []AccountTotalReceivedResult + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + syncBlock := w.addrStore.SyncedTo() + + err := manager.ForEachAccount(addrmgrNs, func(account uint32) error { + accountName, err := manager.AccountName(addrmgrNs, account) + if err != nil { + return err + } + results = append(results, AccountTotalReceivedResult{ + AccountNumber: account, + AccountName: accountName, + }) + return nil + }) + if err != nil { + return err + } + + var stopHeight int32 + + if minConf > 0 { + stopHeight = syncBlock.Height - minConf + 1 + } else { + stopHeight = -1 + } + + //nolint:lll + rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { + for i := range details { + detail := &details[i] + for _, cred := range detail.Credits { + pkScript := detail.MsgTx.TxOut[cred.Index].PkScript + var outputAcct uint32 + _, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, w.chainParams) + if err == nil && len(addrs) > 0 { + _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) + } + if err == nil { + acctIndex := int(outputAcct) + if outputAcct == waddrmgr.ImportedAddrAccount { + acctIndex = len(results) - 1 + } + res := &results[acctIndex] + res.TotalReceived += cred.Amount + + confs := calcConf( + detail.Block.Height, + syncBlock.Height, + ) + res.LastConfirmation = confs + } + } + } + return false, nil + } + + return w.txStore.RangeTransactions( + txmgrNs, 0, stopHeight, rangeFn, + ) + }) + return results, err +} + +// TotalReceivedForAddr iterates through a wallet's transaction history, +// returning the total amount of bitcoins received for a single wallet +// address. +func (w *Wallet) TotalReceivedForAddr(addr btcutil.Address, minConf int32) (btcutil.Amount, error) { + var amount btcutil.Amount + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + syncBlock := w.addrStore.SyncedTo() + + var ( + addrStr = addr.EncodeAddress() + stopHeight int32 + ) + + if minConf > 0 { + stopHeight = syncBlock.Height - minConf + 1 + } else { + stopHeight = -1 + } + rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { + for i := range details { + detail := &details[i] + for _, cred := range detail.Credits { + pkScript := detail.MsgTx.TxOut[cred.Index].PkScript + _, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, + w.chainParams) + // An error creating addresses from the output script only + // indicates a non-standard script, so ignore this credit. + if err != nil { + continue + } + for _, a := range addrs { + if addrStr == a.EncodeAddress() { + amount += cred.Amount + break + } + } + } + } + return false, nil + } + + return w.txStore.RangeTransactions( + txmgrNs, 0, stopHeight, rangeFn, + ) + }) + return amount, err +} + +// SendOutputs creates and sends payment transactions. Coin selection is +// performed by the wallet, choosing inputs that belong to the given key scope +// and account, unless a key scope is not specified. In that case, inputs from +// accounts matching the account number provided across all key scopes may be +// selected. This is done to handle the default account case, where a user wants +// to fund a PSBT with inputs regardless of their type (NP2WKH, P2WKH, etc.). It +// returns the transaction upon success. +func (w *Wallet) SendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, + account uint32, minconf int32, satPerKb btcutil.Amount, + coinSelectionStrategy CoinSelectionStrategy, label string) (*wire.MsgTx, + error) { + + return w.sendOutputs( + outputs, keyScope, account, minconf, satPerKb, + coinSelectionStrategy, label, + ) +} + +// SendOutputsWithInput creates and sends payment transactions using the +// provided selected utxos. It returns the transaction upon success. +func (w *Wallet) SendOutputsWithInput(outputs []*wire.TxOut, + keyScope *waddrmgr.KeyScope, + account uint32, minconf int32, satPerKb btcutil.Amount, + coinSelectionStrategy CoinSelectionStrategy, label string, + selectedUtxos []wire.OutPoint) (*wire.MsgTx, error) { + + return w.sendOutputs(outputs, keyScope, account, minconf, satPerKb, + coinSelectionStrategy, label, selectedUtxos...) +} + +// sendOutputs creates and sends payment transactions. It returns the +// transaction upon success. +func (w *Wallet) sendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, + account uint32, minconf int32, satPerKb btcutil.Amount, + coinSelectionStrategy CoinSelectionStrategy, label string, + selectedUtxos ...wire.OutPoint) (*wire.MsgTx, error) { + + // If the key scope wasn't specified, then we'll default to the BIP0084 + // key scope for this account. + if keyScope == nil { + keyScope = &waddrmgr.KeyScopeBIP0084 + } + + // Create a transaction which spends from the wallet. + var ( + tx *txauthor.AuthoredTx + err error + ) + // We'll specify the WithCustomSelectUtxos functional option if we were + // passed a set of utxos to spend. + var opts []TxCreateOption + if len(selectedUtxos) != 0 { + opts = append(opts, WithCustomSelectUtxos(selectedUtxos)) + } + + tx, err = w.CreateSimpleTx( + keyScope, account, outputs, minconf, satPerKb, + coinSelectionStrategy, false, opts..., + ) + if err != nil { + return nil, err + } + + // If there is a label we should write, get the namespace key + // and record it in the tx store. + // + // TODO(yy): We should remove this `label` parameter from the function + // signature and instead let the caller use `LabelTransaction` to label + // the transaction after it's been published. + if len(label) != 0 { + err := walletdb.Update(w.db, func(txmgr walletdb.ReadWriteTx) error { + ns := txmgr.ReadWriteBucket(wtxmgrNamespaceKey) + return w.txStore.PutTxLabel(ns, tx.Tx.TxHash(), label) + }) + if err != nil { + return nil, err + } + } + + // And publish it. + return nil, w.PublishTransaction(tx.Tx, label) +} + +// SignatureError records the underlying error when validating a transaction +// input signature. +type SignatureError struct { + InputIndex uint32 + Error error +} + +// SignTransaction uses secrets of the wallet, as well as additional secrets +// passed in by the caller, to create and add input signatures to a transaction. +// +// Transaction input script validation is used to confirm that all signatures +// are valid. For any invalid input, a SignatureError is added to the returns. +// The final error return is reserved for unexpected or fatal errors, such as +// being unable to determine a previous output script to redeem. +// +// The transaction pointed to by tx is modified by this function. +func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, + additionalPrevScripts map[wire.OutPoint][]byte, + additionalKeysByAddress map[string]*btcutil.WIF, + p2shRedeemScriptsByAddress map[string][]byte) ([]SignatureError, error) { + + var signErrors []SignatureError + err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { + addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + inputFetcher := txscript.NewMultiPrevOutFetcher(nil) + for i, txIn := range tx.TxIn { + prevOutScript, ok := additionalPrevScripts[txIn.PreviousOutPoint] + if !ok { + prevHash := &txIn.PreviousOutPoint.Hash + prevIndex := txIn.PreviousOutPoint.Index + + txDetails, err := w.txStore.TxDetails( + txmgrNs, prevHash, + ) + if err != nil { + return fmt.Errorf("cannot query previous transaction "+ + "details for %v: %w", txIn.PreviousOutPoint, err) + } + if txDetails == nil { + return fmt.Errorf("%v not found", + txIn.PreviousOutPoint) + } + prevOutScript = txDetails.MsgTx.TxOut[prevIndex].PkScript + } + inputFetcher.AddPrevOut(txIn.PreviousOutPoint, &wire.TxOut{ + PkScript: prevOutScript, + }) + + // Set up our callbacks that we pass to txscript so it can + // look up the appropriate keys and scripts by address. + // + //nolint:lll + getKey := txscript.KeyClosure(func(addr btcutil.Address) (*btcec.PrivateKey, bool, error) { + if len(additionalKeysByAddress) != 0 { + addrStr := addr.EncodeAddress() + wif, ok := additionalKeysByAddress[addrStr] + if !ok { + return nil, false, + errors.New("no key for address") + } + return wif.PrivKey, wif.CompressPubKey, nil + } + + address, err := w.addrStore.Address(addrmgrNs, addr) + if err != nil { + return nil, false, err + } + + pka, ok := address.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil, false, fmt.Errorf("address %v is not "+ + "a pubkey address", address.Address().EncodeAddress()) + } + + key, err := pka.PrivKey() + if err != nil { + return nil, false, err + } + + return key, pka.Compressed(), nil + }) + //nolint:lll + getScript := txscript.ScriptClosure(func(addr btcutil.Address) ([]byte, error) { + // If keys were provided then we can only use the + // redeem scripts provided with our inputs, too. + if len(additionalKeysByAddress) != 0 { + addrStr := addr.EncodeAddress() + script, ok := p2shRedeemScriptsByAddress[addrStr] + if !ok { + return nil, errors.New("no script for address") + } + return script, nil + } + + address, err := w.addrStore.Address(addrmgrNs, addr) + if err != nil { + return nil, err + } + + // If we found the address, we check to see if it's + // a p2sh address, if so, then we'll verify that it + // is one that we know the redeem script for. + shAddr, ok := address.(waddrmgr.ManagedScriptAddress) + if !ok { + return nil, errors.New("address is not a " + + "p2sh address") + } + + return shAddr.Script() + }) + + // SigHashSingle inputs can only be signed if there's a + // corresponding output. However this could be already signed, + // so we always verify the output. + if (hashType&txscript.SigHashSingle) != + txscript.SigHashSingle || i < len(tx.TxOut) { + + script, err := txscript.SignTxOutput(w.ChainParams(), + tx, i, prevOutScript, hashType, getKey, + getScript, txIn.SignatureScript) + // Failure to sign isn't an error, it just means that + // the tx isn't complete. + if err != nil { + signErrors = append(signErrors, SignatureError{ + InputIndex: uint32(i), + Error: err, + }) + continue + } + txIn.SignatureScript = script + } + + // Either it was already signed or we just signed it. + // Find out if it is completely satisfied or still needs more. + vm, err := txscript.NewEngine( + prevOutScript, tx, i, + txscript.StandardVerifyFlags, nil, nil, 0, + inputFetcher, + ) + if err == nil { + err = vm.Execute() + } + if err != nil { + signErrors = append(signErrors, SignatureError{ + InputIndex: uint32(i), + Error: err, + }) + } + } + return nil + }) + return signErrors, err +} + +// ErrDoubleSpend is an error returned from PublishTransaction in case the +// published transaction failed to propagate since it was double spending a +// confirmed transaction or a transaction in the mempool. +type ErrDoubleSpend struct { + backendError error +} + +// Error returns the string representation of ErrDoubleSpend. +// +// NOTE: Satisfies the error interface. +func (e *ErrDoubleSpend) Error() string { + return fmt.Sprintf("double spend: %v", e.backendError) +} + +// Unwrap returns the underlying error returned from the backend. +func (e *ErrDoubleSpend) Unwrap() error { + return e.backendError +} + +// ErrMempoolFee is an error returned from PublishTransaction in case the +// published transaction failed to propagate since it did not match the +// current mempool fee requirement. +type ErrMempoolFee struct { + backendError error +} + +// Error returns the string representation of ErrMempoolFee. +// +// NOTE: Satisfies the error interface. +func (e *ErrMempoolFee) Error() string { + return fmt.Sprintf("mempool fee not met: %v", e.backendError) +} + +// Unwrap returns the underlying error returned from the backend. +func (e *ErrMempoolFee) Unwrap() error { + return e.backendError +} + +// ErrAlreadyConfirmed is an error returned from PublishTransaction in case +// a transaction is already confirmed in the blockchain. +type ErrAlreadyConfirmed struct { + backendError error +} + +// Error returns the string representation of ErrAlreadyConfirmed. +// +// NOTE: Satisfies the error interface. +func (e *ErrAlreadyConfirmed) Error() string { + return fmt.Sprintf("tx already confirmed: %v", e.backendError) +} + +// Unwrap returns the underlying error returned from the backend. +func (e *ErrAlreadyConfirmed) Unwrap() error { + return e.backendError +} + +// ErrInMempool is an error returned from PublishTransaction in case a +// transaction is already in the mempool. +type ErrInMempool struct { + backendError error +} + +// Error returns the string representation of ErrInMempool. +// +// NOTE: Satisfies the error interface. +func (e *ErrInMempool) Error() string { + return fmt.Sprintf("tx already in mempool: %v", e.backendError) +} + +// Unwrap returns the underlying error returned from the backend. +func (e *ErrInMempool) Unwrap() error { + return e.backendError +} + +// PublishTransaction sends the transaction to the consensus RPC server so it +// can be propagated to other nodes and eventually mined. +// +// This function is unstable and will be removed once syncing code is moved out +// of the wallet. +func (w *Wallet) PublishTransaction(tx *wire.MsgTx, label string) error { + _, err := w.reliablyPublishTransaction(tx, label) + return err +} + +// reliablyPublishTransaction is a superset of publishTransaction which contains +// the primary logic required for publishing a transaction, updating the +// relevant database state, and finally possible removing the transaction from +// the database (along with cleaning up all inputs used, and outputs created) if +// the transaction is rejected by the backend. +func (w *Wallet) reliablyPublishTransaction(tx *wire.MsgTx, + label string) (*chainhash.Hash, error) { + + chainClient, err := w.requireChainClient() + if err != nil { + return nil, err + } + + // As we aim for this to be general reliable transaction broadcast API, + // we'll write this tx to disk as an unconfirmed transaction. This way, + // upon restarts, we'll always rebroadcast it, and also add it to our + // set of records. + txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + if err != nil { + return nil, err + } + + // Along the way, we'll extract our relevant destination addresses from + // the transaction. + var ourAddrs []btcutil.Address + err = walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { + addrmgrNs := dbTx.ReadWriteBucket(waddrmgrNamespaceKey) + for _, txOut := range tx.TxOut { + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + txOut.PkScript, w.chainParams, + ) + if err != nil { + // Non-standard outputs can safely be skipped + // because they're not supported by the wallet. + log.Warnf("Non-standard pkScript=%x in tx=%v", + txOut.PkScript, tx.TxHash()) + + continue + } + for _, addr := range addrs { + // Skip any addresses which are not relevant to + // us. + _, err := w.addrStore.Address(addrmgrNs, addr) + if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { + continue + } + if err != nil { + return err + } + ourAddrs = append(ourAddrs, addr) + } + } + + // If there is a label we should write, get the namespace key + // and record it in the tx store. + if len(label) != 0 { + txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) + + err = w.txStore.PutTxLabel(txmgrNs, tx.TxHash(), label) + if err != nil { + return err + } + } + + return w.addRelevantTx(dbTx, txRec, nil) + }) + if err != nil { + return nil, err + } + + // We'll also ask to be notified of the transaction once it confirms + // on-chain. This is done outside of the database transaction to prevent + // backend interaction within it. + if err := chainClient.NotifyReceived(ourAddrs); err != nil { + return nil, err + } + + return w.publishTransaction(tx) +} + +// publishTransaction attempts to send an unconfirmed transaction to the +// wallet's current backend. In the event that sending the transaction fails for +// whatever reason, it will be removed from the wallet's unconfirmed transaction +// store. +func (w *Wallet) publishTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) { + chainClient, err := w.requireChainClient() + if err != nil { + return nil, err + } + + txid := tx.TxHash() + _, rpcErr := chainClient.SendRawTransaction(tx, false) + if rpcErr == nil { + return &txid, nil + } + + switch { + case errors.Is(rpcErr, chain.ErrTxAlreadyInMempool): + log.Infof("%v: tx already in mempool", txid) + return &txid, nil + + case errors.Is(rpcErr, chain.ErrTxAlreadyKnown), + errors.Is(rpcErr, chain.ErrTxAlreadyConfirmed): + + dbErr := walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { + txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) + txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + if err != nil { + return err + } + + return w.txStore.RemoveUnminedTx(txmgrNs, txRec) + }) + if dbErr != nil { + log.Warnf("Unable to remove confirmed transaction %v "+ + "from unconfirmed store: %v", tx.TxHash(), dbErr) + } + + log.Infof("%v: tx already confirmed", txid) + + return &txid, nil + + } + + // Log the causing error, even if we know how to handle it. + log.Infof("%v: broadcast failed because of: %v", txid, rpcErr) + + // If the transaction was rejected for whatever other reason, then + // we'll remove it from the transaction store, as otherwise, we'll + // attempt to continually re-broadcast it, and the UTXO state of the + // wallet won't be accurate. + dbErr := walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { + txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) + txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + if err != nil { + return err + } + + return w.txStore.RemoveUnminedTx(txmgrNs, txRec) + }) + if dbErr != nil { + log.Warnf("Unable to remove invalid transaction %v: %v", + tx.TxHash(), dbErr) + } else { + log.Infof("Removed invalid transaction: %v", tx.TxHash()) + + // The serialized transaction is for logging only, don't fail + // on the error. + var txRaw bytes.Buffer + _ = tx.Serialize(&txRaw) + + // Optionally log the tx in debug when the size is manageable. + if txRaw.Len() < 1_000_000 { + log.Debugf("Removed invalid transaction: %v \n hex=%x", + newLogClosure(func() string { + return spew.Sdump(tx) + }), txRaw.Bytes()) + } else { + log.Debug("Removed invalid transaction due to size " + + "too large") + } + } + + return nil, rpcErr +} + +// CreateDeprecated creates an new wallet, writing it to an empty database. +// If the passed root key is non-nil, it is used. Otherwise, a secure +// random seed of the recommended length is generated. +// +// Deprecated: Use wallet.Create instead. +func CreateDeprecated(db walletdb.DB, pubPass, privPass []byte, + rootKey *hdkeychain.ExtendedKey, params *chaincfg.Params, + birthday time.Time) error { + + return create( + db, pubPass, privPass, rootKey, params, birthday, false, nil, + ) +} + +// AccountNumber returns the account number for an account name under a +// particular key scope. +func (w *Wallet) AccountNumber(scope waddrmgr.KeyScope, accountName string) (uint32, error) { + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return 0, err + } + + var account uint32 + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + var err error + account, err = manager.LookupAccount(addrmgrNs, accountName) + return err + }) + return account, err +} + +// AccountName returns the name of an account. +func (w *Wallet) AccountName(scope waddrmgr.KeyScope, accountNumber uint32) (string, error) { + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return "", err + } + + var accountName string + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + var err error + accountName, err = manager.AccountName(addrmgrNs, accountNumber) + return err + }) + return accountName, err +} + +// AccountProperties returns the properties of an account, including address +// indexes and name. It first fetches the desynced information from the address +// manager, then updates the indexes based on the address pools. +func (w *Wallet) AccountProperties(scope waddrmgr.KeyScope, acct uint32) (*waddrmgr.AccountProperties, error) { + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + var props *waddrmgr.AccountProperties + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + waddrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + var err error + props, err = manager.AccountProperties(waddrmgrNs, acct) + return err + }) + return props, err +} + +// AccountPropertiesByName returns the properties of an account by its name. It +// first fetches the desynced information from the address manager, then updates +// the indexes based on the address pools. +func (w *Wallet) AccountPropertiesByName(scope waddrmgr.KeyScope, + name string) (*waddrmgr.AccountProperties, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + var props *waddrmgr.AccountProperties + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + waddrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + acct, err := manager.LookupAccount(waddrmgrNs, name) + if err != nil { + return err + } + props, err = manager.AccountProperties(waddrmgrNs, acct) + return err + }) + return props, err +} + +// LookupAccount returns the corresponding key scope and account number for the +// account with the given name. +func (w *Wallet) LookupAccount(name string) (waddrmgr.KeyScope, uint32, error) { + var ( + keyScope waddrmgr.KeyScope + account uint32 + ) + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + var err error + + keyScope, account, err = w.addrStore.LookupAccount(ns, name) + return err + }) + + return keyScope, account, err +} + +// CreditCategory describes the type of wallet transaction output. The category +// of "sent transactions" (debits) is always "send", and is not expressed by +// this type. +// +// TODO: This is a requirement of the RPC server and should be moved. +type CreditCategory byte + +// These constants define the possible credit categories. +const ( + CreditReceive CreditCategory = iota + CreditGenerate + CreditImmature +) + +// String returns the category as a string. This string may be used as the +// JSON string for categories as part of listtransactions and gettransaction +// RPC responses. +func (c CreditCategory) String() string { + switch c { + case CreditReceive: + return "receive" + case CreditGenerate: + return "generate" + case CreditImmature: + return "immature" + default: + return "unknown" + } +} + +// RecvCategory returns the category of received credit outputs from a +// transaction record. The passed block chain height is used to distinguish +// immature from mature coinbase outputs. +// +// TODO: This is intended for use by the RPC server and should be moved out of +// this package at a later time. +func RecvCategory(details *wtxmgr.TxDetails, syncHeight int32, net *chaincfg.Params) CreditCategory { + if blockchain.IsCoinBaseTx(&details.MsgTx) { + if hasMinConfs(uint32(net.CoinbaseMaturity), + details.Block.Height, syncHeight) { + + return CreditGenerate + } + return CreditImmature + } + return CreditReceive +} + +// listTransactions creates a object that may be marshalled to a response result +// for a listtransactions RPC. +// +// TODO: This should be moved to the legacyrpc package. +func listTransactions(tx walletdb.ReadTx, details *wtxmgr.TxDetails, + addrMgr waddrmgr.AddrStore, syncHeight int32, + net *chaincfg.Params) []btcjson.ListTransactionsResult { + + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + var ( + blockHashStr string + blockTime int64 + confirmations int64 + ) + if details.Block.Height != -1 { + blockHashStr = details.Block.Hash.String() + blockTime = details.Block.Time.Unix() + confirmations = int64( + calcConf(details.Block.Height, syncHeight), + ) + } + + results := []btcjson.ListTransactionsResult{} + txHashStr := details.Hash.String() + received := details.Received.Unix() + generated := blockchain.IsCoinBaseTx(&details.MsgTx) + recvCat := RecvCategory(details, syncHeight, net).String() + + send := len(details.Debits) != 0 + + // Fee can only be determined if every input is a debit. + var feeF64 float64 + if len(details.Debits) == len(details.MsgTx.TxIn) { + var debitTotal btcutil.Amount + for _, deb := range details.Debits { + debitTotal += deb.Amount + } + var outputTotal btcutil.Amount + for _, output := range details.MsgTx.TxOut { + outputTotal += btcutil.Amount(output.Value) + } + // Note: The actual fee is debitTotal - outputTotal. However, + // this RPC reports negative numbers for fees, so the inverse + // is calculated. + feeF64 = (outputTotal - debitTotal).ToBTC() + } + +outputs: + for i, output := range details.MsgTx.TxOut { + // Determine if this output is a credit, and if so, determine + // its spentness. + var isCredit bool + var spentCredit bool + for _, cred := range details.Credits { + if cred.Index == uint32(i) { + // Change outputs are ignored. + if cred.Change { + continue outputs + } + + isCredit = true + spentCredit = cred.Spent + break + } + } + + var address string + var accountName string + _, addrs, _, _ := txscript.ExtractPkScriptAddrs(output.PkScript, net) + if len(addrs) == 1 { + addr := addrs[0] + address = addr.EncodeAddress() + mgr, account, err := addrMgr.AddrAccount(addrmgrNs, addrs[0]) + if err == nil { + accountName, err = mgr.AccountName(addrmgrNs, account) + if err != nil { + accountName = "" + } + } + } + + amountF64 := btcutil.Amount(output.Value).ToBTC() + result := btcjson.ListTransactionsResult{ + // Fields left zeroed: + // InvolvesWatchOnly + // BlockIndex + // + // Fields set below: + // Account (only for non-"send" categories) + // Category + // Amount + // Fee + Address: address, + Vout: uint32(i), + Confirmations: confirmations, + Generated: generated, + BlockHash: blockHashStr, + BlockTime: blockTime, + TxID: txHashStr, + WalletConflicts: []string{}, + Time: received, + TimeReceived: received, + } + + // Add a received/generated/immature result if this is a credit. + // If the output was spent, create a second result under the + // send category with the inverse of the output amount. It is + // therefore possible that a single output may be included in + // the results set zero, one, or two times. + // + // Since credits are not saved for outputs that are not + // controlled by this wallet, all non-credits from transactions + // with debits are grouped under the send category. + + if send || spentCredit { + result.Category = "send" + result.Amount = -amountF64 + result.Fee = &feeF64 + results = append(results, result) + } + if isCredit { + result.Account = accountName + result.Category = recvCat + result.Amount = amountF64 + result.Fee = nil + results = append(results, result) + } + } + return results +} + +// ListSinceBlock returns a slice of objects with details about transactions +// since the given block. If the block is -1 then all transactions are included. +// This is intended to be used for listsinceblock RPC replies. +func (w *Wallet) ListSinceBlock(start, end, syncHeight int32) ([]btcjson.ListTransactionsResult, error) { + txList := []btcjson.ListTransactionsResult{} + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { + for _, detail := range details { + detail := detail + + jsonResults := listTransactions( + tx, &detail, w.addrStore, syncHeight, + w.chainParams, + ) + txList = append(txList, jsonResults...) + } + return false, nil + } + + return w.txStore.RangeTransactions(txmgrNs, start, end, rangeFn) + }) + return txList, err +} + +// ListTransactions returns a slice of objects with details about a recorded +// transaction. This is intended to be used for listtransactions RPC +// replies. +func (w *Wallet) ListTransactions(from, count int) ([]btcjson.ListTransactionsResult, error) { + txList := []btcjson.ListTransactionsResult{} + + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // Get current block. The block height used for calculating + // the number of tx confirmations. + syncBlock := w.addrStore.SyncedTo() + + // Need to skip the first from transactions, and after those, only + // include the next count transactions. + skipped := 0 + n := 0 + + rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { + // Iterate over transactions at this height in reverse order. + // This does nothing for unmined transactions, which are + // unsorted, but it will process mined transactions in the + // reverse order they were marked mined. + for i := len(details) - 1; i >= 0; i-- { + if from > skipped { + skipped++ + continue + } + + n++ + if n > count { + return true, nil + } + + jsonResults := listTransactions( + tx, &details[i], w.addrStore, + syncBlock.Height, w.chainParams, + ) + txList = append(txList, jsonResults...) + + if len(jsonResults) > 0 { + n++ + } + } + + return false, nil + } + + // Return newer results first by starting at mempool height and working + // down to the genesis block. + return w.txStore.RangeTransactions(txmgrNs, -1, 0, rangeFn) + }) + return txList, err +} + +// ListAddressTransactions returns a slice of objects with details about +// recorded transactions to or from any address belonging to a set. This is +// intended to be used for listaddresstransactions RPC replies. +func (w *Wallet) ListAddressTransactions(pkHashes map[string]struct{}) ([]btcjson.ListTransactionsResult, error) { + txList := []btcjson.ListTransactionsResult{} + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // Get current block. The block height used for calculating + // the number of tx confirmations. + syncBlock := w.addrStore.SyncedTo() + rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { + loopDetails: + for i := range details { + detail := &details[i] + + for _, cred := range detail.Credits { + pkScript := detail.MsgTx.TxOut[cred.Index].PkScript + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + pkScript, w.chainParams) + if err != nil || len(addrs) != 1 { + continue + } + apkh, ok := addrs[0].(*btcutil.AddressPubKeyHash) + if !ok { + continue + } + _, ok = pkHashes[string(apkh.ScriptAddress())] + if !ok { + continue + } + + jsonResults := listTransactions( + tx, detail, w.addrStore, + syncBlock.Height, w.chainParams, + ) + txList = append(txList, jsonResults...) + continue loopDetails + } + } + return false, nil + } + + return w.txStore.RangeTransactions(txmgrNs, 0, -1, rangeFn) + }) + return txList, err +} + +// ListAllTransactions returns a slice of objects with details about a recorded +// transaction. This is intended to be used for listalltransactions RPC +// replies. +func (w *Wallet) ListAllTransactions() ([]btcjson.ListTransactionsResult, error) { + txList := []btcjson.ListTransactionsResult{} + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + // Get current block. The block height used for calculating + // the number of tx confirmations. + syncBlock := w.addrStore.SyncedTo() + + rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { + // Iterate over transactions at this height in reverse order. + // This does nothing for unmined transactions, which are + // unsorted, but it will process mined transactions in the + // reverse order they were marked mined. + for i := len(details) - 1; i >= 0; i-- { + jsonResults := listTransactions( + tx, &details[i], w.addrStore, + syncBlock.Height, w.chainParams, + ) + txList = append(txList, jsonResults...) + } + return false, nil + } + + // Return newer results first by starting at mempool height and + // working down to the genesis block. + return w.txStore.RangeTransactions(txmgrNs, -1, 0, rangeFn) + }) + return txList, err +} + +// GetTransactions returns transaction results between a starting and ending +// block. Blocks in the block range may be specified by either a height or a +// hash. +// +// Because this is a possibly lenghtly operation, a cancel channel is provided +// to cancel the task. If this channel unblocks, the results created thus far +// will be returned. +// +// Transaction results are organized by blocks in ascending order and unmined +// transactions in an unspecified order. Mined transactions are saved in a +// Block structure which records properties about the block. +func (w *Wallet) GetTransactions(startBlock, endBlock *BlockIdentifier, + accountName string, cancel <-chan struct{}) (*GetTransactionsResult, error) { + + var start, end int32 = 0, -1 + + w.chainClientLock.Lock() + chainClient := w.chainClient + w.chainClientLock.Unlock() + + // TODO: Fetching block heights by their hashes is inherently racy + // because not all block headers are saved but when they are for SPV the + // db can be queried directly without this. + if startBlock != nil { + if startBlock.hash == nil { + start = startBlock.height + } else { + if chainClient == nil { + return nil, errors.New("no chain server client") + } + switch client := chainClient.(type) { + case *chain.RPCClient: + startHeader, err := client.GetBlockHeaderVerbose( + startBlock.hash, + ) + if err != nil { + return nil, err + } + start = startHeader.Height + case *chain.BitcoindClient: + var err error + start, err = client.GetBlockHeight(startBlock.hash) + if err != nil { + return nil, err + } + case *chain.NeutrinoClient: + var err error + start, err = client.GetBlockHeight(startBlock.hash) + if err != nil { + return nil, err + } + } + } + } + if endBlock != nil { + if endBlock.hash == nil { + end = endBlock.height + } else { + if chainClient == nil { + return nil, errors.New("no chain server client") + } + switch client := chainClient.(type) { + case *chain.RPCClient: + endHeader, err := client.GetBlockHeaderVerbose( + endBlock.hash, + ) + if err != nil { + return nil, err + } + end = endHeader.Height + case *chain.BitcoindClient: + var err error + start, err = client.GetBlockHeight(endBlock.hash) + if err != nil { + return nil, err + } + case *chain.NeutrinoClient: + var err error + end, err = client.GetBlockHeight(endBlock.hash) + if err != nil { + return nil, err + } + } + } + } + + var res GetTransactionsResult + err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { + // TODO: probably should make RangeTransactions not reuse the + // details backing array memory. + dets := make([]wtxmgr.TxDetails, len(details)) + copy(dets, details) + details = dets + + txs := make([]TransactionSummary, 0, len(details)) + for i := range details { + txs = append(txs, makeTxSummary(dbtx, w, &details[i])) + } + + if details[0].Block.Height != -1 { + blockHash := details[0].Block.Hash + res.MinedTransactions = append(res.MinedTransactions, Block{ + Hash: &blockHash, + Height: details[0].Block.Height, + Timestamp: details[0].Block.Time.Unix(), + Transactions: txs, + }) + } else { + res.UnminedTransactions = txs + } + + select { + case <-cancel: + return true, nil + default: + return false, nil + } + } + + return w.txStore.RangeTransactions(txmgrNs, start, end, rangeFn) + }) + return &res, err +} + +// GetTransaction returns detailed data of a transaction given its id. In +// addition it returns properties about its block. +func (w *Wallet) GetTransaction(txHash chainhash.Hash) (*GetTransactionResult, + error) { + + var res GetTransactionResult + err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + txDetail, err := w.txStore.TxDetails(txmgrNs, &txHash) + if err != nil { + return err + } + + // If the transaction was not found we return an error. + if txDetail == nil { + return fmt.Errorf("%w: txid %v", ErrNoTx, txHash) + } + + res = GetTransactionResult{ + Summary: makeTxSummary(dbtx, w, txDetail), + BlockHash: nil, + Height: -1, + Confirmations: 0, + Timestamp: 0, + } + + // If it is a confirmed transaction we set the corresponding + // block height, timestamp, hash, and confirmations. + if txDetail.Block.Height != -1 { + res.Height = txDetail.Block.Height + res.Timestamp = txDetail.Block.Time.Unix() + res.BlockHash = &txDetail.Block.Hash + + bestBlock := w.SyncedTo() + blockHeight := txDetail.Block.Height + res.Confirmations = calcConf( + blockHeight, bestBlock.Height, + ) + } + + return nil + }) + if err != nil { + return nil, err + } + return &res, nil +} + +// AccountBalances returns all accounts in the wallet and their balances. +// Balances are determined by excluding transactions that have not met +// requiredConfs confirmations. +func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope, + requiredConfs int32) ([]AccountBalanceResult, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + var results []AccountBalanceResult + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + syncBlock := w.addrStore.SyncedTo() + + // Fill out all account info except for the balances. + lastAcct, err := manager.LastAccount(addrmgrNs) + if err != nil { + return err + } + results = make([]AccountBalanceResult, lastAcct+2) + for i := range results[:len(results)-1] { + accountName, err := manager.AccountName(addrmgrNs, uint32(i)) + if err != nil { + return err + } + results[i].AccountNumber = uint32(i) + results[i].AccountName = accountName + } + results[len(results)-1].AccountNumber = waddrmgr.ImportedAddrAccount + results[len(results)-1].AccountName = waddrmgr.ImportedAddrAccountName + + // Fetch all unspent outputs, and iterate over them tallying each + // account's balance where the output script pays to an account address + // and the required number of confirmations is met. + unspentOutputs, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + for i := range unspentOutputs { + output := &unspentOutputs[i] + if !hasMinConfs( + //nolint:gosec + uint32(requiredConfs), output.Height, + syncBlock.Height, + ) { + + continue + } + + if output.FromCoinBase && !hasMinConfs( + uint32(w.ChainParams().CoinbaseMaturity), + output.Height, syncBlock.Height, + ) { + + continue + } + _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, w.chainParams) + if err != nil || len(addrs) == 0 { + continue + } + outputAcct, err := manager.AddrAccount(addrmgrNs, addrs[0]) + if err != nil { + continue + } + switch { + case outputAcct == waddrmgr.ImportedAddrAccount: + results[len(results)-1].AccountBalance += output.Amount + case outputAcct > lastAcct: + return errors.New("waddrmgr.Manager.AddrAccount returned account " + + "beyond recorded last account") + default: + results[outputAcct].AccountBalance += output.Amount + } + } + return nil + }) + return results, err +} + +// ListUnspentDeprecated returns a slice of objects representing the +// unspent wallet transactions fitting the given criteria. The confirmations +// will be more than +// minconf, less than maxconf and if addresses is populated only the addresses +// contained within it will be considered. If we know nothing about a +// transaction an empty array will be returned. +// +// Deprecated: Use UtxoManager.ListUnspent instead. +// +//nolint:funlen +func (w *Wallet) ListUnspentDeprecated(minconf, maxconf int32, + accountName string) ([]*btcjson.ListUnspentResult, error) { + + var results []*btcjson.ListUnspentResult + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + syncBlock := w.addrStore.SyncedTo() + + filter := accountName != "" + + unspent, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return err + } + sort.Sort(sort.Reverse(creditSlice(unspent))) + + defaultAccountName := "default" + + results = make([]*btcjson.ListUnspentResult, 0, len(unspent)) + for i := range unspent { + output := unspent[i] + + // Outputs with fewer confirmations than the minimum or + // more confs than the maximum are excluded. + confs := calcConf(output.Height, syncBlock.Height) + if confs < minconf || confs > maxconf { + continue + } + + // Only mature coinbase outputs are included. + if output.FromCoinBase { + target := uint32( + w.ChainParams().CoinbaseMaturity, + ) + if !hasMinConfs( + target, output.Height, syncBlock.Height, + ) { + + continue + } + } + + // Exclude locked outputs from the result set. + if w.LockedOutpoint(output.OutPoint) { + continue + } + + // Lookup the associated account for the output. Use the + // default account name in case there is no associated account + // for some reason, although this should never happen. + // + // This will be unnecessary once transactions and outputs are + // grouped under the associated account in the db. + outputAcctName := defaultAccountName + sc, addrs, _, err := txscript.ExtractPkScriptAddrs( + output.PkScript, w.chainParams) + if err != nil { + continue + } + if len(addrs) > 0 { + smgr, acct, err := w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) + if err == nil { + s, err := smgr.AccountName(addrmgrNs, acct) + if err == nil { + outputAcctName = s + } + } + } + + if filter && outputAcctName != accountName { + continue + } + + // At the moment watch-only addresses are not supported, so all + // recorded outputs that are not multisig are "spendable". + // Multisig outputs are only "spendable" if all keys are + // controlled by this wallet. + // + // TODO: Each case will need updates when watch-only addrs + // is added. For P2PK, P2PKH, and P2SH, the address must be + // looked up and not be watching-only. For multisig, all + // pubkeys must belong to the manager with the associated + // private key (currently it only checks whether the pubkey + // exists, since the private key is required at the moment). + var spendable bool + scSwitch: + switch sc { + case txscript.PubKeyHashTy: + spendable = true + case txscript.PubKeyTy: + spendable = true + case txscript.WitnessV0ScriptHashTy: + spendable = true + case txscript.WitnessV0PubKeyHashTy: + spendable = true + case txscript.MultiSigTy: + for _, a := range addrs { + _, err := w.addrStore.Address( + addrmgrNs, a, + ) + if err == nil { + continue + } + if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { + break scSwitch + } + return err + } + spendable = true + } + + result := &btcjson.ListUnspentResult{ + TxID: output.OutPoint.Hash.String(), + Vout: output.OutPoint.Index, + Account: outputAcctName, + ScriptPubKey: hex.EncodeToString(output.PkScript), + Amount: output.Amount.ToBTC(), + Confirmations: int64(confs), + Spendable: spendable, + } + + // BUG: this should be a JSON array so that all + // addresses can be included, or removed (and the + // caller extracts addresses from the pkScript). + if len(addrs) > 0 { + result.Address = addrs[0].EncodeAddress() + } + + results = append(results, result) + } + return nil + }) + return results, err +} + +// ListLeasedOutputsDeprecated returns a list of objects representing the +// currently locked utxos. +// +// Deprecated: Use UtxoManager.ListLeasedOutputs instead. +func (w *Wallet) ListLeasedOutputsDeprecated() ( + []*ListLeasedOutputResult, error) { + + var results []*ListLeasedOutputResult + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(wtxmgrNamespaceKey) + + outputs, err := w.txStore.ListLockedOutputs(ns) + if err != nil { + return err + } + + for _, output := range outputs { + details, err := w.txStore.TxDetails( + ns, &output.Outpoint.Hash, + ) + if err != nil { + return err + } + + if details == nil { + log.Infof("unable to find tx details for "+ + "%v:%v", output.Outpoint.Hash, + output.Outpoint.Index) + continue + } + + txOut := details.MsgTx.TxOut[output.Outpoint.Index] + + result := &ListLeasedOutputResult{ + LockedOutput: output, + Value: txOut.Value, + PkScript: txOut.PkScript, + } + + results = append(results, result) + } + + return nil + }) + return results, err +} + +// BlockIdentifier identifies a block by either a height or a hash. +type BlockIdentifier struct { + height int32 + hash *chainhash.Hash +} + +// NewBlockIdentifierFromHeight constructs a BlockIdentifier for a block height. +func NewBlockIdentifierFromHeight(height int32) *BlockIdentifier { + return &BlockIdentifier{height: height} +} + +// NewBlockIdentifierFromHash constructs a BlockIdentifier for a block hash. +func NewBlockIdentifierFromHash(hash *chainhash.Hash) *BlockIdentifier { + return &BlockIdentifier{hash: hash} +} + +// GetTransactionsResult is the result of the wallet's GetTransactions method. +// See GetTransactions for more details. +type GetTransactionsResult struct { + MinedTransactions []Block + UnminedTransactions []TransactionSummary +} + +// GetTransactionResult returns a summary of the transaction along with +// other block properties. +type GetTransactionResult struct { + Summary TransactionSummary + Height int32 + BlockHash *chainhash.Hash + Confirmations int32 + Timestamp int64 +} + +// AccountBalanceResult is a single result for the Wallet.AccountBalances method. +type AccountBalanceResult struct { + AccountNumber uint32 + AccountName string + AccountBalance btcutil.Amount +} + +// creditSlice satisifies the sort.Interface interface to provide sorting +// transaction credits from oldest to newest. Credits with the same receive +// time and mined in the same block are not guaranteed to be sorted by the order +// they appear in the block. Credits from the same transaction are sorted by +// output index. +type creditSlice []wtxmgr.Credit + +func (s creditSlice) Len() int { + return len(s) +} + +func (s creditSlice) Less(i, j int) bool { + switch { + // If both credits are from the same tx, sort by output index. + case s[i].OutPoint.Hash == s[j].OutPoint.Hash: + return s[i].OutPoint.Index < s[j].OutPoint.Index + + // If both transactions are unmined, sort by their received date. + case s[i].Height == -1 && s[j].Height == -1: + return s[i].Received.Before(s[j].Received) + + // Unmined (newer) txs always come last. + case s[i].Height == -1: + return false + case s[j].Height == -1: + return true + + // If both txs are mined in different blocks, sort by block height. + default: + return s[i].Height < s[j].Height + } +} + +func (s creditSlice) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + + + +// ListLeasedOutputResult is a single result for the Wallet.ListLeasedOutputs method. +// See that method for more details. +type ListLeasedOutputResult struct { + *wtxmgr.LockedOutput + Value int64 + PkScript []byte +} + +func (w *Wallet) newAddressDeprecated(addrmgrNs walletdb.ReadWriteBucket, + account uint32, scope waddrmgr.KeyScope) (btcutil.Address, + *waddrmgr.AccountProperties, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, nil, err + } + + // Get next address from wallet. + addrs, err := manager.NextExternalAddresses(addrmgrNs, account, 1) + if err != nil { + return nil, nil, err + } + + props, err := manager.AccountProperties(addrmgrNs, account) + if err != nil { + log.Errorf("Cannot fetch account properties for notification "+ + "after deriving next external address: %v", err) + + return nil, nil, err + } + + return addrs[0].Address(), props, nil +} + +// AddScopeManager creates a new scoped key manager from the root manager. +func (w *Wallet) AddScopeManager(scope waddrmgr.KeyScope, + addrSchema waddrmgr.ScopeAddrSchema) ( + waddrmgr.AccountStore, error) { + + var scopedManager waddrmgr.AccountStore + + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + manager, err := w.addrStore.NewScopedKeyManager( + addrmgrNs, scope, addrSchema, + ) + scopedManager = manager + + return err + }) + if err != nil { + return nil, err + } + + return scopedManager, nil +} + +// InitAccounts creates a number of accounts specified by `num`, with account +// number ranges from 1 to `num`. +func (w *Wallet) InitAccounts(scope *waddrmgr.ScopedKeyManager, + watchOnly bool, num uint32) error { + + return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + // Generate all accounts that we could ever need. This includes + // all key families. + for account := uint32(1); account <= num; account++ { + // Otherwise, we'll check if the account already exists, + // if so, we can once again bail early. + _, err := scope.AccountName(addrmgrNs, account) + if err == nil { + continue + } + + // If we reach this point, then the account hasn't yet + // been created, so we'll need to create it before we + // can proceed. + err = scope.NewRawAccount(addrmgrNs, account) + if err != nil { + return err + } + } + + // If this is the first startup with remote signing and wallet + // migration turned on and the wallet wasn't previously + // migrated, we can do that now that we made sure all accounts + // that we need were derived correctly. + if watchOnly { + log.Infof("Migrating wallet to watch-only mode, " + + "purging all private key material") + + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + return w.addrStore.ConvertToWatchingOnly(ns) + } + + return nil + }) +} + +// DeriveFromKeyPath derives a private key using the given derivation path. +func (w *Wallet) DeriveFromKeyPath(scope waddrmgr.KeyScope, + path waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { + + scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, fmt.Errorf("error fetching manager for scope %v: "+ + "%w", scope, err) + } + + // Let's see if we can hit the private key cache. + privKey, err := scopedMgr.DeriveFromKeyPathCache(path) + if err == nil { + return privKey, nil + } + + // The key wasn't in the cache, let's fully derive it now. + err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + addr, err := scopedMgr.DeriveFromKeyPath(addrmgrNs, path) + if err != nil { + return fmt.Errorf("error deriving private key: %w", err) + } + + mpka, ok := addr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + err := fmt.Errorf("managed address type for %v is "+ + "`%T` but want waddrmgr.ManagedPubKeyAddress", + addr, addr) + + return err + } + + privKey, err = mpka.PrivKey() + + return err + }) + if err != nil { + return nil, err + } + + return privKey, nil +} + +// DeriveFromKeyPathAddAccount derives a private key using the given derivation +// path. The account will be created if it doesn't exist. +func (w *Wallet) DeriveFromKeyPathAddAccount(scope waddrmgr.KeyScope, + path waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { + + scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, fmt.Errorf("error fetching manager for scope %v: "+ + "%w", scope, err) + } + + // Let's see if we can hit the private key cache. + privKey, err := scopedMgr.DeriveFromKeyPathCache(path) + if err == nil { + return privKey, nil + } + + derivePrivKey := func(addrmgrNs walletdb.ReadWriteBucket) error { + addr, err := scopedMgr.DeriveFromKeyPath(addrmgrNs, path) + + // Exit early if there's no error. + if err == nil { + key, ok := addr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return nil + } + + // Overwrite the returned private key variable. + privKey, err = key.PrivKey() + + return err + } + + return err + } + + // The key wasn't in the cache, let's fully derive it now. + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + err := derivePrivKey(addrmgrNs) + + // Exit early if there's no error. + if err == nil { + return nil + } + + // Exit with the error if it's not account not found. + if !waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound) { + return fmt.Errorf("error deriving private key: %w", err) + } + + // If we've reached this point, then the account doesn't yet + // exist, so we'll create it now to ensure we can sign. + err = scopedMgr.NewRawAccount(addrmgrNs, path.Account) + if err != nil { + return err + } + + // Now that we know the account exists, we'll attempt to + // re-derive the private key. + return derivePrivKey(addrmgrNs) + }) + if err != nil { + return nil, err + } + + return privKey, nil +} + // walletDeprecated encapsulates the legacy state and communication channels // that are being phased out in favor of the modern Controller and Syncer // architecture. diff --git a/wallet/wallet.go b/wallet/wallet.go index 455c8edb6d..bb570cb979 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -9,34 +9,25 @@ // // TODO(yy): bring wrapcheck back when implementing the `Store` interface. // -//nolint:wrapcheck,cyclop,gocognit +//nolint:wrapcheck,cyclop package wallet import ( - "bytes" - "encoding/hex" "errors" "fmt" - "sort" "sync" "time" - "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/walletdb/migration" "github.com/btcsuite/btcwallet/wtxmgr" - "github.com/davecgh/go-spew/spew" ) const ( @@ -244,2292 +235,44 @@ type Wallet struct { // AccountAddresses returns the addresses for every created address for an // account. -func (w *Wallet) AccountAddresses(account uint32) (addrs []btcutil.Address, err error) { - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - - return w.addrStore.ForEachAccountAddress( - addrmgrNs, account, - func(maddr waddrmgr.ManagedAddress) error { - addrs = append(addrs, maddr.Address()) - return nil - }) - }) - return -} - -// AccountManagedAddresses returns the managed addresses for every created -// address for an account. -func (w *Wallet) AccountManagedAddresses(scope waddrmgr.KeyScope, - accountNum uint32) ([]waddrmgr.ManagedAddress, error) { - - scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, err - } - - addrs := make([]waddrmgr.ManagedAddress, 0) - - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - - return scopedMgr.ForEachAccountAddress( - addrmgrNs, accountNum, - func(a waddrmgr.ManagedAddress) error { - addrs = append(addrs, a) - - return nil - }, - ) - }, - ) - if err != nil { - return nil, err - } - - return addrs, nil -} - -// CalculateBalance sums the amounts of all unspent transaction -// outputs to addresses of a wallet and returns the balance. -// -// If confirmations is 0, all UTXOs, even those not present in a -// block (height -1), will be used to get the balance. Otherwise, -// a UTXO must be in a block. If confirmations is 1 or greater, -// the balance will be calculated based on how many how many blocks -// include a UTXO. -func (w *Wallet) CalculateBalance(confirms int32) (btcutil.Amount, error) { - var balance btcutil.Amount - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - var err error - - blk := w.addrStore.SyncedTo() - balance, err = w.txStore.Balance(txmgrNs, confirms, blk.Height) - - return err - }) - return balance, err -} - -// Balances records total, spendable (by policy), and immature coinbase -// reward balance amounts. -type Balances struct { - Total btcutil.Amount - Spendable btcutil.Amount - ImmatureReward btcutil.Amount -} - -// CalculateAccountBalances sums the amounts of all unspent transaction -// outputs to the given account of a wallet and returns the balance. -// -// This function is much slower than it needs to be since transactions outputs -// are not indexed by the accounts they credit to, and all unspent transaction -// outputs must be iterated. -func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balances, error) { - var bals Balances - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - // Get current block. The block height used for calculating - // the number of tx confirmations. - syncBlock := w.addrStore.SyncedTo() - - unspent, err := w.txStore.UnspentOutputs(txmgrNs) - if err != nil { - return err - } - for i := range unspent { - output := &unspent[i] - - var outputAcct uint32 - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - output.PkScript, w.chainParams) - if err == nil && len(addrs) > 0 { - _, outputAcct, err = w.addrStore.AddrAccount( - addrmgrNs, addrs[0], - ) - } - if err != nil || outputAcct != account { - continue - } - - bals.Total += output.Amount - if output.FromCoinBase && !hasMinConfs( - uint32(w.chainParams.CoinbaseMaturity), - output.Height, syncBlock.Height, - ) { - - bals.ImmatureReward += output.Amount - } else if hasMinConfs( - //nolint:gosec - uint32(confirms), output.Height, - syncBlock.Height, - ) { - - bals.Spendable += output.Amount - } - } - return nil - }) - return bals, err -} - -// CurrentAddress gets the most recently requested Bitcoin payment address -// from a wallet for a particular key-chain scope. If the address has already -// been used (there is at least one transaction spending to it in the -// blockchain or btcd mempool), the next chained address is returned. -func (w *Wallet) CurrentAddress(account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) { - chainClient, err := w.requireChainClient() - if err != nil { - return nil, err - } - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, err - } - - // The address manager uses OnCommit on the walletdb tx to update the - // in-memory state of the account state. But because the commit happens - // _after_ the account manager internal lock has been released, there - // is a chance for the address index to be accessed concurrently, even - // though the closure in OnCommit re-acquires the lock. To avoid this - // issue, we surround the whole address creation process with a lock. - w.newAddrMtx.Lock() - defer w.newAddrMtx.Unlock() - - var ( - addr btcutil.Address - props *waddrmgr.AccountProperties - ) - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - maddr, err := manager.LastExternalAddress(addrmgrNs, account) - if err != nil { - // If no address exists yet, create the first external - // address. - if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { - addr, props, err = w.newAddressDeprecated( - addrmgrNs, account, scope, - ) - } - return err - } - - // Get next chained address if the last one has already been - // used. - if maddr.Used(addrmgrNs) { - addr, props, err = w.newAddressDeprecated( - addrmgrNs, account, scope, - ) - return err - } - - addr = maddr.Address() - return nil - }) - if err != nil { - return nil, err - } - - // If the props have been initially, then we had to create a new address - // to satisfy the query. Notify the rpc server about the new address. - if props != nil { - err = chainClient.NotifyReceived([]btcutil.Address{addr}) - if err != nil { - return nil, err - } - - w.NtfnServer.notifyAccountProperties(props) - } - - return addr, nil -} - -// PubKeyForAddress looks up the associated public key for a P2PKH address. -func (w *Wallet) PubKeyForAddress(a btcutil.Address) (*btcec.PublicKey, error) { - var pubKey *btcec.PublicKey - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - - managedAddr, err := w.addrStore.Address(addrmgrNs, a) - if err != nil { - return err - } - managedPubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return errors.New("address does not have an associated public key") - } - pubKey = managedPubKeyAddr.PubKey() - return nil - }) - return pubKey, err -} - -// LabelTransaction adds a label to the transaction with the hash provided. The -// call will fail if the label is too long, or if the transaction already has -// a label and the overwrite boolean is not set. -func (w *Wallet) LabelTransaction(hash chainhash.Hash, label string, - overwrite bool) error { - - // Check that the transaction is known to the wallet, and fail if it is - // unknown. If the transaction is known, check whether it already has - // a label. - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - dbTx, err := w.txStore.TxDetails(txmgrNs, &hash) - if err != nil { - return err - } - - // If the transaction looked up is nil, it was not found. We - // do not allow labelling of unknown transactions so we fail. - if dbTx == nil { - return ErrUnknownTransaction - } - - _, err = w.txStore.FetchTxLabel(txmgrNs, hash) - return err - }) - - switch err { - // If no labels have been written yet, we can silence the error. - // Likewise if there is no label, we do not need to do any overwrite - // checks. - case wtxmgr.ErrNoLabelBucket: - case wtxmgr.ErrTxLabelNotFound: - - // If we successfully looked up a label, fail if the overwrite param - // is not set. - case nil: - if !overwrite { - return ErrTxLabelExists - } - - // In another unrelated error occurred, return it. - default: - return err - } - - return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) - return w.txStore.PutTxLabel(txmgrNs, hash, label) - }) -} - -// PrivKeyForAddress looks up the associated private key for a P2PKH or P2PK -// address. -func (w *Wallet) PrivKeyForAddress(a btcutil.Address) (*btcec.PrivateKey, error) { - var privKey *btcec.PrivateKey - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - - addr, err := w.addrStore.Address(addrmgrNs, a) - if err != nil { - return err - } - - managedPubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return ErrNoAssocPrivateKey - } - - privKey, err = managedPubKeyAddr.PrivKey() - return err - }) - - return privKey, err -} - -// HaveAddress returns whether the wallet is the owner of the address a. -func (w *Wallet) HaveAddress(a btcutil.Address) (bool, error) { - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - _, err := w.addrStore.Address(addrmgrNs, a) - return err - }) - if err == nil { - return true, nil - } - if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { - return false, nil - } - return false, err -} - -// AccountOfAddress finds the account that an address is associated with. -func (w *Wallet) AccountOfAddress(a btcutil.Address) (uint32, error) { - var account uint32 - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - var err error - - _, account, err = w.addrStore.AddrAccount(addrmgrNs, a) - return err - }) - return account, err -} - -// AccountNumber returns the account number for an account name under a -// particular key scope. -func (w *Wallet) AccountNumber(scope waddrmgr.KeyScope, accountName string) (uint32, error) { - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return 0, err - } - - var account uint32 - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - var err error - account, err = manager.LookupAccount(addrmgrNs, accountName) - return err - }) - return account, err -} - -// AccountName returns the name of an account. -func (w *Wallet) AccountName(scope waddrmgr.KeyScope, accountNumber uint32) (string, error) { - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return "", err - } - - var accountName string - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - var err error - accountName, err = manager.AccountName(addrmgrNs, accountNumber) - return err - }) - return accountName, err -} - -// AccountProperties returns the properties of an account, including address -// indexes and name. It first fetches the desynced information from the address -// manager, then updates the indexes based on the address pools. -func (w *Wallet) AccountProperties(scope waddrmgr.KeyScope, acct uint32) (*waddrmgr.AccountProperties, error) { - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, err - } - - var props *waddrmgr.AccountProperties - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - waddrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - var err error - props, err = manager.AccountProperties(waddrmgrNs, acct) - return err - }) - return props, err -} - -// AccountPropertiesByName returns the properties of an account by its name. It -// first fetches the desynced information from the address manager, then updates -// the indexes based on the address pools. -func (w *Wallet) AccountPropertiesByName(scope waddrmgr.KeyScope, - name string) (*waddrmgr.AccountProperties, error) { - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, err - } - - var props *waddrmgr.AccountProperties - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - waddrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - acct, err := manager.LookupAccount(waddrmgrNs, name) - if err != nil { - return err - } - props, err = manager.AccountProperties(waddrmgrNs, acct) - return err - }) - return props, err -} - -// LookupAccount returns the corresponding key scope and account number for the -// account with the given name. -func (w *Wallet) LookupAccount(name string) (waddrmgr.KeyScope, uint32, error) { - var ( - keyScope waddrmgr.KeyScope - account uint32 - ) - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - ns := tx.ReadBucket(waddrmgrNamespaceKey) - var err error - - keyScope, account, err = w.addrStore.LookupAccount(ns, name) - return err - }) - - return keyScope, account, err -} - -// CreditCategory describes the type of wallet transaction output. The category -// of "sent transactions" (debits) is always "send", and is not expressed by -// this type. -// -// TODO: This is a requirement of the RPC server and should be moved. -type CreditCategory byte - -// These constants define the possible credit categories. -const ( - CreditReceive CreditCategory = iota - CreditGenerate - CreditImmature -) - -// String returns the category as a string. This string may be used as the -// JSON string for categories as part of listtransactions and gettransaction -// RPC responses. -func (c CreditCategory) String() string { - switch c { - case CreditReceive: - return "receive" - case CreditGenerate: - return "generate" - case CreditImmature: - return "immature" - default: - return "unknown" - } -} - -// RecvCategory returns the category of received credit outputs from a -// transaction record. The passed block chain height is used to distinguish -// immature from mature coinbase outputs. -// -// TODO: This is intended for use by the RPC server and should be moved out of -// this package at a later time. -func RecvCategory(details *wtxmgr.TxDetails, syncHeight int32, net *chaincfg.Params) CreditCategory { - if blockchain.IsCoinBaseTx(&details.MsgTx) { - if hasMinConfs(uint32(net.CoinbaseMaturity), - details.Block.Height, syncHeight) { - - return CreditGenerate - } - return CreditImmature - } - return CreditReceive -} - -// listTransactions creates a object that may be marshalled to a response result -// for a listtransactions RPC. -// -// TODO: This should be moved to the legacyrpc package. -func listTransactions(tx walletdb.ReadTx, details *wtxmgr.TxDetails, - addrMgr waddrmgr.AddrStore, syncHeight int32, - net *chaincfg.Params) []btcjson.ListTransactionsResult { - - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - - var ( - blockHashStr string - blockTime int64 - confirmations int64 - ) - if details.Block.Height != -1 { - blockHashStr = details.Block.Hash.String() - blockTime = details.Block.Time.Unix() - confirmations = int64( - calcConf(details.Block.Height, syncHeight), - ) - } - - results := []btcjson.ListTransactionsResult{} - txHashStr := details.Hash.String() - received := details.Received.Unix() - generated := blockchain.IsCoinBaseTx(&details.MsgTx) - recvCat := RecvCategory(details, syncHeight, net).String() - - send := len(details.Debits) != 0 - - // Fee can only be determined if every input is a debit. - var feeF64 float64 - if len(details.Debits) == len(details.MsgTx.TxIn) { - var debitTotal btcutil.Amount - for _, deb := range details.Debits { - debitTotal += deb.Amount - } - var outputTotal btcutil.Amount - for _, output := range details.MsgTx.TxOut { - outputTotal += btcutil.Amount(output.Value) - } - // Note: The actual fee is debitTotal - outputTotal. However, - // this RPC reports negative numbers for fees, so the inverse - // is calculated. - feeF64 = (outputTotal - debitTotal).ToBTC() - } - -outputs: - for i, output := range details.MsgTx.TxOut { - // Determine if this output is a credit, and if so, determine - // its spentness. - var isCredit bool - var spentCredit bool - for _, cred := range details.Credits { - if cred.Index == uint32(i) { - // Change outputs are ignored. - if cred.Change { - continue outputs - } - - isCredit = true - spentCredit = cred.Spent - break - } - } - - var address string - var accountName string - _, addrs, _, _ := txscript.ExtractPkScriptAddrs(output.PkScript, net) - if len(addrs) == 1 { - addr := addrs[0] - address = addr.EncodeAddress() - mgr, account, err := addrMgr.AddrAccount(addrmgrNs, addrs[0]) - if err == nil { - accountName, err = mgr.AccountName(addrmgrNs, account) - if err != nil { - accountName = "" - } - } - } - - amountF64 := btcutil.Amount(output.Value).ToBTC() - result := btcjson.ListTransactionsResult{ - // Fields left zeroed: - // InvolvesWatchOnly - // BlockIndex - // - // Fields set below: - // Account (only for non-"send" categories) - // Category - // Amount - // Fee - Address: address, - Vout: uint32(i), - Confirmations: confirmations, - Generated: generated, - BlockHash: blockHashStr, - BlockTime: blockTime, - TxID: txHashStr, - WalletConflicts: []string{}, - Time: received, - TimeReceived: received, - } - - // Add a received/generated/immature result if this is a credit. - // If the output was spent, create a second result under the - // send category with the inverse of the output amount. It is - // therefore possible that a single output may be included in - // the results set zero, one, or two times. - // - // Since credits are not saved for outputs that are not - // controlled by this wallet, all non-credits from transactions - // with debits are grouped under the send category. - - if send || spentCredit { - result.Category = "send" - result.Amount = -amountF64 - result.Fee = &feeF64 - results = append(results, result) - } - if isCredit { - result.Account = accountName - result.Category = recvCat - result.Amount = amountF64 - result.Fee = nil - results = append(results, result) - } - } - return results -} - -// ListSinceBlock returns a slice of objects with details about transactions -// since the given block. If the block is -1 then all transactions are included. -// This is intended to be used for listsinceblock RPC replies. -func (w *Wallet) ListSinceBlock(start, end, syncHeight int32) ([]btcjson.ListTransactionsResult, error) { - txList := []btcjson.ListTransactionsResult{} - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { - for _, detail := range details { - detail := detail - - jsonResults := listTransactions( - tx, &detail, w.addrStore, syncHeight, - w.chainParams, - ) - txList = append(txList, jsonResults...) - } - return false, nil - } - - return w.txStore.RangeTransactions(txmgrNs, start, end, rangeFn) - }) - return txList, err -} - -// ListTransactions returns a slice of objects with details about a recorded -// transaction. This is intended to be used for listtransactions RPC -// replies. -func (w *Wallet) ListTransactions(from, count int) ([]btcjson.ListTransactionsResult, error) { - txList := []btcjson.ListTransactionsResult{} - - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - // Get current block. The block height used for calculating - // the number of tx confirmations. - syncBlock := w.addrStore.SyncedTo() - - // Need to skip the first from transactions, and after those, only - // include the next count transactions. - skipped := 0 - n := 0 - - rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { - // Iterate over transactions at this height in reverse order. - // This does nothing for unmined transactions, which are - // unsorted, but it will process mined transactions in the - // reverse order they were marked mined. - for i := len(details) - 1; i >= 0; i-- { - if from > skipped { - skipped++ - continue - } - - n++ - if n > count { - return true, nil - } - - jsonResults := listTransactions( - tx, &details[i], w.addrStore, - syncBlock.Height, w.chainParams, - ) - txList = append(txList, jsonResults...) - - if len(jsonResults) > 0 { - n++ - } - } - - return false, nil - } - - // Return newer results first by starting at mempool height and working - // down to the genesis block. - return w.txStore.RangeTransactions(txmgrNs, -1, 0, rangeFn) - }) - return txList, err -} - -// ListAddressTransactions returns a slice of objects with details about -// recorded transactions to or from any address belonging to a set. This is -// intended to be used for listaddresstransactions RPC replies. -func (w *Wallet) ListAddressTransactions(pkHashes map[string]struct{}) ([]btcjson.ListTransactionsResult, error) { - txList := []btcjson.ListTransactionsResult{} - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - // Get current block. The block height used for calculating - // the number of tx confirmations. - syncBlock := w.addrStore.SyncedTo() - rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { - loopDetails: - for i := range details { - detail := &details[i] - - for _, cred := range detail.Credits { - pkScript := detail.MsgTx.TxOut[cred.Index].PkScript - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - pkScript, w.chainParams) - if err != nil || len(addrs) != 1 { - continue - } - apkh, ok := addrs[0].(*btcutil.AddressPubKeyHash) - if !ok { - continue - } - _, ok = pkHashes[string(apkh.ScriptAddress())] - if !ok { - continue - } - - jsonResults := listTransactions( - tx, detail, w.addrStore, - syncBlock.Height, w.chainParams, - ) - txList = append(txList, jsonResults...) - continue loopDetails - } - } - return false, nil - } - - return w.txStore.RangeTransactions(txmgrNs, 0, -1, rangeFn) - }) - return txList, err -} - -// ListAllTransactions returns a slice of objects with details about a recorded -// transaction. This is intended to be used for listalltransactions RPC -// replies. -func (w *Wallet) ListAllTransactions() ([]btcjson.ListTransactionsResult, error) { - txList := []btcjson.ListTransactionsResult{} - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - // Get current block. The block height used for calculating - // the number of tx confirmations. - syncBlock := w.addrStore.SyncedTo() - - rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { - // Iterate over transactions at this height in reverse order. - // This does nothing for unmined transactions, which are - // unsorted, but it will process mined transactions in the - // reverse order they were marked mined. - for i := len(details) - 1; i >= 0; i-- { - jsonResults := listTransactions( - tx, &details[i], w.addrStore, - syncBlock.Height, w.chainParams, - ) - txList = append(txList, jsonResults...) - } - return false, nil - } - - // Return newer results first by starting at mempool height and - // working down to the genesis block. - return w.txStore.RangeTransactions(txmgrNs, -1, 0, rangeFn) - }) - return txList, err -} - -// BlockIdentifier identifies a block by either a height or a hash. -type BlockIdentifier struct { - height int32 - hash *chainhash.Hash -} - -// NewBlockIdentifierFromHeight constructs a BlockIdentifier for a block height. -func NewBlockIdentifierFromHeight(height int32) *BlockIdentifier { - return &BlockIdentifier{height: height} -} - -// NewBlockIdentifierFromHash constructs a BlockIdentifier for a block hash. -func NewBlockIdentifierFromHash(hash *chainhash.Hash) *BlockIdentifier { - return &BlockIdentifier{hash: hash} -} - -// GetTransactionsResult is the result of the wallet's GetTransactions method. -// See GetTransactions for more details. -type GetTransactionsResult struct { - MinedTransactions []Block - UnminedTransactions []TransactionSummary -} - -// GetTransactions returns transaction results between a starting and ending -// block. Blocks in the block range may be specified by either a height or a -// hash. -// -// Because this is a possibly lenghtly operation, a cancel channel is provided -// to cancel the task. If this channel unblocks, the results created thus far -// will be returned. -// -// Transaction results are organized by blocks in ascending order and unmined -// transactions in an unspecified order. Mined transactions are saved in a -// Block structure which records properties about the block. -func (w *Wallet) GetTransactions(startBlock, endBlock *BlockIdentifier, - accountName string, cancel <-chan struct{}) (*GetTransactionsResult, error) { - - var start, end int32 = 0, -1 - - w.chainClientLock.Lock() - chainClient := w.chainClient - w.chainClientLock.Unlock() - - // TODO: Fetching block heights by their hashes is inherently racy - // because not all block headers are saved but when they are for SPV the - // db can be queried directly without this. - if startBlock != nil { - if startBlock.hash == nil { - start = startBlock.height - } else { - if chainClient == nil { - return nil, errors.New("no chain server client") - } - switch client := chainClient.(type) { - case *chain.RPCClient: - startHeader, err := client.GetBlockHeaderVerbose( - startBlock.hash, - ) - if err != nil { - return nil, err - } - start = startHeader.Height - case *chain.BitcoindClient: - var err error - start, err = client.GetBlockHeight(startBlock.hash) - if err != nil { - return nil, err - } - case *chain.NeutrinoClient: - var err error - start, err = client.GetBlockHeight(startBlock.hash) - if err != nil { - return nil, err - } - } - } - } - if endBlock != nil { - if endBlock.hash == nil { - end = endBlock.height - } else { - if chainClient == nil { - return nil, errors.New("no chain server client") - } - switch client := chainClient.(type) { - case *chain.RPCClient: - endHeader, err := client.GetBlockHeaderVerbose( - endBlock.hash, - ) - if err != nil { - return nil, err - } - end = endHeader.Height - case *chain.BitcoindClient: - var err error - start, err = client.GetBlockHeight(endBlock.hash) - if err != nil { - return nil, err - } - case *chain.NeutrinoClient: - var err error - end, err = client.GetBlockHeight(endBlock.hash) - if err != nil { - return nil, err - } - } - } - } - - var res GetTransactionsResult - err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { - txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) - - rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { - // TODO: probably should make RangeTransactions not reuse the - // details backing array memory. - dets := make([]wtxmgr.TxDetails, len(details)) - copy(dets, details) - details = dets - - txs := make([]TransactionSummary, 0, len(details)) - for i := range details { - txs = append(txs, makeTxSummary(dbtx, w, &details[i])) - } - - if details[0].Block.Height != -1 { - blockHash := details[0].Block.Hash - res.MinedTransactions = append(res.MinedTransactions, Block{ - Hash: &blockHash, - Height: details[0].Block.Height, - Timestamp: details[0].Block.Time.Unix(), - Transactions: txs, - }) - } else { - res.UnminedTransactions = txs - } - - select { - case <-cancel: - return true, nil - default: - return false, nil - } - } - - return w.txStore.RangeTransactions(txmgrNs, start, end, rangeFn) - }) - return &res, err -} - -// GetTransactionResult returns a summary of the transaction along with -// other block properties. -type GetTransactionResult struct { - Summary TransactionSummary - Height int32 - BlockHash *chainhash.Hash - Confirmations int32 - Timestamp int64 -} - -// GetTransaction returns detailed data of a transaction given its id. In -// addition it returns properties about its block. -func (w *Wallet) GetTransaction(txHash chainhash.Hash) (*GetTransactionResult, - error) { - - var res GetTransactionResult - err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { - txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) - - txDetail, err := w.txStore.TxDetails(txmgrNs, &txHash) - if err != nil { - return err - } - - // If the transaction was not found we return an error. - if txDetail == nil { - return fmt.Errorf("%w: txid %v", ErrNoTx, txHash) - } - - res = GetTransactionResult{ - Summary: makeTxSummary(dbtx, w, txDetail), - BlockHash: nil, - Height: -1, - Confirmations: 0, - Timestamp: 0, - } - - // If it is a confirmed transaction we set the corresponding - // block height, timestamp, hash, and confirmations. - if txDetail.Block.Height != -1 { - res.Height = txDetail.Block.Height - res.Timestamp = txDetail.Block.Time.Unix() - res.BlockHash = &txDetail.Block.Hash - - bestBlock := w.SyncedTo() - blockHeight := txDetail.Block.Height - res.Confirmations = calcConf( - blockHeight, bestBlock.Height, - ) - } - - return nil - }) - if err != nil { - return nil, err - } - return &res, nil -} - -// AccountBalanceResult is a single result for the Wallet.AccountBalances method. -type AccountBalanceResult struct { - AccountNumber uint32 - AccountName string - AccountBalance btcutil.Amount -} - -// AccountBalances returns all accounts in the wallet and their balances. -// Balances are determined by excluding transactions that have not met -// requiredConfs confirmations. -func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope, - requiredConfs int32) ([]AccountBalanceResult, error) { - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, err - } - - var results []AccountBalanceResult - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - syncBlock := w.addrStore.SyncedTo() - - // Fill out all account info except for the balances. - lastAcct, err := manager.LastAccount(addrmgrNs) - if err != nil { - return err - } - results = make([]AccountBalanceResult, lastAcct+2) - for i := range results[:len(results)-1] { - accountName, err := manager.AccountName(addrmgrNs, uint32(i)) - if err != nil { - return err - } - results[i].AccountNumber = uint32(i) - results[i].AccountName = accountName - } - results[len(results)-1].AccountNumber = waddrmgr.ImportedAddrAccount - results[len(results)-1].AccountName = waddrmgr.ImportedAddrAccountName - - // Fetch all unspent outputs, and iterate over them tallying each - // account's balance where the output script pays to an account address - // and the required number of confirmations is met. - unspentOutputs, err := w.txStore.UnspentOutputs(txmgrNs) - if err != nil { - return err - } - for i := range unspentOutputs { - output := &unspentOutputs[i] - if !hasMinConfs( - //nolint:gosec - uint32(requiredConfs), output.Height, - syncBlock.Height, - ) { - - continue - } - - if output.FromCoinBase && !hasMinConfs( - uint32(w.ChainParams().CoinbaseMaturity), - output.Height, syncBlock.Height, - ) { - - continue - } - _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, w.chainParams) - if err != nil || len(addrs) == 0 { - continue - } - outputAcct, err := manager.AddrAccount(addrmgrNs, addrs[0]) - if err != nil { - continue - } - switch { - case outputAcct == waddrmgr.ImportedAddrAccount: - results[len(results)-1].AccountBalance += output.Amount - case outputAcct > lastAcct: - return errors.New("waddrmgr.Manager.AddrAccount returned account " + - "beyond recorded last account") - default: - results[outputAcct].AccountBalance += output.Amount - } - } - return nil - }) - return results, err -} - -// creditSlice satisifies the sort.Interface interface to provide sorting -// transaction credits from oldest to newest. Credits with the same receive -// time and mined in the same block are not guaranteed to be sorted by the order -// they appear in the block. Credits from the same transaction are sorted by -// output index. -type creditSlice []wtxmgr.Credit - -func (s creditSlice) Len() int { - return len(s) -} - -func (s creditSlice) Less(i, j int) bool { - switch { - // If both credits are from the same tx, sort by output index. - case s[i].OutPoint.Hash == s[j].OutPoint.Hash: - return s[i].OutPoint.Index < s[j].OutPoint.Index - - // If both transactions are unmined, sort by their received date. - case s[i].Height == -1 && s[j].Height == -1: - return s[i].Received.Before(s[j].Received) - - // Unmined (newer) txs always come last. - case s[i].Height == -1: - return false - case s[j].Height == -1: - return true - - // If both txs are mined in different blocks, sort by block height. - default: - return s[i].Height < s[j].Height - } -} - -func (s creditSlice) Swap(i, j int) { - s[i], s[j] = s[j], s[i] -} - -// ListUnspentDeprecated returns a slice of objects representing the -// unspent wallet transactions fitting the given criteria. The confirmations -// will be more than -// minconf, less than maxconf and if addresses is populated only the addresses -// contained within it will be considered. If we know nothing about a -// transaction an empty array will be returned. -// -// Deprecated: Use UtxoManager.ListUnspent instead. -// -//nolint:funlen -func (w *Wallet) ListUnspentDeprecated(minconf, maxconf int32, - accountName string) ([]*btcjson.ListUnspentResult, error) { - - var results []*btcjson.ListUnspentResult - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - syncBlock := w.addrStore.SyncedTo() - - filter := accountName != "" - - unspent, err := w.txStore.UnspentOutputs(txmgrNs) - if err != nil { - return err - } - sort.Sort(sort.Reverse(creditSlice(unspent))) - - defaultAccountName := "default" - - results = make([]*btcjson.ListUnspentResult, 0, len(unspent)) - for i := range unspent { - output := unspent[i] - - // Outputs with fewer confirmations than the minimum or - // more confs than the maximum are excluded. - confs := calcConf(output.Height, syncBlock.Height) - if confs < minconf || confs > maxconf { - continue - } - - // Only mature coinbase outputs are included. - if output.FromCoinBase { - target := uint32( - w.ChainParams().CoinbaseMaturity, - ) - if !hasMinConfs( - target, output.Height, syncBlock.Height, - ) { - - continue - } - } - - // Exclude locked outputs from the result set. - if w.LockedOutpoint(output.OutPoint) { - continue - } - - // Lookup the associated account for the output. Use the - // default account name in case there is no associated account - // for some reason, although this should never happen. - // - // This will be unnecessary once transactions and outputs are - // grouped under the associated account in the db. - outputAcctName := defaultAccountName - sc, addrs, _, err := txscript.ExtractPkScriptAddrs( - output.PkScript, w.chainParams) - if err != nil { - continue - } - if len(addrs) > 0 { - smgr, acct, err := w.addrStore.AddrAccount( - addrmgrNs, addrs[0], - ) - if err == nil { - s, err := smgr.AccountName(addrmgrNs, acct) - if err == nil { - outputAcctName = s - } - } - } - - if filter && outputAcctName != accountName { - continue - } - - // At the moment watch-only addresses are not supported, so all - // recorded outputs that are not multisig are "spendable". - // Multisig outputs are only "spendable" if all keys are - // controlled by this wallet. - // - // TODO: Each case will need updates when watch-only addrs - // is added. For P2PK, P2PKH, and P2SH, the address must be - // looked up and not be watching-only. For multisig, all - // pubkeys must belong to the manager with the associated - // private key (currently it only checks whether the pubkey - // exists, since the private key is required at the moment). - var spendable bool - scSwitch: - switch sc { - case txscript.PubKeyHashTy: - spendable = true - case txscript.PubKeyTy: - spendable = true - case txscript.WitnessV0ScriptHashTy: - spendable = true - case txscript.WitnessV0PubKeyHashTy: - spendable = true - case txscript.MultiSigTy: - for _, a := range addrs { - _, err := w.addrStore.Address( - addrmgrNs, a, - ) - if err == nil { - continue - } - if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { - break scSwitch - } - return err - } - spendable = true - } - - result := &btcjson.ListUnspentResult{ - TxID: output.OutPoint.Hash.String(), - Vout: output.OutPoint.Index, - Account: outputAcctName, - ScriptPubKey: hex.EncodeToString(output.PkScript), - Amount: output.Amount.ToBTC(), - Confirmations: int64(confs), - Spendable: spendable, - } - - // BUG: this should be a JSON array so that all - // addresses can be included, or removed (and the - // caller extracts addresses from the pkScript). - if len(addrs) > 0 { - result.Address = addrs[0].EncodeAddress() - } - - results = append(results, result) - } - return nil - }) - return results, err -} - -// ListLeasedOutputResult is a single result for the Wallet.ListLeasedOutputs method. -// See that method for more details. -type ListLeasedOutputResult struct { - *wtxmgr.LockedOutput - Value int64 - PkScript []byte -} - -// ListLeasedOutputsDeprecated returns a list of objects representing the -// currently locked utxos. -// -// Deprecated: Use UtxoManager.ListLeasedOutputs instead. -func (w *Wallet) ListLeasedOutputsDeprecated() ( - []*ListLeasedOutputResult, error) { - - var results []*ListLeasedOutputResult - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - ns := tx.ReadBucket(wtxmgrNamespaceKey) - - outputs, err := w.txStore.ListLockedOutputs(ns) - if err != nil { - return err - } - - for _, output := range outputs { - details, err := w.txStore.TxDetails( - ns, &output.Outpoint.Hash, - ) - if err != nil { - return err - } - - if details == nil { - log.Infof("unable to find tx details for "+ - "%v:%v", output.Outpoint.Hash, - output.Outpoint.Index) - continue - } - - txOut := details.MsgTx.TxOut[output.Outpoint.Index] - - result := &ListLeasedOutputResult{ - LockedOutput: output, - Value: txOut.Value, - PkScript: txOut.PkScript, - } - - results = append(results, result) - } - - return nil - }) - return results, err -} - -// DumpPrivKeys returns the WIF-encoded private keys for all addresses with -// private keys in a wallet. -func (w *Wallet) DumpPrivKeys() ([]string, error) { - var privkeys []string - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - // Iterate over each active address, appending the private key to - return w.addrStore.ForEachActiveAddress( - addrmgrNs, func(addr btcutil.Address) error { - ma, err := w.addrStore.Address(addrmgrNs, addr) - if err != nil { - return err - } - - // Only those addresses with keys needed. - pka, ok := ma.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return nil - } - - wif, err := pka.ExportPrivKey() - if err != nil { - // It would be nice to zero out the - // array here. However, since strings - // in go are immutable, and we have no - // control over the caller I don't - // think we can. :( - return err - } - - privkeys = append(privkeys, wif.String()) - - return nil - }) - }) - return privkeys, err -} - -// DumpWIFPrivateKey returns the WIF encoded private key for a -// single wallet address. -func (w *Wallet) DumpWIFPrivateKey(addr btcutil.Address) (string, error) { - var maddr waddrmgr.ManagedAddress - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - waddrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - // Get private key from wallet if it exists. - var err error - - maddr, err = w.addrStore.Address(waddrmgrNs, addr) - return err - }) - if err != nil { - return "", err - } - - pka, ok := maddr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return "", fmt.Errorf("address %s is not a key type", addr) - } - - wif, err := pka.ExportPrivKey() - if err != nil { - return "", err - } - return wif.String(), nil -} - -// LockedOutpoint returns whether an outpoint has been marked as locked and -// should not be used as an input for created transactions. -func (w *Wallet) LockedOutpoint(op wire.OutPoint) bool { - w.lockedOutpointsMtx.Lock() - defer w.lockedOutpointsMtx.Unlock() - - _, locked := w.lockedOutpoints[op] - return locked -} - -// LockOutpoint marks an outpoint as locked, that is, it should not be used as -// an input for newly created transactions. -func (w *Wallet) LockOutpoint(op wire.OutPoint) { - w.lockedOutpointsMtx.Lock() - defer w.lockedOutpointsMtx.Unlock() - - w.lockedOutpoints[op] = struct{}{} -} - -// UnlockOutpoint marks an outpoint as unlocked, that is, it may be used as an -// input for newly created transactions. -func (w *Wallet) UnlockOutpoint(op wire.OutPoint) { - w.lockedOutpointsMtx.Lock() - defer w.lockedOutpointsMtx.Unlock() - - delete(w.lockedOutpoints, op) -} - -// ResetLockedOutpoints resets the set of locked outpoints so all may be used -// as inputs for new transactions. -func (w *Wallet) ResetLockedOutpoints() { - w.lockedOutpointsMtx.Lock() - defer w.lockedOutpointsMtx.Unlock() - - w.lockedOutpoints = map[wire.OutPoint]struct{}{} -} - -// LockedOutpoints returns a slice of currently locked outpoints. This is -// intended to be used by marshaling the result as a JSON array for -// listlockunspent RPC results. -func (w *Wallet) LockedOutpoints() []btcjson.TransactionInput { - w.lockedOutpointsMtx.Lock() - defer w.lockedOutpointsMtx.Unlock() - - locked := make([]btcjson.TransactionInput, len(w.lockedOutpoints)) - i := 0 - for op := range w.lockedOutpoints { - locked[i] = btcjson.TransactionInput{ - Txid: op.Hash.String(), - Vout: op.Index, - } - i++ - } - return locked -} - -// LeaseOutputDeprecated locks an output to the given ID, preventing it from -// being available for coin selection. The absolute time of the lock's -// expiration is -// returned. The expiration of the lock can be extended by successive -// invocations of this call. -// -// Outputs can be unlocked before their expiration through `UnlockOutput`. -// Otherwise, they are unlocked lazily through calls which iterate through all -// known outputs, e.g., `CalculateBalance`, `ListUnspent`. -// -// If the output is not known, ErrUnknownOutput is returned. If the output has -// already been locked to a different ID, then ErrOutputAlreadyLocked is -// returned. -// -// NOTE: This differs from LockOutpoint in that outputs are locked for a limited -// amount of time and their locks are persisted to disk. -// -// Deprecated: Use UtxoManager.LeaseOutput instead. -func (w *Wallet) LeaseOutputDeprecated(id wtxmgr.LockID, op wire.OutPoint, - duration time.Duration) (time.Time, error) { - - var expiry time.Time - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) - var err error - - expiry, err = w.txStore.LockOutput(ns, id, op, duration) - return err - }) - return expiry, err -} - -// ReleaseOutputDeprecated unlocks an output, allowing it to be available for -// coin selection if it remains unspent. The ID should match the one used to -// originally lock the output. -// -// Deprecated: Use UtxoManager.ReleaseOutput instead. -func (w *Wallet) ReleaseOutputDeprecated( - id wtxmgr.LockID, op wire.OutPoint) error { - - return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) - return w.txStore.UnlockOutput(ns, id, op) - }) -} - -// resendUnminedTxs iterates through all transactions that spend from wallet -// credits that are not known to have been mined into a block, and attempts -// to send each to the chain server for relay. -func (w *Wallet) resendUnminedTxs() { - var txs []*wire.MsgTx - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - var err error - - txs, err = w.txStore.UnminedTxs(txmgrNs) - return err - }) - if err != nil { - log.Errorf("Unable to retrieve unconfirmed transactions to "+ - "resend: %v", err) - return - } - - for _, tx := range txs { - txHash, err := w.publishTransaction(tx) - if err != nil { - log.Debugf("Unable to rebroadcast transaction %v: %v", - tx.TxHash(), err) - continue - } - - log.Debugf("Successfully rebroadcast unconfirmed transaction %v", - txHash) - } -} - -// SortedActivePaymentAddresses returns a slice of all active payment -// addresses in a wallet. -func (w *Wallet) SortedActivePaymentAddresses() ([]string, error) { - var addrStrs []string - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - - return w.addrStore.ForEachActiveAddress( - addrmgrNs, func(addr btcutil.Address) error { - addrStrs = append( - addrStrs, addr.EncodeAddress(), - ) - - return nil - }) - }) - if err != nil { - return nil, err - } - - sort.Strings(addrStrs) - return addrStrs, nil -} - -// NewAddressDeprecated returns the next external chained address for a wallet. -func (w *Wallet) NewAddressDeprecated(account uint32, - scope waddrmgr.KeyScope) (btcutil.Address, error) { - - chainClient, err := w.requireChainClient() - if err != nil { - return nil, err - } - - // The address manager uses OnCommit on the walletdb tx to update the - // in-memory state of the account state. But because the commit happens - // _after_ the account manager internal lock has been released, there - // is a chance for the address index to be accessed concurrently, even - // though the closure in OnCommit re-acquires the lock. To avoid this - // issue, we surround the whole address creation process with a lock. - w.newAddrMtx.Lock() - defer w.newAddrMtx.Unlock() - - var ( - addr btcutil.Address - props *waddrmgr.AccountProperties - ) - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - var err error - - addr, props, err = w.newAddressDeprecated( - addrmgrNs, account, scope, - ) - return err - }) - if err != nil { - return nil, err - } - - // Notify the rpc server about the newly created address. - err = chainClient.NotifyReceived([]btcutil.Address{addr}) - if err != nil { - return nil, err - } - - w.NtfnServer.notifyAccountProperties(props) - - return addr, nil -} - -// NewChangeAddress returns a new change address for a wallet. -func (w *Wallet) NewChangeAddress(account uint32, - scope waddrmgr.KeyScope) (btcutil.Address, error) { - - chainClient, err := w.requireChainClient() - if err != nil { - return nil, err - } - - // The address manager uses OnCommit on the walletdb tx to update the - // in-memory state of the account state. But because the commit happens - // _after_ the account manager internal lock has been released, there - // is a chance for the address index to be accessed concurrently, even - // though the closure in OnCommit re-acquires the lock. To avoid this - // issue, we surround the whole address creation process with a lock. - w.newAddrMtx.Lock() - defer w.newAddrMtx.Unlock() - - var addr btcutil.Address - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - var err error - addr, err = w.newChangeAddress(addrmgrNs, account, scope) - return err - }) - if err != nil { - return nil, err - } - - // Notify the rpc server about the newly created address. - err = chainClient.NotifyReceived([]btcutil.Address{addr}) - if err != nil { - return nil, err - } - - return addr, nil -} - -// newChangeAddress returns a new change address for the wallet. -// -// NOTE: This method requires the caller to use the backend's NotifyReceived -// method in order to detect when an on-chain transaction pays to the address -// being created. -func (w *Wallet) newChangeAddress(addrmgrNs walletdb.ReadWriteBucket, - account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) { - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, err - } - - // Get next chained change address from wallet for account. - addrs, err := manager.NextInternalAddresses(addrmgrNs, account, 1) - if err != nil { - return nil, err - } - - return addrs[0].Address(), nil -} - -// hasMinConfs checks whether a transaction at height txHeight has met minconf -// confirmations for a blockchain at height curHeight. -func hasMinConfs(minconf uint32, txHeight, curHeight int32) bool { - confs := calcConf(txHeight, curHeight) - if confs < 0 { - return false - } - - return uint32(confs) >= minconf -} - -// calcConf returns the number of confirmations for a transaction given its -// containing block height and the current best block height. Unconfirmed -// transactions have a height of -1 and are considered to have 0 confirmations. -func calcConf(txHeight, curHeight int32) int32 { - switch { - // Unconfirmed transactions have 0 confirmations. - case txHeight == -1: - return 0 - - // A transaction in a block after the current best block is considered - // unconfirmed. This can happen during a chain reorg. - case txHeight > curHeight: - return 0 - - // Confirmed transactions have at least one confirmation. - default: - return curHeight - txHeight + 1 - } -} - -// AccountTotalReceivedResult is a single result for the -// Wallet.TotalReceivedForAccounts method. -type AccountTotalReceivedResult struct { - AccountNumber uint32 - AccountName string - TotalReceived btcutil.Amount - LastConfirmation int32 -} - -// TotalReceivedForAccounts iterates through a wallet's transaction history, -// returning the total amount of Bitcoin received for all accounts. -func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope, - minConf int32) ([]AccountTotalReceivedResult, error) { - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, err - } - - var results []AccountTotalReceivedResult - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - syncBlock := w.addrStore.SyncedTo() - - err := manager.ForEachAccount(addrmgrNs, func(account uint32) error { - accountName, err := manager.AccountName(addrmgrNs, account) - if err != nil { - return err - } - results = append(results, AccountTotalReceivedResult{ - AccountNumber: account, - AccountName: accountName, - }) - return nil - }) - if err != nil { - return err - } - - var stopHeight int32 - - if minConf > 0 { - stopHeight = syncBlock.Height - minConf + 1 - } else { - stopHeight = -1 - } - - //nolint:lll - rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { - for i := range details { - detail := &details[i] - for _, cred := range detail.Credits { - pkScript := detail.MsgTx.TxOut[cred.Index].PkScript - var outputAcct uint32 - _, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, w.chainParams) - if err == nil && len(addrs) > 0 { - _, outputAcct, err = w.addrStore.AddrAccount(addrmgrNs, addrs[0]) - } - if err == nil { - acctIndex := int(outputAcct) - if outputAcct == waddrmgr.ImportedAddrAccount { - acctIndex = len(results) - 1 - } - res := &results[acctIndex] - res.TotalReceived += cred.Amount - - confs := calcConf( - detail.Block.Height, - syncBlock.Height, - ) - res.LastConfirmation = confs - } - } - } - return false, nil - } - - return w.txStore.RangeTransactions( - txmgrNs, 0, stopHeight, rangeFn, - ) - }) - return results, err -} - -// TotalReceivedForAddr iterates through a wallet's transaction history, -// returning the total amount of bitcoins received for a single wallet -// address. -func (w *Wallet) TotalReceivedForAddr(addr btcutil.Address, minConf int32) (btcutil.Amount, error) { - var amount btcutil.Amount - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) - - syncBlock := w.addrStore.SyncedTo() - - var ( - addrStr = addr.EncodeAddress() - stopHeight int32 - ) - - if minConf > 0 { - stopHeight = syncBlock.Height - minConf + 1 - } else { - stopHeight = -1 - } - rangeFn := func(details []wtxmgr.TxDetails) (bool, error) { - for i := range details { - detail := &details[i] - for _, cred := range detail.Credits { - pkScript := detail.MsgTx.TxOut[cred.Index].PkScript - _, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, - w.chainParams) - // An error creating addresses from the output script only - // indicates a non-standard script, so ignore this credit. - if err != nil { - continue - } - for _, a := range addrs { - if addrStr == a.EncodeAddress() { - amount += cred.Amount - break - } - } - } - } - return false, nil - } - - return w.txStore.RangeTransactions( - txmgrNs, 0, stopHeight, rangeFn, - ) - }) - return amount, err -} - -// SendOutputs creates and sends payment transactions. Coin selection is -// performed by the wallet, choosing inputs that belong to the given key scope -// and account, unless a key scope is not specified. In that case, inputs from -// accounts matching the account number provided across all key scopes may be -// selected. This is done to handle the default account case, where a user wants -// to fund a PSBT with inputs regardless of their type (NP2WKH, P2WKH, etc.). It -// returns the transaction upon success. -func (w *Wallet) SendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, - account uint32, minconf int32, satPerKb btcutil.Amount, - coinSelectionStrategy CoinSelectionStrategy, label string) (*wire.MsgTx, - error) { - - return w.sendOutputs( - outputs, keyScope, account, minconf, satPerKb, - coinSelectionStrategy, label, - ) -} - -// SendOutputsWithInput creates and sends payment transactions using the -// provided selected utxos. It returns the transaction upon success. -func (w *Wallet) SendOutputsWithInput(outputs []*wire.TxOut, - keyScope *waddrmgr.KeyScope, - account uint32, minconf int32, satPerKb btcutil.Amount, - coinSelectionStrategy CoinSelectionStrategy, label string, - selectedUtxos []wire.OutPoint) (*wire.MsgTx, error) { - - return w.sendOutputs(outputs, keyScope, account, minconf, satPerKb, - coinSelectionStrategy, label, selectedUtxos...) -} - -// sendOutputs creates and sends payment transactions. It returns the -// transaction upon success. -func (w *Wallet) sendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, - account uint32, minconf int32, satPerKb btcutil.Amount, - coinSelectionStrategy CoinSelectionStrategy, label string, - selectedUtxos ...wire.OutPoint) (*wire.MsgTx, error) { - - // Ensure the outputs to be created adhere to the network's consensus - // rules. - for _, output := range outputs { - err := txrules.CheckOutput( - output, txrules.DefaultRelayFeePerKb, - ) - if err != nil { - return nil, err - } - } - - // Create the transaction and broadcast it to the network. The - // transaction will be added to the database in order to ensure that we - // continue to re-broadcast the transaction upon restarts until it has - // been confirmed. - createdTx, err := w.CreateSimpleTx( - keyScope, account, outputs, minconf, satPerKb, - coinSelectionStrategy, false, WithCustomSelectUtxos( - selectedUtxos, - ), - ) - if err != nil { - return nil, err - } - - // If our wallet is read-only, we'll get a transaction with coins - // selected but no witness data. In such a case we need to inform our - // caller that they'll actually need to go ahead and sign the TX. - if w.addrStore.WatchOnly() { - return createdTx.Tx, ErrTxUnsigned - } - txHash, err := w.reliablyPublishTransaction(createdTx.Tx, label) - if err != nil { - return nil, err - } - - // Sanity check on the returned tx hash. - if *txHash != createdTx.Tx.TxHash() { - return nil, errors.New("tx hash mismatch") - } - - return createdTx.Tx, nil -} +// PrivKeyForAddress looks up the associated private key for a P2PKH or P2PK +// address. +func (w *Wallet) PrivKeyForAddress(a btcutil.Address) ( + *btcec.PrivateKey, error) { -// SignatureError records the underlying error when validating a transaction -// input signature. -type SignatureError struct { - InputIndex uint32 - Error error -} + var privKey *btcec.PrivateKey -// SignTransaction uses secrets of the wallet, as well as additional secrets -// passed in by the caller, to create and add input signatures to a transaction. -// -// Transaction input script validation is used to confirm that all signatures -// are valid. For any invalid input, a SignatureError is added to the returns. -// The final error return is reserved for unexpected or fatal errors, such as -// being unable to determine a previous output script to redeem. -// -// The transaction pointed to by tx is modified by this function. -func (w *Wallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, - additionalPrevScripts map[wire.OutPoint][]byte, - additionalKeysByAddress map[string]*btcutil.WIF, - p2shRedeemScriptsByAddress map[string][]byte) ([]SignatureError, error) { - - var signErrors []SignatureError - err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { - addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) - - inputFetcher := txscript.NewMultiPrevOutFetcher(nil) - for i, txIn := range tx.TxIn { - prevOutScript, ok := additionalPrevScripts[txIn.PreviousOutPoint] - if !ok { - prevHash := &txIn.PreviousOutPoint.Hash - prevIndex := txIn.PreviousOutPoint.Index - - txDetails, err := w.txStore.TxDetails( - txmgrNs, prevHash, - ) - if err != nil { - return fmt.Errorf("cannot query previous transaction "+ - "details for %v: %w", txIn.PreviousOutPoint, err) - } - if txDetails == nil { - return fmt.Errorf("%v not found", - txIn.PreviousOutPoint) - } - prevOutScript = txDetails.MsgTx.TxOut[prevIndex].PkScript - } - inputFetcher.AddPrevOut(txIn.PreviousOutPoint, &wire.TxOut{ - PkScript: prevOutScript, - }) - - // Set up our callbacks that we pass to txscript so it can - // look up the appropriate keys and scripts by address. - // - //nolint:lll - getKey := txscript.KeyClosure(func(addr btcutil.Address) (*btcec.PrivateKey, bool, error) { - if len(additionalKeysByAddress) != 0 { - addrStr := addr.EncodeAddress() - wif, ok := additionalKeysByAddress[addrStr] - if !ok { - return nil, false, - errors.New("no key for address") - } - return wif.PrivKey, wif.CompressPubKey, nil - } - - address, err := w.addrStore.Address(addrmgrNs, addr) - if err != nil { - return nil, false, err - } - - pka, ok := address.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return nil, false, fmt.Errorf("address %v is not "+ - "a pubkey address", address.Address().EncodeAddress()) - } - - key, err := pka.PrivKey() - if err != nil { - return nil, false, err - } - - return key, pka.Compressed(), nil - }) - //nolint:lll - getScript := txscript.ScriptClosure(func(addr btcutil.Address) ([]byte, error) { - // If keys were provided then we can only use the - // redeem scripts provided with our inputs, too. - if len(additionalKeysByAddress) != 0 { - addrStr := addr.EncodeAddress() - script, ok := p2shRedeemScriptsByAddress[addrStr] - if !ok { - return nil, errors.New("no script for address") - } - return script, nil - } - - address, err := w.addrStore.Address(addrmgrNs, addr) - if err != nil { - return nil, err - } - sa, ok := address.(waddrmgr.ManagedScriptAddress) - if !ok { - return nil, errors.New("address is not a script" + - " address") - } - - return sa.Script() - }) - - // SigHashSingle inputs can only be signed if there's a - // corresponding output. However this could be already signed, - // so we always verify the output. - if (hashType&txscript.SigHashSingle) != - txscript.SigHashSingle || i < len(tx.TxOut) { - - script, err := txscript.SignTxOutput(w.ChainParams(), - tx, i, prevOutScript, hashType, getKey, - getScript, txIn.SignatureScript) - // Failure to sign isn't an error, it just means that - // the tx isn't complete. - if err != nil { - signErrors = append(signErrors, SignatureError{ - InputIndex: uint32(i), - Error: err, - }) - continue - } - txIn.SignatureScript = script - } + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - // Either it was already signed or we just signed it. - // Find out if it is completely satisfied or still needs more. - vm, err := txscript.NewEngine( - prevOutScript, tx, i, - txscript.StandardVerifyFlags, nil, nil, 0, - inputFetcher, - ) - if err == nil { - err = vm.Execute() - } - if err != nil { - signErrors = append(signErrors, SignatureError{ - InputIndex: uint32(i), - Error: err, - }) - } + addr, err := w.addrStore.Address(addrmgrNs, a) + if err != nil { + return err } - return nil - }) - return signErrors, err -} - -// ErrDoubleSpend is an error returned from PublishTransaction in case the -// published transaction failed to propagate since it was double spending a -// confirmed transaction or a transaction in the mempool. -type ErrDoubleSpend struct { - backendError error -} - -// Error returns the string representation of ErrDoubleSpend. -// -// NOTE: Satisfies the error interface. -func (e *ErrDoubleSpend) Error() string { - return fmt.Sprintf("double spend: %v", e.backendError) -} - -// Unwrap returns the underlying error returned from the backend. -func (e *ErrDoubleSpend) Unwrap() error { - return e.backendError -} - -// ErrMempoolFee is an error returned from PublishTransaction in case the -// published transaction failed to propagate since it did not match the -// current mempool fee requirement. -type ErrMempoolFee struct { - backendError error -} - -// Error returns the string representation of ErrMempoolFee. -// -// NOTE: Satisfies the error interface. -func (e *ErrMempoolFee) Error() string { - return fmt.Sprintf("mempool fee not met: %v", e.backendError) -} - -// Unwrap returns the underlying error returned from the backend. -func (e *ErrMempoolFee) Unwrap() error { - return e.backendError -} - -// ErrAlreadyConfirmed is an error returned from PublishTransaction in case -// a transaction is already confirmed in the blockchain. -type ErrAlreadyConfirmed struct { - backendError error -} - -// Error returns the string representation of ErrAlreadyConfirmed. -// -// NOTE: Satisfies the error interface. -func (e *ErrAlreadyConfirmed) Error() string { - return fmt.Sprintf("tx already confirmed: %v", e.backendError) -} - -// Unwrap returns the underlying error returned from the backend. -func (e *ErrAlreadyConfirmed) Unwrap() error { - return e.backendError -} - -// ErrInMempool is an error returned from PublishTransaction in case a -// transaction is already in the mempool. -type ErrInMempool struct { - backendError error -} - -// Error returns the string representation of ErrInMempool. -// -// NOTE: Satisfies the error interface. -func (e *ErrInMempool) Error() string { - return fmt.Sprintf("tx already in mempool: %v", e.backendError) -} - -// Unwrap returns the underlying error returned from the backend. -func (e *ErrInMempool) Unwrap() error { - return e.backendError -} - -// PublishTransaction sends the transaction to the consensus RPC server so it -// can be propagated to other nodes and eventually mined. -// -// This function is unstable and will be removed once syncing code is moved out -// of the wallet. -func (w *Wallet) PublishTransaction(tx *wire.MsgTx, label string) error { - _, err := w.reliablyPublishTransaction(tx, label) - return err -} -// reliablyPublishTransaction is a superset of publishTransaction which contains -// the primary logic required for publishing a transaction, updating the -// relevant database state, and finally possible removing the transaction from -// the database (along with cleaning up all inputs used, and outputs created) if -// the transaction is rejected by the backend. -func (w *Wallet) reliablyPublishTransaction(tx *wire.MsgTx, - label string) (*chainhash.Hash, error) { - - chainClient, err := w.requireChainClient() - if err != nil { - return nil, err - } - - // As we aim for this to be general reliable transaction broadcast API, - // we'll write this tx to disk as an unconfirmed transaction. This way, - // upon restarts, we'll always rebroadcast it, and also add it to our - // set of records. - txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) - if err != nil { - return nil, err - } - - // Along the way, we'll extract our relevant destination addresses from - // the transaction. - var ourAddrs []btcutil.Address - err = walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { - addrmgrNs := dbTx.ReadWriteBucket(waddrmgrNamespaceKey) - for _, txOut := range tx.TxOut { - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - txOut.PkScript, w.chainParams, - ) - if err != nil { - // Non-standard outputs can safely be skipped - // because they're not supported by the wallet. - log.Warnf("Non-standard pkScript=%x in tx=%v", - txOut.PkScript, tx.TxHash()) - - continue - } - for _, addr := range addrs { - // Skip any addresses which are not relevant to - // us. - _, err := w.addrStore.Address(addrmgrNs, addr) - if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { - continue - } - if err != nil { - return err - } - ourAddrs = append(ourAddrs, addr) - } + managedPubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return ErrNoAssocPrivateKey } - // If there is a label we should write, get the namespace key - // and record it in the tx store. - if len(label) != 0 { - txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) - - err = w.txStore.PutTxLabel(txmgrNs, tx.TxHash(), label) - if err != nil { - return err - } - } + privKey, err = managedPubKeyAddr.PrivKey() - return w.addRelevantTx(dbTx, txRec, nil) + return err }) - if err != nil { - return nil, err - } - - // We'll also ask to be notified of the transaction once it confirms - // on-chain. This is done outside of the database transaction to prevent - // backend interaction within it. - if err := chainClient.NotifyReceived(ourAddrs); err != nil { - return nil, err - } - return w.publishTransaction(tx) + return privKey, err } -// publishTransaction attempts to send an unconfirmed transaction to the -// wallet's current backend. In the event that sending the transaction fails for -// whatever reason, it will be removed from the wallet's unconfirmed transaction -// store. -func (w *Wallet) publishTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) { - chainClient, err := w.requireChainClient() - if err != nil { - return nil, err - } - - txid := tx.TxHash() - _, rpcErr := chainClient.SendRawTransaction(tx, false) - if rpcErr == nil { - return &txid, nil - } - - switch { - case errors.Is(rpcErr, chain.ErrTxAlreadyInMempool): - log.Infof("%v: tx already in mempool", txid) - return &txid, nil - - case errors.Is(rpcErr, chain.ErrTxAlreadyKnown), - errors.Is(rpcErr, chain.ErrTxAlreadyConfirmed): - - dbErr := walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { - txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) - txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) - if err != nil { - return err - } - - return w.txStore.RemoveUnminedTx(txmgrNs, txRec) - }) - if dbErr != nil { - log.Warnf("Unable to remove confirmed transaction %v "+ - "from unconfirmed store: %v", tx.TxHash(), dbErr) - } - - log.Infof("%v: tx already confirmed", txid) - - return &txid, nil - - } - - // Log the causing error, even if we know how to handle it. - log.Infof("%v: broadcast failed because of: %v", txid, rpcErr) - - // If the transaction was rejected for whatever other reason, then - // we'll remove it from the transaction store, as otherwise, we'll - // attempt to continually re-broadcast it, and the UTXO state of the - // wallet won't be accurate. - dbErr := walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { - txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) - txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) - if err != nil { - return err - } +// LockedOutpoint returns whether an outpoint has been marked as locked and +// should not be used as an input for created transactions. +func (w *Wallet) LockedOutpoint(op wire.OutPoint) bool { + w.lockedOutpointsMtx.Lock() + defer w.lockedOutpointsMtx.Unlock() - return w.txStore.RemoveUnminedTx(txmgrNs, txRec) - }) - if dbErr != nil { - log.Warnf("Unable to remove invalid transaction %v: %v", - tx.TxHash(), dbErr) - } else { - log.Infof("Removed invalid transaction: %v", tx.TxHash()) - - // The serialized transaction is for logging only, don't fail - // on the error. - var txRaw bytes.Buffer - _ = tx.Serialize(&txRaw) - - // Optionally log the tx in debug when the size is manageable. - if txRaw.Len() < 1_000_000 { - log.Debugf("Removed invalid transaction: %v \n hex=%x", - newLogClosure(func() string { - return spew.Sdump(tx) - }), txRaw.Bytes()) - } else { - log.Debug("Removed invalid transaction due to size " + - "too large") - } - } + _, locked := w.lockedOutpoints[op] - return nil, rpcErr + return locked } // ChainParams returns the network parameters for the blockchain the wallet @@ -2585,189 +328,6 @@ func (w *Wallet) BirthdayBlock() (*waddrmgr.BlockStamp, error) { return &birthdayBlock, nil } -// AddScopeManager creates a new scoped key manager from the root manager. -func (w *Wallet) AddScopeManager(scope waddrmgr.KeyScope, - addrSchema waddrmgr.ScopeAddrSchema) ( - waddrmgr.AccountStore, error) { - - var scopedManager waddrmgr.AccountStore - - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - - manager, err := w.addrStore.NewScopedKeyManager( - addrmgrNs, scope, addrSchema, - ) - scopedManager = manager - - return err - }) - if err != nil { - return nil, err - } - - return scopedManager, nil -} - -// InitAccounts creates a number of accounts specified by `num`, with account -// number ranges from 1 to `num`. -func (w *Wallet) InitAccounts(scope *waddrmgr.ScopedKeyManager, - watchOnly bool, num uint32) error { - - return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - - // Generate all accounts that we could ever need. This includes - // all key families. - for account := uint32(1); account <= num; account++ { - // Otherwise, we'll check if the account already exists, - // if so, we can once again bail early. - _, err := scope.AccountName(addrmgrNs, account) - if err == nil { - continue - } - - // If we reach this point, then the account hasn't yet - // been created, so we'll need to create it before we - // can proceed. - err = scope.NewRawAccount(addrmgrNs, account) - if err != nil { - return err - } - } - - // If this is the first startup with remote signing and wallet - // migration turned on and the wallet wasn't previously - // migrated, we can do that now that we made sure all accounts - // that we need were derived correctly. - if watchOnly { - log.Infof("Migrating wallet to watch-only mode, " + - "purging all private key material") - - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - - return w.addrStore.ConvertToWatchingOnly(ns) - } - - return nil - }) -} - -// DeriveFromKeyPath derives a private key using the given derivation path. -func (w *Wallet) DeriveFromKeyPath(scope waddrmgr.KeyScope, - path waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { - - scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, fmt.Errorf("error fetching manager for scope %v: "+ - "%w", scope, err) - } - - // Let's see if we can hit the private key cache. - privKey, err := scopedMgr.DeriveFromKeyPathCache(path) - if err == nil { - return privKey, nil - } - - // The key wasn't in the cache, let's fully derive it now. - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - - addr, err := scopedMgr.DeriveFromKeyPath(addrmgrNs, path) - if err != nil { - return fmt.Errorf("error deriving private key: %w", err) - } - - mpka, ok := addr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - err := fmt.Errorf("managed address type for %v is "+ - "`%T` but want waddrmgr.ManagedPubKeyAddress", - addr, addr) - - return err - } - - privKey, err = mpka.PrivKey() - - return err - }) - if err != nil { - return nil, err - } - - return privKey, nil -} - -// DeriveFromKeyPathAddAccount derives a private key using the given derivation -// path. The account will be created if it doesn't exist. -func (w *Wallet) DeriveFromKeyPathAddAccount(scope waddrmgr.KeyScope, - path waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { - - scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, fmt.Errorf("error fetching manager for scope %v: "+ - "%w", scope, err) - } - - // Let's see if we can hit the private key cache. - privKey, err := scopedMgr.DeriveFromKeyPathCache(path) - if err == nil { - return privKey, nil - } - - derivePrivKey := func(addrmgrNs walletdb.ReadWriteBucket) error { - addr, err := scopedMgr.DeriveFromKeyPath(addrmgrNs, path) - - // Exit early if there's no error. - if err == nil { - key, ok := addr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return nil - } - - // Overwrite the returned private key variable. - privKey, err = key.PrivKey() - - return err - } - - return err - } - - // The key wasn't in the cache, let's fully derive it now. - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - - err := derivePrivKey(addrmgrNs) - - // Exit early if there's no error. - if err == nil { - return nil - } - - // Exit with the error if it's not account not found. - if !waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound) { - return fmt.Errorf("error deriving private key: %w", err) - } - - // If we've reached this point, then the account doesn't yet - // exist, so we'll create it now to ensure we can sign. - err = scopedMgr.NewRawAccount(addrmgrNs, path.Account) - if err != nil { - return err - } - - // Now that we know the account exists, we'll attempt to - // re-derive the private key. - return derivePrivKey(addrmgrNs) - }) - if err != nil { - return nil, err - } - - return privKey, nil -} - // SyncedTo calls the `SyncedTo` method on the wallet's manager. func (w *Wallet) SyncedTo() waddrmgr.BlockStamp { return w.addrStore.SyncedTo() @@ -2787,32 +347,6 @@ func (w *Wallet) NotificationServer() *NotificationServer { return w.NtfnServer } -func (w *Wallet) newAddressDeprecated(addrmgrNs walletdb.ReadWriteBucket, - account uint32, scope waddrmgr.KeyScope) (btcutil.Address, - *waddrmgr.AccountProperties, error) { - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, nil, err - } - - // Get next address from wallet. - addrs, err := manager.NextExternalAddresses(addrmgrNs, account, 1) - if err != nil { - return nil, nil, err - } - - props, err := manager.AccountProperties(addrmgrNs, account) - if err != nil { - log.Errorf("Cannot fetch account properties for notification "+ - "after deriving next external address: %v", err) - - return nil, nil, err - } - - return addrs[0].Address(), props, nil -} - // CreateWithCallback is the same as Create with an added callback that will be // called in the same transaction the wallet structure is initialized. func CreateWithCallback(db walletdb.DB, pubPass, privPass []byte, @@ -2836,20 +370,6 @@ func CreateWatchingOnlyWithCallback(db walletdb.DB, pubPass []byte, ) } -// CreateDeprecated creates an new wallet, writing it to an empty database. -// If the passed root key is non-nil, it is used. Otherwise, a secure -// random seed of the recommended length is generated. -// -// Deprecated: Use wallet.Create instead. -func CreateDeprecated(db walletdb.DB, pubPass, privPass []byte, - rootKey *hdkeychain.ExtendedKey, params *chaincfg.Params, - birthday time.Time) error { - - return create( - db, pubPass, privPass, rootKey, params, birthday, false, nil, - ) -} - // CreateWatchingOnly creates an new watch-only wallet, writing it to // an empty database. No root key can be provided as this wallet will be // watching only. Likewise no private passphrase may be provided @@ -2923,6 +443,37 @@ func create(db walletdb.DB, pubPass, privPass []byte, }) } +// hasMinConfs checks whether a transaction at height txHeight has met minconf +// confirmations for a blockchain at height curHeight. +func hasMinConfs(minconf uint32, txHeight, curHeight int32) bool { + confs := calcConf(txHeight, curHeight) + if confs < 0 { + return false + } + + return uint32(confs) >= minconf +} + +// calcConf returns the number of confirmations for a transaction given its +// containing block height and the current best block height. Unconfirmed +// transactions have a height of -1 and are considered to have 0 confirmations. +func calcConf(txHeight, curHeight int32) int32 { + switch { + // Unconfirmed transactions have 0 confirmations. + case txHeight == -1: + return 0 + + // A transaction in a block after the current best block is considered + // unconfirmed. This can happen during a chain reorg. + case txHeight > curHeight: + return 0 + + // Confirmed transactions have at least one confirmation. + default: + return curHeight - txHeight + 1 + } +} + // Open loads an already-created wallet from the passed database and namespaces. func Open(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, params *chaincfg.Params, recoveryWindow uint32) (*Wallet, error) { From 7d85e083c6c097eee27084554f9e4f7a835567e9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 20 Dec 2025 16:36:11 +0800 Subject: [PATCH 229/691] wallet: move coin selection, key lookup, and locking to managers Move specialized logic from wallet.go to respective managers: - Coin and selection strategy to tx_creator.go. - PrivKeyForAddress to signer.go. - LockedOutpoint to utxo_manager.go. This further decouples the Wallet struct and aligns with the manager-based architecture. --- wallet/signer.go | 35 ++++++++++++++++++++++ wallet/tx_creator.go | 29 ++++++++++++++++++ wallet/utxo_manager.go | 11 +++++++ wallet/wallet.go | 68 ------------------------------------------ 4 files changed, 75 insertions(+), 68 deletions(-) diff --git a/wallet/signer.go b/wallet/signer.go index 2eddd2353c..671cedf1bb 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -836,3 +836,38 @@ func (w *Wallet) GetPrivKeyForAddress(_ context.Context, a btcutil.Address) ( return w.PrivKeyForAddress(a) } + +// PrivKeyForAddress looks up the associated private key for a P2PKH or P2PK +// address. +func (w *Wallet) PrivKeyForAddress(a btcutil.Address) ( + *btcec.PrivateKey, error) { + + var privKey *btcec.PrivateKey + + err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + addr, err := w.addrStore.Address(addrmgrNs, a) + if err != nil { + return fmt.Errorf("failed to get address: %w", err) + } + + managedPubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return ErrNoAssocPrivateKey + } + + privKey, err = managedPubKeyAddr.PrivKey() + if err != nil { + return fmt.Errorf("failed to get private key: %w", err) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to view database: %w", err) + } + + return privKey, nil +} diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index 38652ffed1..a7cf65fbe7 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -15,6 +15,7 @@ import ( "errors" "fmt" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/waddrmgr" @@ -85,6 +86,34 @@ var ( DefaultMaxFeeRate = btcunit.NewSatPerKVByte(1_000_000) ) +// Coin represents a spendable UTXO which is available for coin selection. +type Coin struct { + wire.TxOut + wire.OutPoint +} + +// CoinSelectionStrategy is an interface that represents a coin selection +// strategy. A coin selection strategy is responsible for ordering, shuffling or +// filtering a list of coins before they are passed to the coin selection +// algorithm. +type CoinSelectionStrategy interface { + // ArrangeCoins takes a list of coins and arranges them according to the + // specified coin selection strategy and fee rate. + ArrangeCoins(eligible []Coin, feeSatPerKb btcutil.Amount) ([]Coin, + error) +} + +var ( + // CoinSelectionLargest always picks the largest available utxo to add + // to the transaction next. + CoinSelectionLargest CoinSelectionStrategy = &LargestFirstCoinSelector{} + + // CoinSelectionRandom randomly selects the next utxo to add to the + // transaction. This strategy prevents the creation of ever smaller + // utxos over time. + CoinSelectionRandom CoinSelectionStrategy = &RandomCoinSelector{} +) + // TxCreator provides an interface for creating transactions. Its primary // role is to produce a fully-formed, unsigned transaction that can be passed // to the Signer interface. diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 6724fd975a..f91091b23a 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -518,3 +518,14 @@ func (w *Wallet) ListLeasedOutputs( return leasedOutputs, err } + +// LockedOutpoint returns whether an outpoint has been marked as locked and +// should not be used as an input for created transactions. +func (w *Wallet) LockedOutpoint(op wire.OutPoint) bool { + w.lockedOutpointsMtx.Lock() + defer w.lockedOutpointsMtx.Unlock() + + _, locked := w.lockedOutpoints[op] + + return locked +} diff --git a/wallet/wallet.go b/wallet/wallet.go index bb570cb979..8b0b6389b9 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -18,8 +18,6 @@ import ( "sync" "time" - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -84,34 +82,6 @@ var ( wtxmgrNamespaceKey = []byte("wtxmgr") ) -// Coin represents a spendable UTXO which is available for coin selection. -type Coin struct { - wire.TxOut - - wire.OutPoint -} - -// CoinSelectionStrategy is an interface that represents a coin selection -// strategy. A coin selection strategy is responsible for ordering, shuffling or -// filtering a list of coins before they are passed to the coin selection -// algorithm. -type CoinSelectionStrategy interface { - // ArrangeCoins takes a list of coins and arranges them according to the - // specified coin selection strategy and fee rate. - ArrangeCoins(eligible []Coin, feeSatPerKb btcutil.Amount) ([]Coin, - error) -} - -var ( - // CoinSelectionLargest always picks the largest available utxo to add - // to the transaction next. - CoinSelectionLargest CoinSelectionStrategy = &LargestFirstCoinSelector{} - - // CoinSelectionRandom randomly selects the next utxo to add to the - // transaction. This strategy prevents the creation of ever smaller - // utxos over time. - CoinSelectionRandom CoinSelectionStrategy = &RandomCoinSelector{} -) // locateBirthdayBlock returns a block that meets the given birthday timestamp // by a margin of +/-2 hours. This is safe to do as the timestamp is already 2 @@ -236,44 +206,6 @@ type Wallet struct { // AccountAddresses returns the addresses for every created address for an // account. -// PrivKeyForAddress looks up the associated private key for a P2PKH or P2PK -// address. -func (w *Wallet) PrivKeyForAddress(a btcutil.Address) ( - *btcec.PrivateKey, error) { - - var privKey *btcec.PrivateKey - - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - - addr, err := w.addrStore.Address(addrmgrNs, a) - if err != nil { - return err - } - - managedPubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return ErrNoAssocPrivateKey - } - - privKey, err = managedPubKeyAddr.PrivKey() - - return err - }) - - return privKey, err -} - -// LockedOutpoint returns whether an outpoint has been marked as locked and -// should not be used as an input for created transactions. -func (w *Wallet) LockedOutpoint(op wire.OutPoint) bool { - w.lockedOutpointsMtx.Lock() - defer w.lockedOutpointsMtx.Unlock() - - _, locked := w.lockedOutpoints[op] - - return locked -} // ChainParams returns the network parameters for the blockchain the wallet // belongs to. From 81df44225eb5c5aecb9f5bc6086dcfeb02aaa450 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 20 Dec 2025 16:48:54 +0800 Subject: [PATCH 230/691] wallet: refactor legacy logic from createtx and chainntfns This commit continues the wallet package refactoring by moving legacy logic from createtx.go, chainntfns.go, and import.go to deprecated.go. chainntfns.go (legacy RPC notifications) and import.go (legacy imports) were moved entirely to deprecated.go and deleted. createtx.go was split: legacy logic moved to deprecated.go, while helpers used by the new TxCreator were moved to tx_creator.go. The file was deleted. --- wallet/chainntfns.go | 546 ---------------- wallet/createtx.go | 599 ------------------ wallet/deprecated.go | 1403 +++++++++++++++++++++++++++++++++++++++++- wallet/import.go | 600 ------------------ wallet/tx_creator.go | 301 +++++++++ wallet/wallet.go | 7 +- 6 files changed, 1706 insertions(+), 1750 deletions(-) delete mode 100644 wallet/chainntfns.go delete mode 100644 wallet/createtx.go delete mode 100644 wallet/import.go diff --git a/wallet/chainntfns.go b/wallet/chainntfns.go deleted file mode 100644 index d9f2294b35..0000000000 --- a/wallet/chainntfns.go +++ /dev/null @@ -1,546 +0,0 @@ -// Copyright (c) 2013-2015 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "bytes" - "time" - - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/chain" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/walletdb" - "github.com/btcsuite/btcwallet/wtxmgr" -) - -const ( - // birthdayBlockDelta is the maximum time delta allowed between our - // birthday timestamp and our birthday block's timestamp when searching - // for a better birthday block candidate (if possible). - birthdayBlockDelta = 2 * time.Hour -) - -func (w *Wallet) handleChainNotifications() { - defer w.wg.Done() - - chainClient, err := w.requireChainClient() - if err != nil { - log.Errorf("handleChainNotifications called without RPC client") - return - } - - catchUpHashes := func(w *Wallet, client chain.Interface, - height int32) error { - // TODO(aakselrod): There's a race condition here, which - // happens when a reorg occurs between the - // rescanProgress notification and the last GetBlockHash - // call. The solution when using btcd is to make btcd - // send blockconnected notifications with each block - // the way Neutrino does, and get rid of the loop. The - // other alternative is to check the final hash and, - // if it doesn't match the original hash returned by - // the notification, to roll back and restart the - // rescan. - log.Infof("Catching up block hashes to height %d, this"+ - " might take a while", height) - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - - startBlock := w.addrStore.SyncedTo() - - for i := startBlock.Height + 1; i <= height; i++ { - hash, err := client.GetBlockHash(int64(i)) - if err != nil { - return err - } - header, err := chainClient.GetBlockHeader(hash) - if err != nil { - return err - } - - bs := waddrmgr.BlockStamp{ - Height: i, - Hash: *hash, - Timestamp: header.Timestamp, - } - - err = w.addrStore.SetSyncedTo(ns, &bs) - if err != nil { - return err - } - } - return nil - }) - if err != nil { - log.Errorf("Failed to update address manager "+ - "sync state for height %d: %v", height, err) - } - - log.Info("Done catching up block hashes") - return err - } - - waitForSync := func(birthdayBlock *waddrmgr.BlockStamp) error { - // We start with a retry delay of 0 to execute the first attempt - // immediately. - var retryDelay time.Duration - for { - select { - case <-time.After(retryDelay): - // Set the delay to the configured value in case - // we actually need to re-try. - retryDelay = w.syncRetryInterval - - // Sync may be interrupted by actions such as - // locking the wallet. Try again after waiting a - // bit. - err = w.syncWithChain(birthdayBlock) - if err != nil { - if w.ShuttingDown() { - return ErrWalletShuttingDown - } - - log.Errorf("Unable to synchronize "+ - "wallet to chain, trying "+ - "again in %s: %v", - w.syncRetryInterval, err) - - continue - } - - return nil - - case <-w.quitChan(): - return ErrWalletShuttingDown - } - } - } - - for { - select { - case n, ok := <-chainClient.Notifications(): - if !ok { - return - } - - var notificationName string - var err error - switch n := n.(type) { - case chain.ClientConnected: - // Before attempting to sync with our backend, - // we'll make sure that our birthday block has - // been set correctly to potentially prevent - // missing relevant events. - birthdayStore := &walletBirthdayStore{ - db: w.db, - manager: w.addrStore, - } - birthdayBlock, err := birthdaySanityCheck( - chainClient, birthdayStore, - ) - if err != nil && !waddrmgr.IsError( - err, waddrmgr.ErrBirthdayBlockNotSet, - ) { - - log.Errorf("Unable to sanity check "+ - "wallet birthday block: %v", - err) - } - - err = waitForSync(birthdayBlock) - if err != nil { - log.Infof("Stopped waiting for wallet "+ - "sync due to error: %v", err) - - return - } - - case chain.BlockConnected: - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - return w.connectBlock(tx, wtxmgr.BlockMeta(n)) - }) - notificationName = "block connected" - case chain.BlockDisconnected: - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - return w.disconnectBlock(tx, wtxmgr.BlockMeta(n)) - }) - notificationName = "block disconnected" - case chain.RelevantTx: - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - return w.addRelevantTx(tx, n.TxRecord, n.Block) - }) - notificationName = "relevant transaction" - case chain.FilteredBlockConnected: - // Atomically update for the whole block. - if len(n.RelevantTxs) > 0 { - err = walletdb.Update(w.db, func( - tx walletdb.ReadWriteTx) error { - var err error - for _, rec := range n.RelevantTxs { - err = w.addRelevantTx(tx, rec, - n.Block) - if err != nil { - return err - } - } - return nil - }) - } - notificationName = "filtered block connected" - - // The following require some database maintenance, but also - // need to be reported to the wallet's rescan goroutine. - case *chain.RescanProgress: - err = catchUpHashes(w, chainClient, n.Height) - notificationName = "rescan progress" - select { - case w.rescanNotifications <- n: - case <-w.quitChan(): - return - } - case *chain.RescanFinished: - err = catchUpHashes(w, chainClient, n.Height) - notificationName = "rescan finished" - w.SetChainSynced(true) - select { - case w.rescanNotifications <- n: - case <-w.quitChan(): - return - } - } - if err != nil { - // If we received a block connected notification - // while rescanning, then we can ignore logging - // the error as we'll properly catch up once we - // process the RescanFinished notification. - if notificationName == "block connected" && - waddrmgr.IsError(err, waddrmgr.ErrBlockNotFound) && - !w.ChainSynced() { - - log.Debugf("Received block connected "+ - "notification for height %v "+ - "while rescanning", - n.(chain.BlockConnected).Height) - continue - } - - log.Errorf("Unable to process chain backend "+ - "%v notification: %v", notificationName, - err) - } - case <-w.quit: - return - } - } -} - -// connectBlock handles a chain server notification by marking a wallet -// that's currently in-sync with the chain server as being synced up to -// the passed block. -func (w *Wallet) connectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) error { - addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) - - bs := waddrmgr.BlockStamp{ - Height: b.Height, - Hash: b.Hash, - Timestamp: b.Time, - } - - err := w.addrStore.SetSyncedTo(addrmgrNs, &bs) - if err != nil { - return err - } - - // Notify interested clients of the connected block. - // - // TODO: move all notifications outside of the database transaction. - w.NtfnServer.notifyAttachedBlock(dbtx, &b) - return nil -} - -// disconnectBlock handles a chain server reorganize by rolling back all -// block history from the reorged block for a wallet in-sync with the chain -// server. -func (w *Wallet) disconnectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) error { - addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) - txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) - - if !w.ChainSynced() { - return nil - } - - // Disconnect the removed block and all blocks after it if we know about - // the disconnected block. Otherwise, the block is in the future. - //nolint:nestif - if b.Height <= w.addrStore.SyncedTo().Height { - hash, err := w.addrStore.BlockHash(addrmgrNs, b.Height) - if err != nil { - return err - } - if bytes.Equal(hash[:], b.Hash[:]) { - bs := waddrmgr.BlockStamp{ - Height: b.Height - 1, - } - - hash, err = w.addrStore.BlockHash(addrmgrNs, bs.Height) - if err != nil { - return err - } - b.Hash = *hash - - client := w.ChainClient() - header, err := client.GetBlockHeader(hash) - if err != nil { - return err - } - - bs.Timestamp = header.Timestamp - - err = w.addrStore.SetSyncedTo(addrmgrNs, &bs) - if err != nil { - return err - } - - err = w.txStore.Rollback(txmgrNs, b.Height) - if err != nil { - return err - } - } - } - - // Notify interested clients of the disconnected block. - w.NtfnServer.notifyDetachedBlock(&b.Hash) - - return nil -} - -func (w *Wallet) addRelevantTx(dbtx walletdb.ReadWriteTx, rec *wtxmgr.TxRecord, - block *wtxmgr.BlockMeta) error { - - addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) - txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) - - // At the moment all notified transactions are assumed to actually be - // relevant. This assumption will not hold true when SPV support is - // added, but until then, simply insert the transaction because there - // should either be one or more relevant inputs or outputs. - exists, err := w.txStore.InsertTxCheckIfExists(txmgrNs, rec, block) - if err != nil { - return err - } - - // If the transaction has already been recorded, we can return early. - // Note: Returning here is safe as we're within the context of an atomic - // database transaction, so we don't need to worry about the MarkUsed - // calls below. - if exists { - return nil - } - - // Check every output to determine whether it is controlled by a wallet - // key. If so, mark the output as a credit. - for i, output := range rec.MsgTx.TxOut { - _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, - w.chainParams) - if err != nil { - // Non-standard outputs are skipped. - log.Warnf("Cannot extract non-std pkScript=%x", - output.PkScript) - - continue - } - - for _, addr := range addrs { - ma, err := w.addrStore.Address(addrmgrNs, addr) - - switch { - // Missing addresses are skipped. - case waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound): - continue - - // Other errors should be propagated. - case err != nil: - return err - } - - // Prevent addresses from non-default scopes to be - // detected here. We don't watch funds sent to - // non-default scopes in other places either, so - // detecting them here would mean we'd also not properly - // detect them as spent later. - scopedManager, _, err := w.addrStore.AddrAccount( - addrmgrNs, addr, - ) - if err != nil { - return err - } - if !waddrmgr.IsDefaultScope(scopedManager.Scope()) { - log.Debugf("Skipping non-default scope "+ - "address %v", addr) - - continue - } - - // TODO: Credits should be added with the - // account they belong to, so wtxmgr is able to - // track per-account balances. - err = w.txStore.AddCredit( - txmgrNs, rec, block, uint32(i), ma.Internal(), - ) - if err != nil { - return err - } - - err = w.addrStore.MarkUsed(addrmgrNs, addr) - if err != nil { - return err - } - log.Debugf("Marked address %v used", addr) - } - } - - // Send notification of mined or unmined transaction to any interested - // clients. - // - // TODO: Avoid the extra db hits. - if block == nil { - w.NtfnServer.notifyUnminedTransaction(dbtx, txmgrNs, rec.Hash) - } else { - w.NtfnServer.notifyMinedTransaction( - dbtx, txmgrNs, rec.Hash, block, - ) - } - - return nil -} - -// chainConn is an interface that abstracts the chain connection logic required -// to perform a wallet's birthday block sanity check. -type chainConn interface { - // GetBestBlock returns the hash and height of the best block known to - // the backend. - GetBestBlock() (*chainhash.Hash, int32, error) - - // GetBlockHash returns the hash of the block with the given height. - GetBlockHash(int64) (*chainhash.Hash, error) - - // GetBlockHeader returns the header for the block with the given hash. - GetBlockHeader(*chainhash.Hash) (*wire.BlockHeader, error) -} - -// birthdayStore is an interface that abstracts the wallet's sync-related -// information required to perform a birthday block sanity check. -type birthdayStore interface { - // Birthday returns the birthday timestamp of the wallet. - Birthday() time.Time - - // BirthdayBlock returns the birthday block of the wallet. The boolean - // returned should signal whether the wallet has already verified the - // correctness of its birthday block. - BirthdayBlock() (waddrmgr.BlockStamp, bool, error) - - // SetBirthdayBlock updates the birthday block of the wallet to the - // given block. The boolean can be used to signal whether this block - // should be sanity checked the next time the wallet starts. - // - // NOTE: This should also set the wallet's synced tip to reflect the new - // birthday block. This will allow the wallet to rescan from this point - // to detect any potentially missed events. - SetBirthdayBlock(waddrmgr.BlockStamp) error -} - -// walletBirthdayStore is a wrapper around the wallet's database and address -// manager that satisfies the birthdayStore interface. -type walletBirthdayStore struct { - db walletdb.DB - manager waddrmgr.AddrStore -} - -var _ birthdayStore = (*walletBirthdayStore)(nil) - -// Birthday returns the birthday timestamp of the wallet. -func (s *walletBirthdayStore) Birthday() time.Time { - return s.manager.Birthday() -} - -// BirthdayBlock returns the birthday block of the wallet. -func (s *walletBirthdayStore) BirthdayBlock() (waddrmgr.BlockStamp, bool, error) { - var ( - birthdayBlock waddrmgr.BlockStamp - birthdayBlockVerified bool - ) - - err := walletdb.View(s.db, func(tx walletdb.ReadTx) error { - var err error - ns := tx.ReadBucket(waddrmgrNamespaceKey) - birthdayBlock, birthdayBlockVerified, err = s.manager.BirthdayBlock(ns) - return err - }) - - return birthdayBlock, birthdayBlockVerified, err -} - -// SetBirthdayBlock updates the birthday block of the wallet to the -// given block. The boolean can be used to signal whether this block -// should be sanity checked the next time the wallet starts. -// -// NOTE: This should also set the wallet's synced tip to reflect the new -// birthday block. This will allow the wallet to rescan from this point -// to detect any potentially missed events. -func (s *walletBirthdayStore) SetBirthdayBlock(block waddrmgr.BlockStamp) error { - return walletdb.Update(s.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - err := s.manager.SetBirthdayBlock(ns, block, true) - if err != nil { - return err - } - return s.manager.SetSyncedTo(ns, &block) - }) -} - -// birthdaySanityCheck is a helper function that ensures a birthday block -// correctly reflects the birthday timestamp within a reasonable timestamp -// delta. It's intended to be run after the wallet establishes its connection -// with the backend, but before it begins syncing. This is done as the second -// part to the wallet's address manager migration where we populate the birthday -// block to ensure we do not miss any relevant events throughout rescans. -// waddrmgr.ErrBirthdayBlockNotSet is returned if the birthday block has not -// been set yet. -func birthdaySanityCheck(chainConn chainConn, - birthdayStore birthdayStore) (*waddrmgr.BlockStamp, error) { - - // We'll start by fetching our wallet's birthday timestamp and block. - birthdayTimestamp := birthdayStore.Birthday() - birthdayBlock, birthdayBlockVerified, err := birthdayStore.BirthdayBlock() - if err != nil { - return nil, err - } - - // If the birthday block has already been verified to be correct, we can - // exit our sanity check to prevent potentially fetching a better - // candidate. - if birthdayBlockVerified { - log.Debugf("Birthday block has already been verified: "+ - "height=%d, hash=%v", birthdayBlock.Height, - birthdayBlock.Hash) - - return &birthdayBlock, nil - } - - // Otherwise, we'll attempt to locate a better one now that we have - // access to the chain. - newBirthdayBlock, err := locateBirthdayBlock(chainConn, birthdayTimestamp) - if err != nil { - return nil, err - } - - if err := birthdayStore.SetBirthdayBlock(*newBirthdayBlock); err != nil { - return nil, err - } - - return newBirthdayBlock, nil -} diff --git a/wallet/createtx.go b/wallet/createtx.go deleted file mode 100644 index f1435d4920..0000000000 --- a/wallet/createtx.go +++ /dev/null @@ -1,599 +0,0 @@ -// Copyright (c) 2013-2017 The btcsuite developers -// Copyright (c) 2015-2016 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "errors" - "fmt" - "math/rand" - "sort" - - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/wallet/txauthor" - "github.com/btcsuite/btcwallet/wallet/txsizes" - "github.com/btcsuite/btcwallet/walletdb" - "github.com/btcsuite/btcwallet/wtxmgr" - "github.com/lightningnetwork/lnd/fn/v2" -) - -func makeInputSource(eligible []Coin) txauthor.InputSource { - // Current inputs and their total value. These are closed over by the - // returned input source and reused across multiple calls. - currentTotal := btcutil.Amount(0) - currentInputs := make([]*wire.TxIn, 0, len(eligible)) - currentScripts := make([][]byte, 0, len(eligible)) - currentInputValues := make([]btcutil.Amount, 0, len(eligible)) - - return func(target btcutil.Amount) (btcutil.Amount, []*wire.TxIn, - []btcutil.Amount, [][]byte, error) { - - for currentTotal < target && len(eligible) != 0 { - nextCredit := eligible[0] - prevOut := nextCredit.TxOut - outpoint := nextCredit.OutPoint - eligible = eligible[1:] - - nextInput := wire.NewTxIn(&outpoint, nil, nil) - currentTotal += btcutil.Amount(prevOut.Value) - currentInputs = append(currentInputs, nextInput) - currentScripts = append( - currentScripts, prevOut.PkScript, - ) - currentInputValues = append( - currentInputValues, - btcutil.Amount(prevOut.Value), - ) - } - - return currentTotal, currentInputs, currentInputValues, - currentScripts, nil - } -} - -// constantInputSource creates an input source function that always returns the -// static set of user-selected UTXOs. -func constantInputSource(eligible []wtxmgr.Credit) txauthor.InputSource { - // Current inputs and their total value. These won't change over - // different invocations as we want our inputs to remain static since - // they're selected by the user. - currentTotal := btcutil.Amount(0) - currentInputs := make([]*wire.TxIn, 0, len(eligible)) - currentScripts := make([][]byte, 0, len(eligible)) - currentInputValues := make([]btcutil.Amount, 0, len(eligible)) - - for _, credit := range eligible { - nextInput := wire.NewTxIn(&credit.OutPoint, nil, nil) - currentTotal += credit.Amount - currentInputs = append(currentInputs, nextInput) - currentScripts = append(currentScripts, credit.PkScript) - currentInputValues = append(currentInputValues, credit.Amount) - } - - return func(target btcutil.Amount) (btcutil.Amount, []*wire.TxIn, - []btcutil.Amount, [][]byte, error) { - - return currentTotal, currentInputs, currentInputValues, - currentScripts, nil - } -} - -// secretSource is an implementation of txauthor.SecretSource for the wallet's -// address manager. -type secretSource struct { - waddrmgr.AddrStore - - addrmgrNs walletdb.ReadBucket -} - -func (s secretSource) GetKey(addr btcutil.Address) (*btcec.PrivateKey, bool, error) { - ma, err := s.Address(s.addrmgrNs, addr) - if err != nil { - return nil, false, err - } - - mpka, ok := ma.(waddrmgr.ManagedPubKeyAddress) - if !ok { - e := fmt.Errorf("managed address type for %v is `%T` but "+ - "want waddrmgr.ManagedPubKeyAddress", addr, ma) - return nil, false, e - } - privKey, err := mpka.PrivKey() - if err != nil { - return nil, false, err - } - return privKey, ma.Compressed(), nil -} - -func (s secretSource) GetScript(addr btcutil.Address) ([]byte, error) { - ma, err := s.Address(s.addrmgrNs, addr) - if err != nil { - return nil, err - } - - msa, ok := ma.(waddrmgr.ManagedScriptAddress) - if !ok { - e := fmt.Errorf("managed address type for %v is `%T` but "+ - "want waddrmgr.ManagedScriptAddress", addr, ma) - return nil, e - } - return msa.Script() -} - -// txToOutputs creates a signed transaction which includes each output from -// outputs. Previous outputs to redeem are chosen from the passed account's -// UTXO set and minconf policy. An additional output may be added to return -// change to the wallet. This output will have an address generated from the -// given key scope and account. If a key scope is not specified, the address -// will always be generated from the P2WKH key scope. An appropriate fee is -// included based on the wallet's current relay fee. The wallet must be -// unlocked to create the transaction. -// -// NOTE: The dryRun argument can be set true to create a tx that doesn't alter -// the database. A tx created with this set to true will intentionally have no -// input scripts added and SHOULD NOT be broadcasted. -func (w *Wallet) txToOutputs(outputs []*wire.TxOut, - coinSelectKeyScope, changeKeyScope *waddrmgr.KeyScope, - account uint32, minconf int32, feeSatPerKb btcutil.Amount, - strategy CoinSelectionStrategy, dryRun bool, - selectedUtxos []wire.OutPoint, - allowUtxo func(utxo wtxmgr.Credit) bool) ( - *txauthor.AuthoredTx, error) { - - chainClient, err := w.requireChainClient() - if err != nil { - return nil, err - } - - // Get current block's height and hash. - bs, err := chainClient.BlockStamp() - if err != nil { - return nil, err - } - - // Fall back to default coin selection strategy if none is supplied. - if strategy == nil { - strategy = CoinSelectionLargest - } - - // The addrMgrWithChangeSource function of the wallet creates a - // new change address. The address manager uses OnCommit on the - // walletdb tx to update the in-memory state of the account - // state. But because the commit happens _after_ the account - // manager internal lock has been released, there is a chance - // for the address index to be accessed concurrently, even - // though the closure in OnCommit re-acquires the lock. To avoid - // this issue, we surround the whole address creation process - // with a lock. - w.newAddrMtx.Lock() - defer w.newAddrMtx.Unlock() - - var tx *txauthor.AuthoredTx - err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { - addrmgrNs, changeSource, err := w.addrMgrWithChangeSource( - dbtx, changeKeyScope, account, - ) - if err != nil { - return err - } - - eligible, err := w.findEligibleOutputs( - dbtx, coinSelectKeyScope, account, - //nolint:gosec - uint32(minconf), - bs, allowUtxo, - ) - if err != nil { - return err - } - - var inputSource txauthor.InputSource - if len(selectedUtxos) > 0 { - dedupUtxos := fn.NewSet(selectedUtxos...) - if len(dedupUtxos) != len(selectedUtxos) { - return errors.New("selected UTXOs contain " + - "duplicate values") - } - - eligibleByOutpoint := make( - map[wire.OutPoint]wtxmgr.Credit, - ) - - for _, e := range eligible { - eligibleByOutpoint[e.OutPoint] = e - } - - var eligibleSelectedUtxo []wtxmgr.Credit - for _, outpoint := range selectedUtxos { - e, ok := eligibleByOutpoint[outpoint] - - if !ok { - return fmt.Errorf("selected outpoint "+ - "not eligible for "+ - "spending: %v", outpoint) - } - eligibleSelectedUtxo = append( - eligibleSelectedUtxo, e, - ) - } - - inputSource = constantInputSource(eligibleSelectedUtxo) - - } else { - // Wrap our coins in a type that implements the - // SelectableCoin interface, so we can arrange them - // according to the selected coin selection strategy. - wrappedEligible := make([]Coin, len(eligible)) - for i := range eligible { - wrappedEligible[i] = Coin{ - TxOut: wire.TxOut{ - Value: int64( - eligible[i].Amount, - ), - PkScript: eligible[i].PkScript, - }, - OutPoint: eligible[i].OutPoint, - } - } - - arrangedCoins, err := strategy.ArrangeCoins( - wrappedEligible, feeSatPerKb, - ) - if err != nil { - return err - } - inputSource = makeInputSource(arrangedCoins) - } - - tx, err = txauthor.NewUnsignedTransaction( - outputs, feeSatPerKb, inputSource, changeSource, - ) - if err != nil { - return err - } - - // Randomize change position, if change exists, before signing. - // This doesn't affect the serialize size, so the change amount - // will still be valid. - if tx.ChangeIndex >= 0 { - tx.RandomizeChangePosition() - } - - // If a dry run was requested, we return now before adding the - // input scripts, and don't commit the database transaction. - // By returning an error, we make sure the walletdb.Update call - // rolls back the transaction. But we'll react to this specific - // error outside of the DB transaction so we can still return - // the produced chain TX. - if dryRun { - return walletdb.ErrDryRunRollBack - } - - // Before committing the transaction, we'll sign our inputs. If - // the inputs are part of a watch-only account, there's no - // private key information stored, so we'll skip signing such. - var watchOnly bool - if coinSelectKeyScope == nil { - // If a key scope wasn't specified, then coin selection - // was performed from the default wallet accounts - // (NP2WKH, P2WKH, P2TR), so any key scope provided - // doesn't impact the result of this call. - watchOnly, err = w.addrStore.IsWatchOnlyAccount( - addrmgrNs, waddrmgr.KeyScopeBIP0086, account, - ) - } else { - watchOnly, err = w.addrStore.IsWatchOnlyAccount( - addrmgrNs, *coinSelectKeyScope, account, - ) - } - if err != nil { - return err - } - if !watchOnly { - err = tx.AddAllInputScripts( - secretSource{w.addrStore, addrmgrNs}, - ) - if err != nil { - return err - } - - err = validateMsgTx( - tx.Tx, tx.PrevScripts, tx.PrevInputValues, - ) - if err != nil { - return err - } - } - - if tx.ChangeIndex >= 0 && account == waddrmgr.ImportedAddrAccount { - changeAmount := btcutil.Amount( - tx.Tx.TxOut[tx.ChangeIndex].Value, - ) - log.Warnf("Spend from imported account produced "+ - "change: moving %v from imported account into "+ - "default account.", changeAmount) - } - - // Finally, we'll request the backend to notify us of the - // transaction that pays to the change address, if there is one, - // when it confirms. - if tx.ChangeIndex >= 0 { - changePkScript := tx.Tx.TxOut[tx.ChangeIndex].PkScript - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - changePkScript, w.chainParams, - ) - if err != nil { - return err - } - if err := chainClient.NotifyReceived(addrs); err != nil { - return err - } - } - - return nil - }) - if err != nil && !errors.Is(err, walletdb.ErrDryRunRollBack) { - return nil, err - } - - return tx, nil -} - -func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, - keyScope *waddrmgr.KeyScope, account uint32, minconf uint32, - bs *waddrmgr.BlockStamp, - allowUtxo func(utxo wtxmgr.Credit) bool) ([]wtxmgr.Credit, error) { - - addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) - txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) - - unspent, err := w.txStore.UnspentOutputs(txmgrNs) - if err != nil { - return nil, err - } - - // TODO: Eventually all of these filters (except perhaps output locking) - // should be handled by the call to UnspentOutputs (or similar). - // Because one of these filters requires matching the output script to - // the desired account, this change depends on making wtxmgr a waddrmgr - // dependency and requesting unspent outputs for a single account. - eligible := make([]wtxmgr.Credit, 0, len(unspent)) - for i := range unspent { - output := &unspent[i] - - // Restrict the selected utxos if a filter function is provided. - if allowUtxo != nil && - !allowUtxo(*output) { - - continue - } - - // Only include this output if it meets the required number of - // confirmations. Coinbase transactions must have reached - // maturity before their outputs may be spent. - if !hasMinConfs(minconf, output.Height, bs.Height) { - continue - } - if output.FromCoinBase { - target := w.chainParams.CoinbaseMaturity - if !hasMinConfs( - uint32(target), output.Height, bs.Height, - ) { - - continue - } - } - - // Locked unspent outputs are skipped. - if w.LockedOutpoint(output.OutPoint) { - continue - } - - // Only include the output if it is associated with the passed - // account. - // - // TODO: Handle multisig outputs by determining if enough of the - // addresses are controlled. - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - output.PkScript, w.chainParams) - if err != nil || len(addrs) != 1 { - continue - } - - scopedMgr, addrAcct, err := w.addrStore.AddrAccount( - addrmgrNs, addrs[0], - ) - if err != nil { - continue - } - if keyScope != nil && scopedMgr.Scope() != *keyScope { - continue - } - if addrAcct != account { - continue - } - eligible = append(eligible, *output) - } - return eligible, nil -} - -// inputYieldsPositively returns a boolean indicating whether this input yields -// positively if added to a transaction. This determination is based on the -// best-case added virtual size. For edge cases this function can return true -// while the input is yielding slightly negative as part of the final -// transaction. -func inputYieldsPositively(credit *wire.TxOut, - feeRatePerKb btcutil.Amount) bool { - - inputSize := txsizes.GetMinInputVirtualSize(credit.PkScript) - inputFee := feeRatePerKb * btcutil.Amount(inputSize) / 1000 - - return inputFee < btcutil.Amount(credit.Value) -} - -// addrMgrWithChangeSource returns the address manager bucket and a change -// source that returns change addresses from said address manager. The change -// addresses will come from the specified key scope and account, unless a key -// scope is not specified. In that case, change addresses will always come from -// the P2WKH key scope. -func (w *Wallet) addrMgrWithChangeSource(dbtx walletdb.ReadWriteTx, - changeKeyScope *waddrmgr.KeyScope, account uint32) ( - walletdb.ReadWriteBucket, *txauthor.ChangeSource, error) { - - // Determine the address type for change addresses of the given - // account. - if changeKeyScope == nil { - changeKeyScope = &waddrmgr.KeyScopeBIP0086 - } - addrType := waddrmgr.ScopeAddrMap[*changeKeyScope].InternalAddrType - - // It's possible for the account to have an address schema override, so - // prefer that if it exists. - addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) - - scopeMgr, err := w.addrStore.FetchScopedKeyManager(*changeKeyScope) - if err != nil { - return nil, nil, err - } - accountInfo, err := scopeMgr.AccountProperties(addrmgrNs, account) - if err != nil { - return nil, nil, err - } - if accountInfo.AddrSchema != nil { - addrType = accountInfo.AddrSchema.InternalAddrType - } - - // Compute the expected size of the script for the change address type. - var scriptSize int - switch addrType { - case waddrmgr.PubKeyHash: - scriptSize = txsizes.P2PKHPkScriptSize - case waddrmgr.NestedWitnessPubKey: - scriptSize = txsizes.NestedP2WPKHPkScriptSize - case waddrmgr.WitnessPubKey: - scriptSize = txsizes.P2WPKHPkScriptSize - case waddrmgr.TaprootPubKey: - scriptSize = txsizes.P2TRPkScriptSize - default: - return nil, nil, fmt.Errorf("unsupported address type: %v", - addrType) - } - - newChangeScript := func() ([]byte, error) { - // Derive the change output script. As a hack to allow spending - // from the imported account, change addresses are created from - // account 0. - var ( - changeAddr btcutil.Address - err error - ) - if account == waddrmgr.ImportedAddrAccount { - changeAddr, err = w.newChangeAddress( - addrmgrNs, 0, *changeKeyScope, - ) - } else { - changeAddr, err = w.newChangeAddress( - addrmgrNs, account, *changeKeyScope, - ) - } - if err != nil { - return nil, err - } - return txscript.PayToAddrScript(changeAddr) - } - - return addrmgrNs, &txauthor.ChangeSource{ - ScriptSize: scriptSize, - NewScript: newChangeScript, - }, nil -} - -// validateMsgTx verifies transaction input scripts for tx. All previous output -// scripts from outputs redeemed by the transaction, in the same order they are -// spent, must be passed in the prevScripts slice. -func validateMsgTx(tx *wire.MsgTx, prevScripts [][]byte, - inputValues []btcutil.Amount) error { - - inputFetcher, err := txauthor.TXPrevOutFetcher( - tx, prevScripts, inputValues, - ) - if err != nil { - return err - } - - hashCache := txscript.NewTxSigHashes(tx, inputFetcher) - for i, prevScript := range prevScripts { - vm, err := txscript.NewEngine( - prevScript, tx, i, txscript.StandardVerifyFlags, nil, - hashCache, int64(inputValues[i]), inputFetcher, - ) - if err != nil { - return fmt.Errorf("cannot create script engine: %w", err) - } - err = vm.Execute() - if err != nil { - return fmt.Errorf("cannot validate transaction: %w", err) - } - } - return nil -} - -// sortByAmount is a generic sortable type for sorting coins by their amount. -type sortByAmount []Coin - -func (s sortByAmount) Len() int { return len(s) } -func (s sortByAmount) Less(i, j int) bool { - return s[i].Value < s[j].Value -} -func (s sortByAmount) Swap(i, j int) { s[i], s[j] = s[j], s[i] } - -// LargestFirstCoinSelector is an implementation of the CoinSelectionStrategy -// that always selects the largest coins first. -type LargestFirstCoinSelector struct{} - -// ArrangeCoins takes a list of coins and arranges them according to the -// specified coin selection strategy and fee rate. -func (*LargestFirstCoinSelector) ArrangeCoins(eligible []Coin, - _ btcutil.Amount) ([]Coin, error) { - - sort.Sort(sort.Reverse(sortByAmount(eligible))) - - return eligible, nil -} - -// RandomCoinSelector is an implementation of the CoinSelectionStrategy that -// selects coins at random. This prevents the creation of ever smaller UTXOs -// over time that may never become economical to spend. -type RandomCoinSelector struct{} - -// ArrangeCoins takes a list of coins and arranges them according to the -// specified coin selection strategy and fee rate. -func (*RandomCoinSelector) ArrangeCoins(eligible []Coin, - feeSatPerKb btcutil.Amount) ([]Coin, error) { - - // Skip inputs that do not raise the total transaction output - // value at the requested fee rate. - positivelyYielding := make([]Coin, 0, len(eligible)) - for _, output := range eligible { - output := output - - if !inputYieldsPositively(&output.TxOut, feeSatPerKb) { - continue - } - - positivelyYielding = append(positivelyYielding, output) - } - - rand.Shuffle(len(positivelyYielding), func(i, j int) { - positivelyYielding[i], positivelyYielding[j] = - positivelyYielding[j], positivelyYielding[i] - }) - - return positivelyYielding, nil -} diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 983791a71f..e98d6c359a 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -4,6 +4,7 @@ package wallet import ( "bytes" "context" + "encoding/binary" "encoding/hex" "errors" "fmt" @@ -23,12 +24,14 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/netparams" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/davecgh/go-spew/spew" + "github.com/lightningnetwork/lnd/fn/v2" ) // NextAccount creates the next account and returns its account number. The @@ -2942,7 +2945,6 @@ func (w *Wallet) newChangeAddress(addrmgrNs walletdb.ReadWriteBucket, return addrs[0].Address(), nil } - // AccountTotalReceivedResult is a single result for the // Wallet.TotalReceivedForAccounts method. type AccountTotalReceivedResult struct { @@ -4553,8 +4555,6 @@ func (s creditSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } - - // ListLeasedOutputResult is a single result for the Wallet.ListLeasedOutputs method. // See that method for more details. type ListLeasedOutputResult struct { @@ -4772,6 +4772,1403 @@ func (w *Wallet) DeriveFromKeyPathAddAccount(scope waddrmgr.KeyScope, return privKey, nil } +func (w *Wallet) handleChainNotifications() { + defer w.wg.Done() + + chainClient, err := w.requireChainClient() + if err != nil { + log.Errorf("handleChainNotifications called without RPC client") + return + } + + catchUpHashes := func(w *Wallet, client chain.Interface, + height int32) error { + // TODO(aakselrod): There's a race condition here, which + // happens when a reorg occurs between the + // rescanProgress notification and the last GetBlockHash + // call. The solution when using btcd is to make btcd + // send blockconnected notifications with each block + // the way Neutrino does, and get rid of the loop. The + // other alternative is to check the final hash and, + // if it doesn't match the original hash returned by + // the notification, to roll back and restart the + // rescan. + log.Infof("Catching up block hashes to height %d, this"+ + " might take a while", height) + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + startBlock := w.addrStore.SyncedTo() + + for i := startBlock.Height + 1; i <= height; i++ { + hash, err := client.GetBlockHash(int64(i)) + if err != nil { + return err + } + header, err := chainClient.GetBlockHeader(hash) + if err != nil { + return err + } + + bs := waddrmgr.BlockStamp{ + Height: i, + Hash: *hash, + Timestamp: header.Timestamp, + } + + err = w.addrStore.SetSyncedTo(ns, &bs) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + log.Errorf("Failed to update address manager "+ + "sync state for height %d: %v", height, err) + } + + log.Info("Done catching up block hashes") + return err + } + + waitForSync := func(birthdayBlock *waddrmgr.BlockStamp) error { + // We start with a retry delay of 0 to execute the first attempt + // immediately. + var retryDelay time.Duration + for { + select { + case <-time.After(retryDelay): + // Set the delay to the configured value in case + // we actually need to re-try. + retryDelay = w.syncRetryInterval + + // Sync may be interrupted by actions such as + // locking the wallet. Try again after waiting a + // bit. + err = w.syncWithChain(birthdayBlock) + if err != nil { + if w.ShuttingDown() { + return ErrWalletShuttingDown + } + + log.Errorf("Unable to synchronize "+ + "wallet to chain, trying "+ + "again in %s: %v", + w.syncRetryInterval, err) + + continue + } + + return nil + + case <-w.quitChan(): + return ErrWalletShuttingDown + } + } + } + + for { + select { + case n, ok := <-chainClient.Notifications(): + if !ok { + return + } + + var notificationName string + var err error + switch n := n.(type) { + case chain.ClientConnected: + // Before attempting to sync with our backend, + // we'll make sure that our birthday block has + // been set correctly to potentially prevent + // missing relevant events. + birthdayStore := &walletBirthdayStore{ + db: w.db, + manager: w.addrStore, + } + birthdayBlock, err := birthdaySanityCheck( + chainClient, birthdayStore, + ) + if err != nil && !waddrmgr.IsError( + err, waddrmgr.ErrBirthdayBlockNotSet, + ) { + + log.Errorf("Unable to sanity check "+ + "wallet birthday block: %v", + err) + } + + err = waitForSync(birthdayBlock) + if err != nil { + log.Infof("Stopped waiting for wallet "+ + "sync due to error: %v", err) + + return + } + + case chain.BlockConnected: + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + return w.connectBlock(tx, wtxmgr.BlockMeta(n)) + }) + notificationName = "block connected" + case chain.BlockDisconnected: + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + return w.disconnectBlock(tx, wtxmgr.BlockMeta(n)) + }) + notificationName = "block disconnected" + case chain.RelevantTx: + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + return w.addRelevantTx(tx, n.TxRecord, n.Block) + }) + notificationName = "relevant transaction" + case chain.FilteredBlockConnected: + // Atomically update for the whole block. + if len(n.RelevantTxs) > 0 { + err = walletdb.Update(w.db, func( + tx walletdb.ReadWriteTx) error { + var err error + for _, rec := range n.RelevantTxs { + err = w.addRelevantTx(tx, rec, + n.Block) + if err != nil { + return err + } + } + return nil + }) + } + notificationName = "filtered block connected" + + // The following require some database maintenance, but also + // need to be reported to the wallet's rescan goroutine. + case *chain.RescanProgress: + err = catchUpHashes(w, chainClient, n.Height) + notificationName = "rescan progress" + select { + case w.rescanNotifications <- n: + case <-w.quitChan(): + return + } + case *chain.RescanFinished: + err = catchUpHashes(w, chainClient, n.Height) + notificationName = "rescan finished" + w.SetChainSynced(true) + select { + case w.rescanNotifications <- n: + case <-w.quitChan(): + return + } + } + if err != nil { + // If we received a block connected notification + // while rescanning, then we can ignore logging + // the error as we'll properly catch up once we + // process the RescanFinished notification. + if notificationName == "block connected" && + waddrmgr.IsError(err, waddrmgr.ErrBlockNotFound) && + !w.ChainSynced() { + + log.Debugf("Received block connected "+ + "notification for height %v "+ + "while rescanning", + n.(chain.BlockConnected).Height) + continue + } + + log.Errorf("Unable to process chain backend "+ + "%v notification: %v", notificationName, + err) + } + case <-w.quit: + return + } + } +} + +// connectBlock handles a chain server notification by marking a wallet +// that's currently in-sync with the chain server as being synced up to +// the passed block. +func (w *Wallet) connectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) error { + addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + + bs := waddrmgr.BlockStamp{ + Height: b.Height, + Hash: b.Hash, + Timestamp: b.Time, + } + + err := w.addrStore.SetSyncedTo(addrmgrNs, &bs) + if err != nil { + return err + } + + // Notify interested clients of the connected block. + // + // TODO: move all notifications outside of the database transaction. + w.NtfnServer.notifyAttachedBlock(dbtx, &b) + return nil +} + +// disconnectBlock handles a chain server reorganize by rolling back all +// block history from the reorged block for a wallet in-sync with the chain +// server. +func (w *Wallet) disconnectBlock(dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) error { + addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) + + if !w.ChainSynced() { + return nil + } + + // Disconnect the removed block and all blocks after it if we know about + // the disconnected block. Otherwise, the block is in the future. + //nolint:nestif + if b.Height <= w.addrStore.SyncedTo().Height { + hash, err := w.addrStore.BlockHash(addrmgrNs, b.Height) + if err != nil { + return err + } + if bytes.Equal(hash[:], b.Hash[:]) { + bs := waddrmgr.BlockStamp{ + Height: b.Height - 1, + } + + hash, err = w.addrStore.BlockHash(addrmgrNs, bs.Height) + if err != nil { + return err + } + b.Hash = *hash + + client := w.ChainClient() + header, err := client.GetBlockHeader(hash) + if err != nil { + return err + } + + bs.Timestamp = header.Timestamp + + err = w.addrStore.SetSyncedTo(addrmgrNs, &bs) + if err != nil { + return err + } + + err = w.txStore.Rollback(txmgrNs, b.Height) + if err != nil { + return err + } + } + } + + // Notify interested clients of the disconnected block. + w.NtfnServer.notifyDetachedBlock(&b.Hash) + + return nil +} + +func (w *Wallet) addRelevantTx(dbtx walletdb.ReadWriteTx, rec *wtxmgr.TxRecord, + block *wtxmgr.BlockMeta) error { + + addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) + + // At the moment all notified transactions are assumed to actually be + // relevant. This assumption will not hold true when SPV support is + // added, but until then, simply insert the transaction because there + // should either be one or more relevant inputs or outputs. + exists, err := w.txStore.InsertTxCheckIfExists(txmgrNs, rec, block) + if err != nil { + return err + } + + // If the transaction has already been recorded, we can return early. + // Note: Returning here is safe as we're within the context of an atomic + // database transaction, so we don't need to worry about the MarkUsed + // calls below. + if exists { + return nil + } + + // Check every output to determine whether it is controlled by a wallet + // key. If so, mark the output as a credit. + for i, output := range rec.MsgTx.TxOut { + _, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, + w.chainParams) + if err != nil { + // Non-standard outputs are skipped. + log.Warnf("Cannot extract non-std pkScript=%x", + output.PkScript) + + continue + } + + for _, addr := range addrs { + ma, err := w.addrStore.Address(addrmgrNs, addr) + + switch { + // Missing addresses are skipped. + case waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound): + continue + + // Other errors should be propagated. + case err != nil: + return err + } + + // Prevent addresses from non-default scopes to be + // detected here. We don't watch funds sent to + // non-default scopes in other places either, so + // detecting them here would mean we'd also not properly + // detect them as spent later. + scopedManager, _, err := w.addrStore.AddrAccount( + addrmgrNs, addr, + ) + if err != nil { + return err + } + if !waddrmgr.IsDefaultScope(scopedManager.Scope()) { + log.Debugf("Skipping non-default scope "+ + "address %v", addr) + + continue + } + + // TODO: Credits should be added with the + // account they belong to, so wtxmgr is able to + // track per-account balances. + err = w.txStore.AddCredit( + txmgrNs, rec, block, uint32(i), ma.Internal(), + ) + if err != nil { + return err + } + + err = w.addrStore.MarkUsed(addrmgrNs, addr) + if err != nil { + return err + } + log.Debugf("Marked address %v used", addr) + } + } + + // Send notification of mined or unmined transaction to any interested + // clients. + // + // TODO: Avoid the extra db hits. + if block == nil { + w.NtfnServer.notifyUnminedTransaction(dbtx, txmgrNs, rec.Hash) + } else { + w.NtfnServer.notifyMinedTransaction( + dbtx, txmgrNs, rec.Hash, block, + ) + } + + return nil +} + +// chainConn is an interface that abstracts the chain connection logic required +// to perform a wallet's birthday block sanity check. +type chainConn interface { + // GetBestBlock returns the hash and height of the best block known to + // the backend. + GetBestBlock() (*chainhash.Hash, int32, error) + + // GetBlockHash returns the hash of the block with the given height. + GetBlockHash(int64) (*chainhash.Hash, error) + + // GetBlockHeader returns the header for the block with the given hash. + GetBlockHeader(*chainhash.Hash) (*wire.BlockHeader, error) +} + +// birthdayStore is an interface that abstracts the wallet's sync-related +// information required to perform a birthday block sanity check. +type birthdayStore interface { + // Birthday returns the birthday timestamp of the wallet. + Birthday() time.Time + + // BirthdayBlock returns the birthday block of the wallet. The boolean + // returned should signal whether the wallet has already verified the + // correctness of its birthday block. + BirthdayBlock() (waddrmgr.BlockStamp, bool, error) + + // SetBirthdayBlock updates the birthday block of the wallet to the + // given block. The boolean can be used to signal whether this block + // should be sanity checked the next time the wallet starts. + // + // NOTE: This should also set the wallet's synced tip to reflect the new + // birthday block. This will allow the wallet to rescan from this point + // to detect any potentially missed events. + SetBirthdayBlock(waddrmgr.BlockStamp) error +} + +// walletBirthdayStore is a wrapper around the wallet's database and address +// manager that satisfies the birthdayStore interface. +type walletBirthdayStore struct { + db walletdb.DB + manager waddrmgr.AddrStore +} + +var _ birthdayStore = (*walletBirthdayStore)(nil) + +// Birthday returns the birthday timestamp of the wallet. +func (s *walletBirthdayStore) Birthday() time.Time { + return s.manager.Birthday() +} + +// BirthdayBlock returns the birthday block of the wallet. +func (s *walletBirthdayStore) BirthdayBlock() (waddrmgr.BlockStamp, bool, error) { + var ( + birthdayBlock waddrmgr.BlockStamp + birthdayBlockVerified bool + ) + + err := walletdb.View(s.db, func(tx walletdb.ReadTx) error { + var err error + ns := tx.ReadBucket(waddrmgrNamespaceKey) + birthdayBlock, birthdayBlockVerified, err = s.manager.BirthdayBlock(ns) + return err + }) + + return birthdayBlock, birthdayBlockVerified, err +} + +// SetBirthdayBlock updates the birthday block of the wallet to the +// given block. The boolean can be used to signal whether this block +// should be sanity checked the next time the wallet starts. +// +// NOTE: This should also set the wallet's synced tip to reflect the new +// birthday block. This will allow the wallet to rescan from this point +// to detect any potentially missed events. +func (s *walletBirthdayStore) SetBirthdayBlock(block waddrmgr.BlockStamp) error { + return walletdb.Update(s.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + err := s.manager.SetBirthdayBlock(ns, block, true) + if err != nil { + return err + } + return s.manager.SetSyncedTo(ns, &block) + }) +} + +// birthdaySanityCheck is a helper function that ensures a birthday block +// correctly reflects the birthday timestamp within a reasonable timestamp +// delta. It's intended to be run after the wallet establishes its connection +// with the backend, but before it begins syncing. This is done as the second +// part to the wallet's address manager migration where we populate the birthday +// block to ensure we do not miss any relevant events throughout rescans. +// waddrmgr.ErrBirthdayBlockNotSet is returned if the birthday block has not +// been set yet. +func birthdaySanityCheck(chainConn chainConn, + birthdayStore birthdayStore) (*waddrmgr.BlockStamp, error) { + + // We'll start by fetching our wallet's birthday timestamp and block. + birthdayTimestamp := birthdayStore.Birthday() + birthdayBlock, birthdayBlockVerified, err := birthdayStore.BirthdayBlock() + if err != nil { + return nil, err + } + + // If the birthday block has already been verified to be correct, we can + // exit our sanity check to prevent potentially fetching a better + // candidate. + if birthdayBlockVerified { + log.Debugf("Birthday block has already been verified: "+ + "height=%d, hash=%v", birthdayBlock.Height, + birthdayBlock.Hash) + + return &birthdayBlock, nil + } + + // Otherwise, we'll attempt to locate a better one now that we have + // access to the chain. + newBirthdayBlock, err := locateBirthdayBlock(chainConn, birthdayTimestamp) + if err != nil { + return nil, err + } + + if err := birthdayStore.SetBirthdayBlock(*newBirthdayBlock); err != nil { + return nil, err + } + + return newBirthdayBlock, nil +} + +// secretSource is an implementation of txauthor.SecretSource for the wallet's +// address manager. +type secretSource struct { + waddrmgr.AddrStore + + addrmgrNs walletdb.ReadBucket +} + +func (s secretSource) GetKey(addr btcutil.Address) (*btcec.PrivateKey, bool, error) { + ma, err := s.Address(s.addrmgrNs, addr) + if err != nil { + return nil, false, err + } + + mpka, ok := ma.(waddrmgr.ManagedPubKeyAddress) + if !ok { + e := fmt.Errorf("managed address type for %v is `%T` but "+ + "want waddrmgr.ManagedPubKeyAddress", addr, ma) + return nil, false, e + } + privKey, err := mpka.PrivKey() + if err != nil { + return nil, false, err + } + return privKey, ma.Compressed(), nil +} + +func (s secretSource) GetScript(addr btcutil.Address) ([]byte, error) { + ma, err := s.Address(s.addrmgrNs, addr) + if err != nil { + return nil, err + } + + msa, ok := ma.(waddrmgr.ManagedScriptAddress) + if !ok { + e := fmt.Errorf("managed address type for %v is `%T` but "+ + "want waddrmgr.ManagedScriptAddress", addr, ma) + return nil, e + } + return msa.Script() +} + +// txToOutputs creates a signed transaction which includes each output from +// outputs. Previous outputs to redeem are chosen from the passed account's +// UTXO set and minconf policy. An additional output may be added to return +// change to the wallet. This output will have an address generated from the +// given key scope and account. If a key scope is not specified, the address +// will always be generated from the P2WKH key scope. An appropriate fee is +// included based on the wallet's current relay fee. The wallet must be +// unlocked to create the transaction. +// +// NOTE: The dryRun argument can be set true to create a tx that doesn't alter +// the database. A tx created with this set to true will intentionally have no +// input scripts added and SHOULD NOT be broadcasted. +func (w *Wallet) txToOutputs(outputs []*wire.TxOut, + coinSelectKeyScope, changeKeyScope *waddrmgr.KeyScope, + account uint32, minconf int32, feeSatPerKb btcutil.Amount, + strategy CoinSelectionStrategy, dryRun bool, + selectedUtxos []wire.OutPoint, + allowUtxo func(utxo wtxmgr.Credit) bool) ( + *txauthor.AuthoredTx, error) { + + chainClient, err := w.requireChainClient() + if err != nil { + return nil, err + } + + // Get current block's height and hash. + bs, err := chainClient.BlockStamp() + if err != nil { + return nil, err + } + + // Fall back to default coin selection strategy if none is supplied. + if strategy == nil { + strategy = CoinSelectionLargest + } + + // The addrMgrWithChangeSource function of the wallet creates a + // new change address. The address manager uses OnCommit on the + // walletdb tx to update the in-memory state of the account + // state. But because the commit happens _after_ the account + // manager internal lock has been released, there is a chance + // for the address index to be accessed concurrently, even + // though the closure in OnCommit re-acquires the lock. To avoid + // this issue, we surround the whole address creation process + // with a lock. + w.newAddrMtx.Lock() + defer w.newAddrMtx.Unlock() + + var tx *txauthor.AuthoredTx + err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { + addrmgrNs, changeSource, err := w.addrMgrWithChangeSource( + dbtx, changeKeyScope, account, + ) + if err != nil { + return err + } + + eligible, err := w.findEligibleOutputs( + dbtx, coinSelectKeyScope, account, + //nolint:gosec + uint32(minconf), + bs, allowUtxo, + ) + if err != nil { + return err + } + + var inputSource txauthor.InputSource + if len(selectedUtxos) > 0 { + dedupUtxos := fn.NewSet(selectedUtxos...) + if len(dedupUtxos) != len(selectedUtxos) { + return errors.New("selected UTXOs contain " + + "duplicate values") + } + + eligibleByOutpoint := make( + map[wire.OutPoint]wtxmgr.Credit, + ) + + for _, e := range eligible { + eligibleByOutpoint[e.OutPoint] = e + } + + var eligibleSelectedUtxo []wtxmgr.Credit + for _, outpoint := range selectedUtxos { + e, ok := eligibleByOutpoint[outpoint] + + if !ok { + return fmt.Errorf("selected outpoint "+ + "not eligible for "+ + "spending: %v", outpoint) + } + eligibleSelectedUtxo = append( + eligibleSelectedUtxo, e, + ) + } + + inputSource = constantInputSource(eligibleSelectedUtxo) + + } else { + // Wrap our coins in a type that implements the + // SelectableCoin interface, so we can arrange them + // according to the selected coin selection strategy. + wrappedEligible := make([]Coin, len(eligible)) + for i := range eligible { + wrappedEligible[i] = Coin{ + TxOut: wire.TxOut{ + Value: int64( + eligible[i].Amount, + ), + PkScript: eligible[i].PkScript, + }, + OutPoint: eligible[i].OutPoint, + } + } + + arrangedCoins, err := strategy.ArrangeCoins( + wrappedEligible, feeSatPerKb, + ) + if err != nil { + return err + } + inputSource = makeInputSource(arrangedCoins) + } + + tx, err = txauthor.NewUnsignedTransaction( + outputs, feeSatPerKb, inputSource, changeSource, + ) + if err != nil { + return err + } + + // Randomize change position, if change exists, before signing. + // This doesn't affect the serialize size, so the change amount + // will still be valid. + if tx.ChangeIndex >= 0 { + tx.RandomizeChangePosition() + } + + // If a dry run was requested, we return now before adding the + // input scripts, and don't commit the database transaction. + // By returning an error, we make sure the walletdb.Update call + // rolls back the transaction. But we'll react to this specific + // error outside of the DB transaction so we can still return + // the produced chain TX. + if dryRun { + return walletdb.ErrDryRunRollBack + } + + // Before committing the transaction, we'll sign our inputs. If + // the inputs are part of a watch-only account, there's no + // private key information stored, so we'll skip signing such. + var watchOnly bool + if coinSelectKeyScope == nil { + // If a key scope wasn't specified, then coin selection + // was performed from the default wallet accounts + // (NP2WKH, P2WKH, P2TR), so any key scope provided + // doesn't impact the result of this call. + watchOnly, err = w.addrStore.IsWatchOnlyAccount( + addrmgrNs, waddrmgr.KeyScopeBIP0086, account, + ) + } else { + watchOnly, err = w.addrStore.IsWatchOnlyAccount( + addrmgrNs, *coinSelectKeyScope, account, + ) + } + if err != nil { + return err + } + if !watchOnly { + err = tx.AddAllInputScripts( + secretSource{w.addrStore, addrmgrNs}, + ) + if err != nil { + return err + } + + err = validateMsgTx( + tx.Tx, tx.PrevScripts, tx.PrevInputValues, + ) + if err != nil { + return err + } + } + + if tx.ChangeIndex >= 0 && account == waddrmgr.ImportedAddrAccount { + changeAmount := btcutil.Amount( + tx.Tx.TxOut[tx.ChangeIndex].Value, + ) + log.Warnf("Spend from imported account produced "+ + "change: moving %v from imported account into "+ + "default account.", changeAmount) + } + + // Finally, we'll request the backend to notify us of the + // transaction that pays to the change address, if there is one, + // when it confirms. + if tx.ChangeIndex >= 0 { + changePkScript := tx.Tx.TxOut[tx.ChangeIndex].PkScript + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + changePkScript, w.chainParams, + ) + if err != nil { + return err + } + if err := chainClient.NotifyReceived(addrs); err != nil { + return err + } + } + + return nil + }) + if err != nil && !errors.Is(err, walletdb.ErrDryRunRollBack) { + return nil, err + } + + return tx, nil +} + +// validateMsgTx verifies transaction input scripts for tx. All previous output +// scripts from outputs redeemed by the transaction, in the same order they are +// spent, must be passed in the prevScripts slice. +func validateMsgTx(tx *wire.MsgTx, prevScripts [][]byte, + inputValues []btcutil.Amount) error { + + inputFetcher, err := txauthor.TXPrevOutFetcher( + tx, prevScripts, inputValues, + ) + if err != nil { + return err + } + + hashCache := txscript.NewTxSigHashes(tx, inputFetcher) + for i, prevScript := range prevScripts { + vm, err := txscript.NewEngine( + prevScript, tx, i, txscript.StandardVerifyFlags, nil, + hashCache, int64(inputValues[i]), inputFetcher, + ) + if err != nil { + return fmt.Errorf("cannot create script engine: %w", err) + } + err = vm.Execute() + if err != nil { + return fmt.Errorf("cannot validate transaction: %w", err) + } + } + return nil +} + +const ( + // accountPubKeyDepth is the maximum depth of an extended key for an + // account public key. + accountPubKeyDepth = 3 + + // pubKeyDepth is the depth of an extended key for a derived public key. + pubKeyDepth = 5 +) + +// keyScopeFromPubKey returns the corresponding wallet key scope for the given +// extended public key. The address type can usually be inferred from the key's +// version, but may be required for certain keys to map them into the proper +// scope. +func keyScopeFromPubKey(pubKey *hdkeychain.ExtendedKey, + addrType *waddrmgr.AddressType) (waddrmgr.KeyScope, + *waddrmgr.ScopeAddrSchema, error) { + + switch waddrmgr.HDVersion(binary.BigEndian.Uint32(pubKey.Version())) { + // For BIP-0044 keys, an address type must be specified as we intend to + // not support importing BIP-0044 keys into the wallet using the legacy + // pay-to-pubkey-hash (P2PKH) scheme. A nested witness address type will + // force the standard BIP-0049 derivation scheme (nested witness pubkeys + // everywhere), while a witness address type will force the standard + // BIP-0084 derivation scheme. + case waddrmgr.HDVersionMainNetBIP0044, waddrmgr.HDVersionTestNetBIP0044, + waddrmgr.HDVersionSimNetBIP0044: + + if addrType == nil { + return waddrmgr.KeyScope{}, nil, errors.New("address " + + "type must be specified for account public " + + "key with legacy version") + } + + switch *addrType { + case waddrmgr.NestedWitnessPubKey: + return waddrmgr.KeyScopeBIP0049Plus, + &waddrmgr.KeyScopeBIP0049AddrSchema, nil + + case waddrmgr.WitnessPubKey: + return waddrmgr.KeyScopeBIP0084, nil, nil + + case waddrmgr.TaprootPubKey: + return waddrmgr.KeyScopeBIP0086, nil, nil + + default: + return waddrmgr.KeyScope{}, nil, + fmt.Errorf("unsupported address type %v", + *addrType) + } + + // For BIP-0049 keys, we'll need to make a distinction between the + // traditional BIP-0049 address schema (nested witness pubkeys + // everywhere) and our own BIP-0049Plus address schema (nested + // externally, witness internally). + case waddrmgr.HDVersionMainNetBIP0049, waddrmgr.HDVersionTestNetBIP0049: + if addrType == nil { + return waddrmgr.KeyScope{}, nil, errors.New("address " + + "type must be specified for account public " + + "key with BIP-0049 version") + } + + switch *addrType { + case waddrmgr.NestedWitnessPubKey: + return waddrmgr.KeyScopeBIP0049Plus, + &waddrmgr.KeyScopeBIP0049AddrSchema, nil + + case waddrmgr.WitnessPubKey: + return waddrmgr.KeyScopeBIP0049Plus, nil, nil + + default: + return waddrmgr.KeyScope{}, nil, + fmt.Errorf("unsupported address type %v", + *addrType) + } + + // BIP-0086 does not have its own SLIP-0132 HD version byte set (yet?). + // So we either expect a user to import it with a BIP-0084 or BIP-0044 + // encoding. + case waddrmgr.HDVersionMainNetBIP0084, waddrmgr.HDVersionTestNetBIP0084: + if addrType == nil { + return waddrmgr.KeyScope{}, nil, errors.New("address " + + "type must be specified for account public " + + "key with BIP-0084 version") + } + + switch *addrType { + case waddrmgr.WitnessPubKey: + return waddrmgr.KeyScopeBIP0084, nil, nil + + case waddrmgr.TaprootPubKey: + return waddrmgr.KeyScopeBIP0086, nil, nil + + default: + return waddrmgr.KeyScope{}, nil, + errors.New("address type mismatch") + } + + default: + return waddrmgr.KeyScope{}, nil, fmt.Errorf("unknown version %x", + pubKey.Version()) + } +} + +// isPubKeyForNet determines if the given public key is for the current network +// the wallet is operating under. +func (w *Wallet) isPubKeyForNet(pubKey *hdkeychain.ExtendedKey) bool { + version := waddrmgr.HDVersion(binary.BigEndian.Uint32(pubKey.Version())) + switch w.chainParams.Net { + case wire.MainNet: + return version == waddrmgr.HDVersionMainNetBIP0044 || + version == waddrmgr.HDVersionMainNetBIP0049 || + version == waddrmgr.HDVersionMainNetBIP0084 + + case wire.TestNet, wire.TestNet3, wire.TestNet4, + netparams.SigNetWire(w.chainParams): + + return version == waddrmgr.HDVersionTestNetBIP0044 || + version == waddrmgr.HDVersionTestNetBIP0049 || + version == waddrmgr.HDVersionTestNetBIP0084 + + // For simnet, we'll also allow the mainnet versions since simnet + // doesn't have defined versions for some of our key scopes, and the + // mainnet versions are usually used as the default regardless of the + // network/key scope. + case wire.SimNet: + return version == waddrmgr.HDVersionSimNetBIP0044 || + version == waddrmgr.HDVersionMainNetBIP0049 || + version == waddrmgr.HDVersionMainNetBIP0084 + + default: + return false + } +} + +// validateExtendedPubKey ensures a sane derived public key is provided. +func (w *Wallet) validateExtendedPubKey(pubKey *hdkeychain.ExtendedKey, + isAccountKey bool) error { + + // Private keys are not allowed. + if pubKey.IsPrivate() { + return errors.New("private keys cannot be imported") + } + + // The public key must have a version corresponding to the current + // chain. + if !w.isPubKeyForNet(pubKey) { + return fmt.Errorf("expected extended public key for current "+ + "network %v", w.chainParams.Name) + } + + // Verify the extended public key's depth and child index based on + // whether it's an account key or not. + if isAccountKey { + if pubKey.Depth() != accountPubKeyDepth { + return errors.New("invalid account key, must be of the " + + "form m/purpose'/coin_type'/account'") + } + if pubKey.ChildIndex() < hdkeychain.HardenedKeyStart { + return errors.New("invalid account key, must be hardened") + } + } else { + if pubKey.Depth() != pubKeyDepth { + return errors.New("invalid account key, must be of the " + + "form m/purpose'/coin_type'/account'/change/" + + "address_index") + } + if pubKey.ChildIndex() >= hdkeychain.HardenedKeyStart { + return errors.New("invalid pulic key, must not be " + + "hardened") + } + } + + return nil +} + +// ImportAccountDeprecated imports an account backed by an account extended +// public key. +// The master key fingerprint denotes the fingerprint of the root key +// corresponding to the account public key (also known as the key with +// derivation path m/). This may be required by some hardware wallets for proper +// identification and signing. +// +// The address type can usually be inferred from the key's version, but may be +// required for certain keys to map them into the proper scope. +// +// For BIP-0044 keys, an address type must be specified as we intend to not +// support importing BIP-0044 keys into the wallet using the legacy +// pay-to-pubkey-hash (P2PKH) scheme. A nested witness address type will force +// the standard BIP-0049 derivation scheme, while a witness address type will +// force the standard BIP-0084 derivation scheme. +// +// For BIP-0049 keys, an address type must also be specified to make a +// distinction between the traditional BIP-0049 address schema (nested witness +// pubkeys everywhere) and our own BIP-0049Plus address schema (nested +// externally, witness internally). +func (w *Wallet) ImportAccountDeprecated( + name string, accountPubKey *hdkeychain.ExtendedKey, + masterKeyFingerprint uint32, addrType *waddrmgr.AddressType) ( + *waddrmgr.AccountProperties, error) { + + var accountProps *waddrmgr.AccountProperties + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + var err error + accountProps, err = w.importAccount( + ns, name, accountPubKey, masterKeyFingerprint, addrType, + ) + return err + }) + return accountProps, err +} + +// ImportAccountWithScope imports an account backed by an account extended +// public key for a specific key scope which is known in advance. +// The master key fingerprint denotes the fingerprint of the root key +// corresponding to the account public key (also known as the key with +// derivation path m/). This may be required by some hardware wallets for proper +// identification and signing. +func (w *Wallet) ImportAccountWithScope(name string, + accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, + keyScope waddrmgr.KeyScope, addrSchema waddrmgr.ScopeAddrSchema) ( + *waddrmgr.AccountProperties, error) { + + var accountProps *waddrmgr.AccountProperties + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + var err error + accountProps, err = w.importAccountScope( + ns, name, accountPubKey, masterKeyFingerprint, keyScope, + &addrSchema, + ) + return err + }) + return accountProps, err +} + +// importAccount is the internal implementation of ImportAccount -- one should +// reference its documentation for this method. +func (w *Wallet) importAccount(ns walletdb.ReadWriteBucket, name string, + accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, + addrType *waddrmgr.AddressType) (*waddrmgr.AccountProperties, error) { + + // Ensure we have a valid account public key. + if err := w.validateExtendedPubKey(accountPubKey, true); err != nil { + return nil, err + } + + // Determine what key scope the account public key should belong to and + // whether it should use a custom address schema. + keyScope, addrSchema, err := keyScopeFromPubKey(accountPubKey, addrType) + if err != nil { + return nil, err + } + + return w.importAccountScope( + ns, name, accountPubKey, masterKeyFingerprint, keyScope, + addrSchema, + ) +} + +// importAccountScope imports a watch-only account for a given scope. +func (w *Wallet) importAccountScope(ns walletdb.ReadWriteBucket, name string, + accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, + keyScope waddrmgr.KeyScope, addrSchema *waddrmgr.ScopeAddrSchema) ( + *waddrmgr.AccountProperties, error) { + + scopedMgr, err := w.addrStore.FetchScopedKeyManager(keyScope) + if err != nil { + scopedMgr, err = w.addrStore.NewScopedKeyManager( + ns, keyScope, *addrSchema, + ) + if err != nil { + return nil, err + } + } + + account, err := scopedMgr.NewAccountWatchingOnly( + ns, name, accountPubKey, masterKeyFingerprint, addrSchema, + ) + if err != nil { + return nil, err + } + return scopedMgr.AccountProperties(ns, account) +} + +// ImportAccountDryRun serves as a dry run implementation of ImportAccount. This +// method also returns the first N external and internal addresses, which can be +// presented to users to confirm whether the account has been imported +// correctly. +func (w *Wallet) ImportAccountDryRun(name string, + accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, + addrType *waddrmgr.AddressType, numAddrs uint32) ( + *waddrmgr.AccountProperties, []waddrmgr.ManagedAddress, + []waddrmgr.ManagedAddress, error) { + + // The address manager uses OnCommit on the walletdb tx to update the + // in-memory state of the account state. But because the commit happens + // _after_ the account manager internal lock has been released, there + // is a chance for the address index to be accessed concurrently, even + // though the closure in OnCommit re-acquires the lock. To avoid this + // issue, we surround the whole address creation process with a lock. + w.newAddrMtx.Lock() + defer w.newAddrMtx.Unlock() + + var ( + accountProps *waddrmgr.AccountProperties + externalAddrs []waddrmgr.ManagedAddress + internalAddrs []waddrmgr.ManagedAddress + ) + + // Start a database transaction that we'll never commit and always + // rollback because we'll return a specific error in the end. + err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + // Import the account as usual. + var err error + accountProps, err = w.importAccount( + ns, name, accountPubKey, masterKeyFingerprint, addrType, + ) + if err != nil { + return err + } + + // Derive the external and internal addresses. Note that we + // could do this based on the provided accountPubKey alone, but + // we go through the ScopedKeyManager instead to ensure + // addresses will be derived as expected from the wallet's + // point-of-view. + manager, err := w.addrStore.FetchScopedKeyManager( + accountProps.KeyScope, + ) + if err != nil { + return err + } + + // The importAccount method above will cache the imported + // account within the scoped manager. Since this is a dry-run + // attempt, we'll want to invalidate the cache for it. + defer manager.InvalidateAccountCache(accountProps.AccountNumber) + + externalAddrs, err = manager.NextExternalAddresses( + ns, accountProps.AccountNumber, numAddrs, + ) + if err != nil { + return err + } + internalAddrs, err = manager.NextInternalAddresses( + ns, accountProps.AccountNumber, numAddrs, + ) + if err != nil { + return err + } + + // Refresh the account's properties after generating the + // addresses. + accountProps, err = manager.AccountProperties( + ns, accountProps.AccountNumber, + ) + if err != nil { + return err + } + + // Make sure we always roll back the dry-run transaction by + // returning an error here. + return walletdb.ErrDryRunRollBack + }) + if err != nil && err != walletdb.ErrDryRunRollBack { + return nil, nil, nil, err + } + + return accountProps, externalAddrs, internalAddrs, nil +} + +// ImportPublicKey imports a single derived public key into the address manager. +// The address type can usually be inferred from the key's version, but in the +// case of legacy versions (xpub, tpub), an address type must be specified as we +// intend to not support importing BIP-44 keys into the wallet using the legacy +// pay-to-pubkey-hash (P2PKH) scheme. +func (w *Wallet) ImportPublicKeyDeprecated(pubKey *btcec.PublicKey, + addrType waddrmgr.AddressType) error { + + // Determine what key scope the public key should belong to and import + // it into the key scope's default imported account. + var keyScope waddrmgr.KeyScope + switch addrType { + case waddrmgr.NestedWitnessPubKey: + keyScope = waddrmgr.KeyScopeBIP0049Plus + + case waddrmgr.WitnessPubKey: + keyScope = waddrmgr.KeyScopeBIP0084 + + case waddrmgr.TaprootPubKey: + keyScope = waddrmgr.KeyScopeBIP0086 + + default: + return fmt.Errorf("address type %v is not supported", addrType) + } + + scopedKeyManager, err := w.addrStore.FetchScopedKeyManager(keyScope) + if err != nil { + return err + } + + // TODO: Perform rescan if requested. + var addr waddrmgr.ManagedAddress + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + addr, err = scopedKeyManager.ImportPublicKey(ns, pubKey, nil) + return err + }) + if err != nil { + return err + } + + log.Infof("Imported address %v", addr.Address()) + + err = w.chainClient.NotifyReceived([]btcutil.Address{addr.Address()}) + if err != nil { + return fmt.Errorf("unable to subscribe for address "+ + "notifications: %w", err) + } + + return nil +} + +// ImportTaprootScriptDeprecated imports a user-provided taproot script into the +// address manager. The imported script will act as a pay-to-taproot address. +// +// Deprecated: Use AddressManager.ImportTaprootScript instead. +func (w *Wallet) ImportTaprootScriptDeprecated(scope waddrmgr.KeyScope, + tapscript *waddrmgr.Tapscript, bs *waddrmgr.BlockStamp, + witnessVersion byte, isSecretScript bool) (waddrmgr.ManagedAddress, + error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return nil, err + } + + // The starting block for the key is the genesis block unless otherwise + // specified. + if bs == nil { + bs = &waddrmgr.BlockStamp{ + Hash: *w.chainParams.GenesisHash, + Height: 0, + Timestamp: w.chainParams.GenesisBlock.Header.Timestamp, + } + } else if bs.Timestamp.IsZero() { + // Only update the new birthday time from default value if we + // actually have timestamp info in the header. + header, err := w.chainClient.GetBlockHeader(&bs.Hash) + if err == nil { + bs.Timestamp = header.Timestamp + } + } + + // TODO: Perform rescan if requested. + var addr waddrmgr.ManagedAddress + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + addr, err = manager.ImportTaprootScript( + ns, tapscript, bs, witnessVersion, isSecretScript, + ) + return err + }) + if err != nil { + return nil, err + } + + log.Infof("Imported address %v", addr.Address()) + + err = w.chainClient.NotifyReceived([]btcutil.Address{addr.Address()}) + if err != nil { + return nil, fmt.Errorf("unable to subscribe for address "+ + "notifications: %w", err) + } + + return addr, nil +} + +// ImportPrivateKey imports a private key to the wallet and writes the new +// wallet to disk. +// +// NOTE: If a block stamp is not provided, then the wallet's birthday will be +// set to the genesis block of the corresponding chain. +func (w *Wallet) ImportPrivateKey(scope waddrmgr.KeyScope, wif *btcutil.WIF, + bs *waddrmgr.BlockStamp, rescan bool) (string, error) { + + manager, err := w.addrStore.FetchScopedKeyManager(scope) + if err != nil { + return "", err + } + + // The starting block for the key is the genesis block unless otherwise + // specified. + if bs == nil { + bs = &waddrmgr.BlockStamp{ + Hash: *w.chainParams.GenesisHash, + Height: 0, + Timestamp: w.chainParams.GenesisBlock.Header.Timestamp, + } + } else if bs.Timestamp.IsZero() { + // Only update the new birthday time from default value if we + // actually have timestamp info in the header. + header, err := w.chainClient.GetBlockHeader(&bs.Hash) + if err == nil { + bs.Timestamp = header.Timestamp + } + } + + // Attempt to import private key into wallet. + var addr btcutil.Address + var props *waddrmgr.AccountProperties + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + maddr, err := manager.ImportPrivateKey(addrmgrNs, wif, bs) + if err != nil { + return err + } + addr = maddr.Address() + props, err = manager.AccountProperties( + addrmgrNs, waddrmgr.ImportedAddrAccount, + ) + if err != nil { + return err + } + + // We'll only update our birthday with the new one if it is + // before our current one. Otherwise, if we do, we can + // potentially miss detecting relevant chain events that + // occurred between them while rescanning. + birthdayBlock, _, err := w.addrStore.BirthdayBlock(addrmgrNs) + if err != nil { + return err + } + if bs.Height >= birthdayBlock.Height { + return nil + } + + err = w.addrStore.SetBirthday(addrmgrNs, bs.Timestamp) + if err != nil { + return err + } + + // To ensure this birthday block is correct, we'll mark it as + // unverified to prompt a sanity check at the next restart to + // ensure it is correct as it was provided by the caller. + return w.addrStore.SetBirthdayBlock(addrmgrNs, *bs, false) + }) + if err != nil { + return "", err + } + + // Rescan blockchain for transactions with txout scripts paying to the + // imported address. + if rescan { + job := &RescanJob{ + Addrs: []btcutil.Address{addr}, + OutPoints: nil, + BlockStamp: *bs, + } + + // Submit rescan job and log when the import has completed. + // Do not block on finishing the rescan. The rescan success + // or failure is logged elsewhere, and the channel is not + // required to be read, so discard the return value. + _ = w.SubmitRescan(job) + } else { + err := w.chainClient.NotifyReceived([]btcutil.Address{addr}) + if err != nil { + return "", fmt.Errorf("failed to subscribe for address ntfns for "+ + "address %s: %w", addr.EncodeAddress(), err) + } + } + + addrStr := addr.EncodeAddress() + log.Infof("Imported payment address %s", addrStr) + + w.NtfnServer.notifyAccountProperties(props) + + // Return the payment address string of the imported private key. + return addrStr, nil +} + // walletDeprecated encapsulates the legacy state and communication channels // that are being phased out in favor of the modern Controller and Syncer // architecture. diff --git a/wallet/import.go b/wallet/import.go deleted file mode 100644 index 7f957309f2..0000000000 --- a/wallet/import.go +++ /dev/null @@ -1,600 +0,0 @@ -package wallet - -import ( - "encoding/binary" - "errors" - "fmt" - - "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/netparams" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/walletdb" -) - -const ( - // accountPubKeyDepth is the maximum depth of an extended key for an - // account public key. - accountPubKeyDepth = 3 - - // pubKeyDepth is the depth of an extended key for a derived public key. - pubKeyDepth = 5 -) - -// keyScopeFromPubKey returns the corresponding wallet key scope for the given -// extended public key. The address type can usually be inferred from the key's -// version, but may be required for certain keys to map them into the proper -// scope. -func keyScopeFromPubKey(pubKey *hdkeychain.ExtendedKey, - addrType *waddrmgr.AddressType) (waddrmgr.KeyScope, - *waddrmgr.ScopeAddrSchema, error) { - - switch waddrmgr.HDVersion(binary.BigEndian.Uint32(pubKey.Version())) { - // For BIP-0044 keys, an address type must be specified as we intend to - // not support importing BIP-0044 keys into the wallet using the legacy - // pay-to-pubkey-hash (P2PKH) scheme. A nested witness address type will - // force the standard BIP-0049 derivation scheme (nested witness pubkeys - // everywhere), while a witness address type will force the standard - // BIP-0084 derivation scheme. - case waddrmgr.HDVersionMainNetBIP0044, waddrmgr.HDVersionTestNetBIP0044, - waddrmgr.HDVersionSimNetBIP0044: - - if addrType == nil { - return waddrmgr.KeyScope{}, nil, errors.New("address " + - "type must be specified for account public " + - "key with legacy version") - } - - switch *addrType { - case waddrmgr.NestedWitnessPubKey: - return waddrmgr.KeyScopeBIP0049Plus, - &waddrmgr.KeyScopeBIP0049AddrSchema, nil - - case waddrmgr.WitnessPubKey: - return waddrmgr.KeyScopeBIP0084, nil, nil - - case waddrmgr.TaprootPubKey: - return waddrmgr.KeyScopeBIP0086, nil, nil - - default: - return waddrmgr.KeyScope{}, nil, - fmt.Errorf("unsupported address type %v", - *addrType) - } - - // For BIP-0049 keys, we'll need to make a distinction between the - // traditional BIP-0049 address schema (nested witness pubkeys - // everywhere) and our own BIP-0049Plus address schema (nested - // externally, witness internally). - case waddrmgr.HDVersionMainNetBIP0049, waddrmgr.HDVersionTestNetBIP0049: - if addrType == nil { - return waddrmgr.KeyScope{}, nil, errors.New("address " + - "type must be specified for account public " + - "key with BIP-0049 version") - } - - switch *addrType { - case waddrmgr.NestedWitnessPubKey: - return waddrmgr.KeyScopeBIP0049Plus, - &waddrmgr.KeyScopeBIP0049AddrSchema, nil - - case waddrmgr.WitnessPubKey: - return waddrmgr.KeyScopeBIP0049Plus, nil, nil - - default: - return waddrmgr.KeyScope{}, nil, - fmt.Errorf("unsupported address type %v", - *addrType) - } - - // BIP-0086 does not have its own SLIP-0132 HD version byte set (yet?). - // So we either expect a user to import it with a BIP-0084 or BIP-0044 - // encoding. - case waddrmgr.HDVersionMainNetBIP0084, waddrmgr.HDVersionTestNetBIP0084: - if addrType == nil { - return waddrmgr.KeyScope{}, nil, errors.New("address " + - "type must be specified for account public " + - "key with BIP-0084 version") - } - - switch *addrType { - case waddrmgr.WitnessPubKey: - return waddrmgr.KeyScopeBIP0084, nil, nil - - case waddrmgr.TaprootPubKey: - return waddrmgr.KeyScopeBIP0086, nil, nil - - default: - return waddrmgr.KeyScope{}, nil, - errors.New("address type mismatch") - } - - default: - return waddrmgr.KeyScope{}, nil, fmt.Errorf("unknown version %x", - pubKey.Version()) - } -} - -// isPubKeyForNet determines if the given public key is for the current network -// the wallet is operating under. -func (w *Wallet) isPubKeyForNet(pubKey *hdkeychain.ExtendedKey) bool { - version := waddrmgr.HDVersion(binary.BigEndian.Uint32(pubKey.Version())) - switch w.chainParams.Net { - case wire.MainNet: - return version == waddrmgr.HDVersionMainNetBIP0044 || - version == waddrmgr.HDVersionMainNetBIP0049 || - version == waddrmgr.HDVersionMainNetBIP0084 - - case wire.TestNet, wire.TestNet3, wire.TestNet4, - netparams.SigNetWire(w.chainParams): - - return version == waddrmgr.HDVersionTestNetBIP0044 || - version == waddrmgr.HDVersionTestNetBIP0049 || - version == waddrmgr.HDVersionTestNetBIP0084 - - // For simnet, we'll also allow the mainnet versions since simnet - // doesn't have defined versions for some of our key scopes, and the - // mainnet versions are usually used as the default regardless of the - // network/key scope. - case wire.SimNet: - return version == waddrmgr.HDVersionSimNetBIP0044 || - version == waddrmgr.HDVersionMainNetBIP0049 || - version == waddrmgr.HDVersionMainNetBIP0084 - - default: - return false - } -} - -// validateExtendedPubKey ensures a sane derived public key is provided. -func (w *Wallet) validateExtendedPubKey(pubKey *hdkeychain.ExtendedKey, - isAccountKey bool) error { - - // Private keys are not allowed. - if pubKey.IsPrivate() { - return errors.New("private keys cannot be imported") - } - - // The public key must have a version corresponding to the current - // chain. - if !w.isPubKeyForNet(pubKey) { - return fmt.Errorf("expected extended public key for current "+ - "network %v", w.chainParams.Name) - } - - // Verify the extended public key's depth and child index based on - // whether it's an account key or not. - if isAccountKey { - if pubKey.Depth() != accountPubKeyDepth { - return errors.New("invalid account key, must be of the " + - "form m/purpose'/coin_type'/account'") - } - if pubKey.ChildIndex() < hdkeychain.HardenedKeyStart { - return errors.New("invalid account key, must be hardened") - } - } else { - if pubKey.Depth() != pubKeyDepth { - return errors.New("invalid account key, must be of the " + - "form m/purpose'/coin_type'/account'/change/" + - "address_index") - } - if pubKey.ChildIndex() >= hdkeychain.HardenedKeyStart { - return errors.New("invalid pulic key, must not be " + - "hardened") - } - } - - return nil -} - -// ImportAccountDeprecated imports an account backed by an account extended -// public key. -// The master key fingerprint denotes the fingerprint of the root key -// corresponding to the account public key (also known as the key with -// derivation path m/). This may be required by some hardware wallets for proper -// identification and signing. -// -// The address type can usually be inferred from the key's version, but may be -// required for certain keys to map them into the proper scope. -// -// For BIP-0044 keys, an address type must be specified as we intend to not -// support importing BIP-0044 keys into the wallet using the legacy -// pay-to-pubkey-hash (P2PKH) scheme. A nested witness address type will force -// the standard BIP-0049 derivation scheme, while a witness address type will -// force the standard BIP-0084 derivation scheme. -// -// For BIP-0049 keys, an address type must also be specified to make a -// distinction between the traditional BIP-0049 address schema (nested witness -// pubkeys everywhere) and our own BIP-0049Plus address schema (nested -// externally, witness internally). -func (w *Wallet) ImportAccountDeprecated( - name string, accountPubKey *hdkeychain.ExtendedKey, - masterKeyFingerprint uint32, addrType *waddrmgr.AddressType) ( - *waddrmgr.AccountProperties, error) { - - var accountProps *waddrmgr.AccountProperties - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - var err error - accountProps, err = w.importAccount( - ns, name, accountPubKey, masterKeyFingerprint, addrType, - ) - return err - }) - return accountProps, err -} - -// ImportAccountWithScope imports an account backed by an account extended -// public key for a specific key scope which is known in advance. -// The master key fingerprint denotes the fingerprint of the root key -// corresponding to the account public key (also known as the key with -// derivation path m/). This may be required by some hardware wallets for proper -// identification and signing. -func (w *Wallet) ImportAccountWithScope(name string, - accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, - keyScope waddrmgr.KeyScope, addrSchema waddrmgr.ScopeAddrSchema) ( - *waddrmgr.AccountProperties, error) { - - var accountProps *waddrmgr.AccountProperties - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - var err error - accountProps, err = w.importAccountScope( - ns, name, accountPubKey, masterKeyFingerprint, keyScope, - &addrSchema, - ) - return err - }) - return accountProps, err -} - -// importAccount is the internal implementation of ImportAccount -- one should -// reference its documentation for this method. -func (w *Wallet) importAccount(ns walletdb.ReadWriteBucket, name string, - accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, - addrType *waddrmgr.AddressType) (*waddrmgr.AccountProperties, error) { - - // Ensure we have a valid account public key. - if err := w.validateExtendedPubKey(accountPubKey, true); err != nil { - return nil, err - } - - // Determine what key scope the account public key should belong to and - // whether it should use a custom address schema. - keyScope, addrSchema, err := keyScopeFromPubKey(accountPubKey, addrType) - if err != nil { - return nil, err - } - - return w.importAccountScope( - ns, name, accountPubKey, masterKeyFingerprint, keyScope, - addrSchema, - ) -} - -// importAccountScope imports a watch-only account for a given scope. -func (w *Wallet) importAccountScope(ns walletdb.ReadWriteBucket, name string, - accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, - keyScope waddrmgr.KeyScope, addrSchema *waddrmgr.ScopeAddrSchema) ( - *waddrmgr.AccountProperties, error) { - - scopedMgr, err := w.addrStore.FetchScopedKeyManager(keyScope) - if err != nil { - scopedMgr, err = w.addrStore.NewScopedKeyManager( - ns, keyScope, *addrSchema, - ) - if err != nil { - return nil, err - } - } - - account, err := scopedMgr.NewAccountWatchingOnly( - ns, name, accountPubKey, masterKeyFingerprint, addrSchema, - ) - if err != nil { - return nil, err - } - return scopedMgr.AccountProperties(ns, account) -} - -// ImportAccountDryRun serves as a dry run implementation of ImportAccount. This -// method also returns the first N external and internal addresses, which can be -// presented to users to confirm whether the account has been imported -// correctly. -func (w *Wallet) ImportAccountDryRun(name string, - accountPubKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, - addrType *waddrmgr.AddressType, numAddrs uint32) ( - *waddrmgr.AccountProperties, []waddrmgr.ManagedAddress, - []waddrmgr.ManagedAddress, error) { - - // The address manager uses OnCommit on the walletdb tx to update the - // in-memory state of the account state. But because the commit happens - // _after_ the account manager internal lock has been released, there - // is a chance for the address index to be accessed concurrently, even - // though the closure in OnCommit re-acquires the lock. To avoid this - // issue, we surround the whole address creation process with a lock. - w.newAddrMtx.Lock() - defer w.newAddrMtx.Unlock() - - var ( - accountProps *waddrmgr.AccountProperties - externalAddrs []waddrmgr.ManagedAddress - internalAddrs []waddrmgr.ManagedAddress - ) - - // Start a database transaction that we'll never commit and always - // rollback because we'll return a specific error in the end. - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - - // Import the account as usual. - var err error - accountProps, err = w.importAccount( - ns, name, accountPubKey, masterKeyFingerprint, addrType, - ) - if err != nil { - return err - } - - // Derive the external and internal addresses. Note that we - // could do this based on the provided accountPubKey alone, but - // we go through the ScopedKeyManager instead to ensure - // addresses will be derived as expected from the wallet's - // point-of-view. - manager, err := w.addrStore.FetchScopedKeyManager( - accountProps.KeyScope, - ) - if err != nil { - return err - } - - // The importAccount method above will cache the imported - // account within the scoped manager. Since this is a dry-run - // attempt, we'll want to invalidate the cache for it. - defer manager.InvalidateAccountCache(accountProps.AccountNumber) - - externalAddrs, err = manager.NextExternalAddresses( - ns, accountProps.AccountNumber, numAddrs, - ) - if err != nil { - return err - } - internalAddrs, err = manager.NextInternalAddresses( - ns, accountProps.AccountNumber, numAddrs, - ) - if err != nil { - return err - } - - // Refresh the account's properties after generating the - // addresses. - accountProps, err = manager.AccountProperties( - ns, accountProps.AccountNumber, - ) - if err != nil { - return err - } - - // Make sure we always roll back the dry-run transaction by - // returning an error here. - return walletdb.ErrDryRunRollBack - }) - if err != nil && err != walletdb.ErrDryRunRollBack { - return nil, nil, nil, err - } - - return accountProps, externalAddrs, internalAddrs, nil -} - -// ImportPublicKey imports a single derived public key into the address manager. -// The address type can usually be inferred from the key's version, but in the -// case of legacy versions (xpub, tpub), an address type must be specified as we -// intend to not support importing BIP-44 keys into the wallet using the legacy -// pay-to-pubkey-hash (P2PKH) scheme. -func (w *Wallet) ImportPublicKeyDeprecated(pubKey *btcec.PublicKey, - addrType waddrmgr.AddressType) error { - - // Determine what key scope the public key should belong to and import - // it into the key scope's default imported account. - var keyScope waddrmgr.KeyScope - switch addrType { - case waddrmgr.NestedWitnessPubKey: - keyScope = waddrmgr.KeyScopeBIP0049Plus - - case waddrmgr.WitnessPubKey: - keyScope = waddrmgr.KeyScopeBIP0084 - - case waddrmgr.TaprootPubKey: - keyScope = waddrmgr.KeyScopeBIP0086 - - default: - return fmt.Errorf("address type %v is not supported", addrType) - } - - scopedKeyManager, err := w.addrStore.FetchScopedKeyManager(keyScope) - if err != nil { - return err - } - - // TODO: Perform rescan if requested. - var addr waddrmgr.ManagedAddress - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - addr, err = scopedKeyManager.ImportPublicKey(ns, pubKey, nil) - return err - }) - if err != nil { - return err - } - - log.Infof("Imported address %v", addr.Address()) - - err = w.chainClient.NotifyReceived([]btcutil.Address{addr.Address()}) - if err != nil { - return fmt.Errorf("unable to subscribe for address "+ - "notifications: %w", err) - } - - return nil -} - -// ImportTaprootScriptDeprecated imports a user-provided taproot script into the -// address manager. The imported script will act as a pay-to-taproot address. -// -// Deprecated: Use AddressManager.ImportTaprootScript instead. -func (w *Wallet) ImportTaprootScriptDeprecated(scope waddrmgr.KeyScope, - tapscript *waddrmgr.Tapscript, bs *waddrmgr.BlockStamp, - witnessVersion byte, isSecretScript bool) (waddrmgr.ManagedAddress, - error) { - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return nil, err - } - - // The starting block for the key is the genesis block unless otherwise - // specified. - if bs == nil { - bs = &waddrmgr.BlockStamp{ - Hash: *w.chainParams.GenesisHash, - Height: 0, - Timestamp: w.chainParams.GenesisBlock.Header.Timestamp, - } - } else if bs.Timestamp.IsZero() { - // Only update the new birthday time from default value if we - // actually have timestamp info in the header. - header, err := w.chainClient.GetBlockHeader(&bs.Hash) - if err == nil { - bs.Timestamp = header.Timestamp - } - } - - // TODO: Perform rescan if requested. - var addr waddrmgr.ManagedAddress - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - addr, err = manager.ImportTaprootScript( - ns, tapscript, bs, witnessVersion, isSecretScript, - ) - return err - }) - if err != nil { - return nil, err - } - - log.Infof("Imported address %v", addr.Address()) - - err = w.chainClient.NotifyReceived([]btcutil.Address{addr.Address()}) - if err != nil { - return nil, fmt.Errorf("unable to subscribe for address "+ - "notifications: %w", err) - } - - return addr, nil -} - -// ImportPrivateKey imports a private key to the wallet and writes the new -// wallet to disk. -// -// NOTE: If a block stamp is not provided, then the wallet's birthday will be -// set to the genesis block of the corresponding chain. -func (w *Wallet) ImportPrivateKey(scope waddrmgr.KeyScope, wif *btcutil.WIF, - bs *waddrmgr.BlockStamp, rescan bool) (string, error) { - - manager, err := w.addrStore.FetchScopedKeyManager(scope) - if err != nil { - return "", err - } - - // The starting block for the key is the genesis block unless otherwise - // specified. - if bs == nil { - bs = &waddrmgr.BlockStamp{ - Hash: *w.chainParams.GenesisHash, - Height: 0, - Timestamp: w.chainParams.GenesisBlock.Header.Timestamp, - } - } else if bs.Timestamp.IsZero() { - // Only update the new birthday time from default value if we - // actually have timestamp info in the header. - header, err := w.chainClient.GetBlockHeader(&bs.Hash) - if err == nil { - bs.Timestamp = header.Timestamp - } - } - - // Attempt to import private key into wallet. - var addr btcutil.Address - var props *waddrmgr.AccountProperties - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - maddr, err := manager.ImportPrivateKey(addrmgrNs, wif, bs) - if err != nil { - return err - } - addr = maddr.Address() - props, err = manager.AccountProperties( - addrmgrNs, waddrmgr.ImportedAddrAccount, - ) - if err != nil { - return err - } - - // We'll only update our birthday with the new one if it is - // before our current one. Otherwise, if we do, we can - // potentially miss detecting relevant chain events that - // occurred between them while rescanning. - birthdayBlock, _, err := w.addrStore.BirthdayBlock(addrmgrNs) - if err != nil { - return err - } - if bs.Height >= birthdayBlock.Height { - return nil - } - - err = w.addrStore.SetBirthday(addrmgrNs, bs.Timestamp) - if err != nil { - return err - } - - // To ensure this birthday block is correct, we'll mark it as - // unverified to prompt a sanity check at the next restart to - // ensure it is correct as it was provided by the caller. - return w.addrStore.SetBirthdayBlock(addrmgrNs, *bs, false) - }) - if err != nil { - return "", err - } - - // Rescan blockchain for transactions with txout scripts paying to the - // imported address. - if rescan { - job := &RescanJob{ - Addrs: []btcutil.Address{addr}, - OutPoints: nil, - BlockStamp: *bs, - } - - // Submit rescan job and log when the import has completed. - // Do not block on finishing the rescan. The rescan success - // or failure is logged elsewhere, and the channel is not - // required to be read, so discard the return value. - _ = w.SubmitRescan(job) - } else { - err := w.chainClient.NotifyReceived([]btcutil.Address{addr}) - if err != nil { - return "", fmt.Errorf("failed to subscribe for address ntfns for "+ - "address %s: %w", addr.EncodeAddress(), err) - } - } - - addrStr := addr.EncodeAddress() - log.Infof("Imported payment address %s", addrStr) - - w.NtfnServer.notifyAccountProperties(props) - - // Return the payment address string of the imported private key. - return addrStr, nil -} diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index a7cf65fbe7..233b89b4dc 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -14,13 +14,17 @@ import ( "context" "errors" "fmt" + "math/rand" + "sort" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" + "github.com/btcsuite/btcwallet/wallet/txsizes" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -839,3 +843,300 @@ func (w *Wallet) getEligibleUTXOsFromList(dbtx walletdb.ReadTx, return eligible, nil } + +func makeInputSource(eligible []Coin) txauthor.InputSource { + // Current inputs and their total value. These are closed over by the + // returned input source and reused across multiple calls. + currentTotal := btcutil.Amount(0) + currentInputs := make([]*wire.TxIn, 0, len(eligible)) + currentScripts := make([][]byte, 0, len(eligible)) + currentInputValues := make([]btcutil.Amount, 0, len(eligible)) + + return func(target btcutil.Amount) (btcutil.Amount, []*wire.TxIn, + []btcutil.Amount, [][]byte, error) { + + for currentTotal < target && len(eligible) != 0 { + nextCredit := eligible[0] + prevOut := nextCredit.TxOut + outpoint := nextCredit.OutPoint + eligible = eligible[1:] + + nextInput := wire.NewTxIn(&outpoint, nil, nil) + currentTotal += btcutil.Amount(prevOut.Value) + + currentInputs = append(currentInputs, nextInput) + currentScripts = append( + currentScripts, prevOut.PkScript, + ) + currentInputValues = append( + currentInputValues, + btcutil.Amount(prevOut.Value), + ) + } + + return currentTotal, currentInputs, currentInputValues, + currentScripts, nil + } +} + +// constantInputSource creates an input source function that always returns the +// static set of user-selected UTXOs. +func constantInputSource(eligible []wtxmgr.Credit) txauthor.InputSource { + // Current inputs and their total value. These won't change over + // different invocations as we want our inputs to remain static since + // they're selected by the user. + currentTotal := btcutil.Amount(0) + currentInputs := make([]*wire.TxIn, 0, len(eligible)) + currentScripts := make([][]byte, 0, len(eligible)) + currentInputValues := make([]btcutil.Amount, 0, len(eligible)) + + for _, credit := range eligible { + nextInput := wire.NewTxIn(&credit.OutPoint, nil, nil) + currentTotal += credit.Amount + + currentInputs = append(currentInputs, nextInput) + currentScripts = append(currentScripts, credit.PkScript) + currentInputValues = append(currentInputValues, credit.Amount) + } + + return func(target btcutil.Amount) (btcutil.Amount, []*wire.TxIn, + []btcutil.Amount, [][]byte, error) { + + return currentTotal, currentInputs, currentInputValues, + currentScripts, nil + } +} + +// findEligibleOutputs finds eligible outputs for the given key scope and +// account. +func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, + keyScope *waddrmgr.KeyScope, account uint32, minconf uint32, + bs *waddrmgr.BlockStamp, + allowUtxo func(utxo wtxmgr.Credit) bool) ([]wtxmgr.Credit, error) { + + addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + unspent, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return nil, err + } + + // TODO: Eventually all of these filters (except perhaps output locking) + // should be handled by the call to UnspentOutputs (or similar). + // Because one of these filters requires matching the output script to + // the desired account, this change depends on making wtxmgr a waddrmgr + // dependency and requesting unspent outputs for a single account. + eligible := make([]wtxmgr.Credit, 0, len(unspent)) + for i := range unspent { + output := &unspent[i] + + // Restrict the selected utxos if a filter function is provided. + if allowUtxo != nil && + !allowUtxo(*output) { + + continue + } + + // Only include this output if it meets the required number of + // confirmations. Coinbase transactions must have reached + // maturity before their outputs may be spent. + if !hasMinConfs(minconf, output.Height, bs.Height) { + continue + } + + if output.FromCoinBase { + target := w.chainParams.CoinbaseMaturity + if !hasMinConfs( + uint32(target), output.Height, bs.Height, + ) { + + continue + } + } + + // Locked unspent outputs are skipped. + if w.LockedOutpoint(output.OutPoint) { + continue + } + + // Only include the output if it is associated with the passed + // account. + // + // TODO: Handle multisig outputs by determining if enough of the + // addresses are controlled. + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + output.PkScript, w.chainParams) + if err != nil || len(addrs) != 1 { + continue + } + + scopedMgr, addrAcct, err := w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) + if err != nil { + continue + } + + if keyScope != nil && scopedMgr.Scope() != *keyScope { + continue + } + + if addrAcct != account { + continue + } + + eligible = append(eligible, *output) + } + + return eligible, nil +} + +// inputYieldsPositively returns a boolean indicating whether this input yields +// positively if added to a transaction. This determination is based on the +// best-case added virtual size. For edge cases this function can return true +// while the input is yielding slightly negative as part of the final +// transaction. +func inputYieldsPositively(credit *wire.TxOut, + feeRatePerKb btcutil.Amount) bool { + + inputSize := txsizes.GetMinInputVirtualSize(credit.PkScript) + inputFee := feeRatePerKb * btcutil.Amount(inputSize) / 1000 + + return inputFee < btcutil.Amount(credit.Value) +} + +// addrMgrWithChangeSource returns the address manager bucket and a change +// source that returns change addresses from said address manager. The change +// addresses will come from the specified key scope and account, unless a key +// scope is not specified. In that case, change addresses will always come from +// the P2WKH key scope. +func (w *Wallet) addrMgrWithChangeSource(dbtx walletdb.ReadWriteTx, + changeKeyScope *waddrmgr.KeyScope, account uint32) ( + walletdb.ReadWriteBucket, *txauthor.ChangeSource, error) { + + // Determine the address type for change addresses of the given + // account. + if changeKeyScope == nil { + changeKeyScope = &waddrmgr.KeyScopeBIP0086 + } + + addrType := waddrmgr.ScopeAddrMap[*changeKeyScope].InternalAddrType + + // It's possible for the account to have an address schema override, so + // prefer that if it exists. + addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + + scopeMgr, err := w.addrStore.FetchScopedKeyManager(*changeKeyScope) + if err != nil { + return nil, nil, err + } + + accountInfo, err := scopeMgr.AccountProperties(addrmgrNs, account) + if err != nil { + return nil, nil, err + } + + if accountInfo.AddrSchema != nil { + addrType = accountInfo.AddrSchema.InternalAddrType + } + + // Compute the expected size of the script for the change address type. + var scriptSize int + switch addrType { + case waddrmgr.PubKeyHash: + scriptSize = txsizes.P2PKHPkScriptSize + case waddrmgr.NestedWitnessPubKey: + scriptSize = txsizes.NestedP2WPKHPkScriptSize + case waddrmgr.WitnessPubKey: + scriptSize = txsizes.P2WPKHPkScriptSize + case waddrmgr.TaprootPubKey: + scriptSize = txsizes.P2TRPkScriptSize + default: + return nil, nil, fmt.Errorf("unsupported address type: %v", + addrType) + } + + newChangeScript := func() ([]byte, error) { + // Derive the change output script. As a hack to allow spending + // from the imported account, change addresses are created from + // account 0. + var ( + changeAddr btcutil.Address + err error + ) + if account == waddrmgr.ImportedAddrAccount { + changeAddr, err = w.newChangeAddress( + addrmgrNs, 0, *changeKeyScope, + ) + } else { + changeAddr, err = w.newChangeAddress( + addrmgrNs, account, *changeKeyScope, + ) + } + + if err != nil { + return nil, err + } + + return txscript.PayToAddrScript(changeAddr) + } + + return addrmgrNs, &txauthor.ChangeSource{ + ScriptSize: scriptSize, + NewScript: newChangeScript, + }, nil +} + +// sortByAmount is a generic sortable type for sorting coins by their amount. +type sortByAmount []Coin + +func (s sortByAmount) Len() int { return len(s) } +func (s sortByAmount) Less(i, j int) bool { + return s[i].Value < s[j].Value +} +func (s sortByAmount) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// LargestFirstCoinSelector is an implementation of the CoinSelectionStrategy +// that always selects the largest coins first. +type LargestFirstCoinSelector struct{} + +// ArrangeCoins takes a list of coins and arranges them according to the +// specified coin selection strategy and fee rate. +func (*LargestFirstCoinSelector) ArrangeCoins(eligible []Coin, + _ btcutil.Amount) ([]Coin, error) { + + sort.Sort(sort.Reverse(sortByAmount(eligible))) + + return eligible, nil +} + +// RandomCoinSelector is an implementation of the CoinSelectionStrategy that +// selects coins at random. This prevents the creation of ever smaller UTXOs +// over time that may never become economical to spend. +type RandomCoinSelector struct{} + +// ArrangeCoins takes a list of coins and arranges them according to the +// specified coin selection strategy and fee rate. +func (*RandomCoinSelector) ArrangeCoins(eligible []Coin, + feeSatPerKb btcutil.Amount) ([]Coin, error) { + + // Skip inputs that do not raise the total transaction output + // value at the requested fee rate. + positivelyYielding := make([]Coin, 0, len(eligible)) + for _, output := range eligible { + + if !inputYieldsPositively(&output.TxOut, feeSatPerKb) { + continue + } + + positivelyYielding = append(positivelyYielding, output) + } + + rand.Shuffle(len(positivelyYielding), func(i, j int) { + positivelyYielding[i], positivelyYielding[j] = + positivelyYielding[j], positivelyYielding[i] + }) + + return positivelyYielding, nil +} diff --git a/wallet/wallet.go b/wallet/wallet.go index 8b0b6389b9..85c2466865 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -47,6 +47,11 @@ const ( // defaultSyncRetryInterval is the default amount of time to wait // between re-tries on errors during initial sync. defaultSyncRetryInterval = 5 * time.Second + + // birthdayBlockDelta is the maximum time delta allowed between our + // birthday timestamp and our birthday block's timestamp when searching + // for a better birthday block candidate (if possible). + birthdayBlockDelta = 2 * time.Hour ) var ( @@ -82,7 +87,6 @@ var ( wtxmgrNamespaceKey = []byte("wtxmgr") ) - // locateBirthdayBlock returns a block that meets the given birthday timestamp // by a margin of +/-2 hours. This is safe to do as the timestamp is already 2 // days in the past of the actual timestamp. @@ -206,7 +210,6 @@ type Wallet struct { // AccountAddresses returns the addresses for every created address for an // account. - // ChainParams returns the network parameters for the blockchain the wallet // belongs to. func (w *Wallet) ChainParams() *chaincfg.Params { From 5b8e189ed7074cefca0a3a0166e489b431b9151b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 20 Dec 2025 18:10:08 +0800 Subject: [PATCH 231/691] wallet: fix linter errors in tx_creator.go This commit addresses several linter errors in wallet/tx_creator.go, including cyclomatic complexity, exhaustive switch, unused parameter, magic number, and gosec integer overflow. It also updates wallet/txsizes/size.go to return uint64 for GetMinInputVirtualSize. --- go.mod | 2 + go.sum | 2 - wallet/deprecated.go | 83 ++++++++++++++++++++++++++++++++++++++++++ wallet/tx_creator.go | 73 +++++++++++++++++++++---------------- wallet/txsizes/size.go | 9 ++--- 5 files changed, 130 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index 62b66d202e..7ea12c13c5 100644 --- a/go.mod +++ b/go.mod @@ -63,3 +63,5 @@ go 1.24.6 // that we can freely move between tagged releases and development commits of // this module without needing to constantly update the go.mod file. replace github.com/btcsuite/btcwallet/wtxmgr => ./wtxmgr + +replace github.com/btcsuite/btcwallet/wallet/txsizes => ./wallet/txsizes diff --git a/go.sum b/go.sum index 9267a4680d..6ac11cfed7 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,6 @@ github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 h1:Rr0njWI3r341nhSPesKQ2JF+ github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5/go.mod h1:+tXJ3Ym0nlQc/iHSwW1qzjmPs3ev+UVWMbGgfV1OZqU= github.com/btcsuite/btcwallet/wallet/txrules v1.2.2 h1:YEO+Lx1ZJJAtdRrjuhXjWrYsmAk26wLTlNzxt2q0lhk= github.com/btcsuite/btcwallet/wallet/txrules v1.2.2/go.mod h1:4v+grppsDpVn91SJv+mZT7B8hEV4nSmpREM4I8Uohws= -github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5 h1:93o5Xz9dYepBP4RMFUc9RGIFXwqP2volSWRkYJFrNtI= -github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5/go.mod h1:lQ+e9HxZ85QP7r3kdxItkiMSloSLg1PEGis5o5CXUQw= github.com/btcsuite/btcwallet/walletdb v1.5.1 h1:HgMhDNCrtEFPC+8q0ei5DQ5U9Tl4RCspA22DEKXlopI= github.com/btcsuite/btcwallet/walletdb v1.5.1/go.mod h1:jk/hvpLFINF0C1kfTn0bfx2GbnFT+Nvnj6eblZALfjs= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw= diff --git a/wallet/deprecated.go b/wallet/deprecated.go index e98d6c359a..2dffe5a1ce 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -6227,3 +6227,86 @@ type walletDeprecated struct { // errors during initial sync. syncRetryInterval time.Duration } + +// findEligibleOutputs finds eligible outputs for the given key scope and +// account. +func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, + keyScope *waddrmgr.KeyScope, account uint32, minconf uint32, + bs *waddrmgr.BlockStamp, + allowUtxo func(utxo wtxmgr.Credit) bool) ([]wtxmgr.Credit, error) { + + addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + unspent, err := w.txStore.UnspentOutputs(txmgrNs) + if err != nil { + return nil, err + } + + // TODO: Eventually all of these filters (except perhaps output locking) + // should be handled by the call to UnspentOutputs (or similar). + // Because one of these filters requires matching the output script to + // the desired account, this change depends on making wtxmgr a waddrmgr + // dependency and requesting unspent outputs for a single account. + eligible := make([]wtxmgr.Credit, 0, len(unspent)) + for i := range unspent { + output := &unspent[i] + + // Restrict the selected utxos if a filter function is provided. + if allowUtxo != nil && !allowUtxo(*output) { + continue + } + + // Only include this output if it meets the required number of + // confirmations. Coinbase transactions must have reached + // maturity before their outputs may be spent. + if !hasMinConfs(minconf, output.Height, bs.Height) { + continue + } + + if output.FromCoinBase { + target := w.chainParams.CoinbaseMaturity + if !hasMinConfs( + uint32(target), output.Height, bs.Height, + ) { + + continue + } + } + + // Locked unspent outputs are skipped. + if w.LockedOutpoint(output.OutPoint) { + continue + } + + // Only include the output if it is associated with the passed + // account. + // + // TODO: Handle multisig outputs by determining if enough of the + // addresses are controlled. + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + output.PkScript, w.chainParams) + if err != nil || len(addrs) != 1 { + continue + } + + scopedMgr, addrAcct, err := w.addrStore.AddrAccount( + addrmgrNs, addrs[0], + ) + if err != nil { + continue + } + + if keyScope != nil && scopedMgr.Scope() != *keyScope { + continue + } + + if addrAcct != account { + continue + } + + eligible = append(eligible, *output) + } + + return eligible, nil +} diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index 233b89b4dc..39ab5983a7 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -761,9 +761,9 @@ func (w *Wallet) getEligibleUTXOs(dbtx walletdb.ReadTx, switch source := source.(type) { // If the source is nil, we'll use the default account. case nil: - return w.findEligibleOutputs( + return w.filterEligibleOutputs( dbtx, &waddrmgr.KeyScopeBIP0086, - waddrmgr.DefaultAccountNum, minconf, bs, nil, + waddrmgr.DefaultAccountNum, minconf, bs, ) // If the source is a scoped account, we find all eligible outputs for @@ -796,9 +796,7 @@ func (w *Wallet) getEligibleUTXOsFromAccount(dbtx walletdb.ReadTx, source.AccountName) } - return w.findEligibleOutputs( - dbtx, keyScope, account, minconf, bs, nil, - ) + return w.filterEligibleOutputs(dbtx, keyScope, account, minconf, bs) } // getEligibleUTXOsFromList returns a slice of eligible UTXOs from a specified @@ -899,7 +897,7 @@ func constantInputSource(eligible []wtxmgr.Credit) txauthor.InputSource { currentInputValues = append(currentInputValues, credit.Amount) } - return func(target btcutil.Amount) (btcutil.Amount, []*wire.TxIn, + return func(_ btcutil.Amount) (btcutil.Amount, []*wire.TxIn, []btcutil.Amount, [][]byte, error) { return currentTotal, currentInputs, currentInputValues, @@ -907,12 +905,15 @@ func constantInputSource(eligible []wtxmgr.Credit) txauthor.InputSource { } } -// findEligibleOutputs finds eligible outputs for the given key scope and +// filterEligibleOutputs finds eligible outputs for the given key scope and // account. -func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, +// +// We will build a single query for this operation, so skip the linter for now. +// +//nolint:cyclop +func (w *Wallet) filterEligibleOutputs(dbtx walletdb.ReadTx, keyScope *waddrmgr.KeyScope, account uint32, minconf uint32, - bs *waddrmgr.BlockStamp, - allowUtxo func(utxo wtxmgr.Credit) bool) ([]wtxmgr.Credit, error) { + bs *waddrmgr.BlockStamp) ([]wtxmgr.Credit, error) { addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) @@ -931,13 +932,6 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, for i := range unspent { output := &unspent[i] - // Restrict the selected utxos if a filter function is provided. - if allowUtxo != nil && - !allowUtxo(*output) { - - continue - } - // Only include this output if it meets the required number of // confirmations. Coinbase transactions must have reached // maturity before their outputs may be spent. @@ -1001,11 +995,37 @@ func inputYieldsPositively(credit *wire.TxOut, feeRatePerKb btcutil.Amount) bool { inputSize := txsizes.GetMinInputVirtualSize(credit.PkScript) - inputFee := feeRatePerKb * btcutil.Amount(inputSize) / 1000 + feeRate := btcunit.NewSatPerKVByte(feeRatePerKb) + inputFee := feeRate.FeeForVByte(btcunit.NewVByte(inputSize)) return inputFee < btcutil.Amount(credit.Value) } +func getScriptSize(addrType waddrmgr.AddressType) (int, error) { + switch addrType { + case waddrmgr.PubKeyHash: + return txsizes.P2PKHPkScriptSize, nil + + case waddrmgr.NestedWitnessPubKey: + return txsizes.NestedP2WPKHPkScriptSize, nil + + case waddrmgr.WitnessPubKey: + return txsizes.P2WPKHPkScriptSize, nil + + case waddrmgr.TaprootPubKey: + return txsizes.P2TRPkScriptSize, nil + + case waddrmgr.Script, waddrmgr.RawPubKey, waddrmgr.WitnessScript, + waddrmgr.TaprootScript: + return 0, fmt.Errorf("%w: %v", ErrUnsupportedAddressType, + addrType) + + default: + return 0, fmt.Errorf("%w: %v", ErrUnsupportedAddressType, + addrType) + } +} + // addrMgrWithChangeSource returns the address manager bucket and a change // source that returns change addresses from said address manager. The change // addresses will come from the specified key scope and account, unless a key @@ -1042,19 +1062,9 @@ func (w *Wallet) addrMgrWithChangeSource(dbtx walletdb.ReadWriteTx, } // Compute the expected size of the script for the change address type. - var scriptSize int - switch addrType { - case waddrmgr.PubKeyHash: - scriptSize = txsizes.P2PKHPkScriptSize - case waddrmgr.NestedWitnessPubKey: - scriptSize = txsizes.NestedP2WPKHPkScriptSize - case waddrmgr.WitnessPubKey: - scriptSize = txsizes.P2WPKHPkScriptSize - case waddrmgr.TaprootPubKey: - scriptSize = txsizes.P2TRPkScriptSize - default: - return nil, nil, fmt.Errorf("unsupported address type: %v", - addrType) + scriptSize, err := getScriptSize(addrType) + if err != nil { + return nil, nil, err } newChangeScript := func() ([]byte, error) { @@ -1125,7 +1135,6 @@ func (*RandomCoinSelector) ArrangeCoins(eligible []Coin, // value at the requested fee rate. positivelyYielding := make([]Coin, 0, len(eligible)) for _, output := range eligible { - if !inputYieldsPositively(&output.TxOut, feeSatPerKb) { continue } diff --git a/wallet/txsizes/size.go b/wallet/txsizes/size.go index 83c897d778..c2056f5b96 100644 --- a/wallet/txsizes/size.go +++ b/wallet/txsizes/size.go @@ -246,8 +246,8 @@ func EstimateVirtualSize(numP2PKHIns, numP2TRIns, numP2WPKHIns, numNestedP2WPKHI // GetMinInputVirtualSize returns the minimum number of vbytes that this input // adds to a transaction. -func GetMinInputVirtualSize(pkScript []byte) int { - var baseSize, witnessWeight int +func GetMinInputVirtualSize(pkScript []byte) uint64 { + var baseSize, witnessWeight uint64 switch { // If this is a p2sh output, we assume this is a // nested P2WKH. @@ -267,7 +267,6 @@ func GetMinInputVirtualSize(pkScript []byte) int { baseSize = RedeemP2PKHInputSize } - return baseSize + - (witnessWeight+blockchain.WitnessScaleFactor-1)/ - blockchain.WitnessScaleFactor + return baseSize + (witnessWeight+blockchain.WitnessScaleFactor-1)/ + blockchain.WitnessScaleFactor } From b4a8f0c6c810f6ccec586397184cea4dc9bf6a9c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 22 Dec 2025 23:06:14 +0800 Subject: [PATCH 232/691] wallet: fix existing linter errors --- wallet/wallet.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/wallet/wallet.go b/wallet/wallet.go index 85c2466865..e5cc7e60a7 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -95,6 +95,7 @@ func locateBirthdayBlock(chainClient chainConn, // Retrieve the lookup range for our block. startHeight := int32(0) + _, bestHeight, err := chainClient.GetBestBlock() if err != nil { return nil, err @@ -113,11 +114,15 @@ func locateBirthdayBlock(chainClient chainConn, for { // Retrieve the timestamp for the block halfway through our // range. + // + //nolint:mnd // Division by 2 is standard for binary search. mid := left + (right-left)/2 + hash, err := chainClient.GetBlockHash(int64(mid)) if err != nil { return nil, err } + header, err := chainClient.GetBlockHeader(hash) if err != nil { return nil, err @@ -134,6 +139,7 @@ func locateBirthdayBlock(chainClient chainConn, Height: mid, Timestamp: header.Timestamp, } + break } @@ -156,6 +162,7 @@ func locateBirthdayBlock(chainClient chainConn, Height: mid, Timestamp: header.Timestamp, } + break } From f70fe27b64efaaa51118482216de6bf866548a4f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 21 Dec 2025 21:05:48 +0800 Subject: [PATCH 233/691] chain: eliminate redundant transaction notifications in bitcoind client --- chain/bitcoind_client.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/chain/bitcoind_client.go b/chain/bitcoind_client.go index 1bf55eadd4..25b70bd1ff 100644 --- a/chain/bitcoind_client.go +++ b/chain/bitcoind_client.go @@ -1229,8 +1229,13 @@ func (c *BitcoindClient) filterBlock(block *wire.MsgBlock, height int32, // transaction. blockDetails.Index = i txDetails := btcutil.NewTx(tx) + + // We disable individual transaction notifications here because + // the full set of relevant transactions will be dispatched + // atomically via FilteredBlockConnected at the end of block + // processing. isRelevant, rec, err := c.filterTx( - txDetails, blockDetails, notify, + txDetails, blockDetails, false, ) if err != nil { log.Warnf("Unable to filter transaction %v: %v", @@ -1393,8 +1398,9 @@ func (c *BitcoindClient) filterTx(txDetails *btcutil.Tx, c.mempool[*txDetails.Hash()] = struct{}{} } - c.onRelevantTx(rec, blockDetails) - + if notify { + c.onRelevantTx(rec, blockDetails) + } return true, rec, nil } From e23a9bc05d5adbfb6aa4375206f25329435fb44d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 21 Dec 2025 21:24:19 +0800 Subject: [PATCH 234/691] wtxmgr: add method `InsertUnconfirmedTx` --- wallet/mock_test.go | 8 ++++++++ wtxmgr/interface.go | 5 +++++ wtxmgr/tx.go | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 1ebeb20e44..47293a5949 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -81,6 +81,14 @@ func (m *mockTxStore) InsertConfirmedTx(ns walletdb.ReadWriteBucket, return args.Error(0) } +// InsertUnconfirmedTx implements the wtxmgr.TxStore interface. +func (m *mockTxStore) InsertUnconfirmedTx(ns walletdb.ReadWriteBucket, + rec *wtxmgr.TxRecord, credits []wtxmgr.CreditEntry) error { + + args := m.Called(ns, rec, credits) + return args.Error(0) +} + // AddCredit implements the wtxmgr.TxStore interface. func (m *mockTxStore) AddCredit(ns walletdb.ReadWriteBucket, rec *wtxmgr.TxRecord, block *wtxmgr.BlockMeta, index uint32, diff --git a/wtxmgr/interface.go b/wtxmgr/interface.go index 1b140c1169..15e6ffc4cb 100644 --- a/wtxmgr/interface.go +++ b/wtxmgr/interface.go @@ -81,6 +81,11 @@ type TxStore interface { InsertConfirmedTx(ns walletdb.ReadWriteBucket, rec *TxRecord, block *BlockMeta, credits []CreditEntry) error + // InsertUnconfirmedTx records an unmined transaction and its associated + // credits in a single operation. + InsertUnconfirmedTx(ns walletdb.ReadWriteBucket, rec *TxRecord, + credits []CreditEntry) error + // AddCredit marks a transaction record as containing a transaction // output spendable by wallet. The output is added unspent, and is // marked spent when a new transaction spending the output is inserted diff --git a/wtxmgr/tx.go b/wtxmgr/tx.go index f84d303b1b..a09a948b16 100644 --- a/wtxmgr/tx.go +++ b/wtxmgr/tx.go @@ -418,6 +418,28 @@ func (s *Store) InsertConfirmedTx(ns walletdb.ReadWriteBucket, rec *TxRecord, return nil } +// InsertUnconfirmedTx records an unmined transaction and its associated credits +// in a single operation. This is more efficient than calling InsertTx followed +// by AddCredit for each output. +func (s *Store) InsertUnconfirmedTx(ns walletdb.ReadWriteBucket, rec *TxRecord, + credits []CreditEntry) error { + + if err := s.insertMemPoolTx(ns, rec); err != nil && err != ErrDuplicateTx { + return err + } + + for _, c := range credits { + isNew, err := s.addCredit(ns, rec, nil, c.Index, c.Change) + if err != nil { + return err + } + if isNew && s.NotifyUnspent != nil { + s.NotifyUnspent(&rec.Hash, c.Index) + } + } + return nil +} + // RemoveUnminedTx attempts to remove an unmined transaction from the // transaction store. This is to be used in the scenario that a transaction // that we attempt to rebroadcast, turns out to double spend one of our From eb4db97d2fdbe0d769539e4d33477bf1672a42bf Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 21 Dec 2025 21:26:11 +0800 Subject: [PATCH 235/691] wallet: deprecate `Rescan` Cleanup the namespace for the upcoming `Controller` interface. --- wallet/deprecated.go | 10 ++++++++++ wallet/rescan.go | 8 -------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 2dffe5a1ce..cd29e04351 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -6310,3 +6310,13 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx, return eligible, nil } + +// RescanDeprecated begins a rescan for all active addresses and unspent outputs +// of a wallet. This is intended to be used to sync a wallet back up to the +// current best block in the main chain, and is considered an initial sync +// rescan. +func (w *Wallet) RescanDeprecated(addrs []btcutil.Address, + unspent []wtxmgr.Credit) error { + + return w.rescanWithTarget(addrs, unspent, nil) +} diff --git a/wallet/rescan.go b/wallet/rescan.go index a070d6e11d..b2e8775354 100644 --- a/wallet/rescan.go +++ b/wallet/rescan.go @@ -272,14 +272,6 @@ out: w.wg.Done() } -// Rescan begins a rescan for all active addresses and unspent outputs of -// a wallet. This is intended to be used to sync a wallet back up to the -// current best block in the main chain, and is considered an initial sync -// rescan. -func (w *Wallet) Rescan(addrs []btcutil.Address, unspent []wtxmgr.Credit) error { - return w.rescanWithTarget(addrs, unspent, nil) -} - // rescanWithTarget performs a rescan starting at the optional startStamp. If // none is provided, the rescan will begin from the manager's sync tip. func (w *Wallet) rescanWithTarget(addrs []btcutil.Address, From 32897a51c672d5678d544b6949c38ffe51239eaf Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 24 Dec 2025 20:54:21 +0800 Subject: [PATCH 236/691] wallet: fix PSBT unknown field merging and add tests This commit fixes a bug in the CombinePsbt method where unknown fields were not being correctly deduplicated and merged from all PSBT packets. Specifically, a new helper function deduplicateUnknowns is introduced to handle this logic for both global and input/output scopes. --- wallet/psbt_manager.go | 31 +++++++++++++++++- wallet/psbt_manager_test.go | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index bb51ff3722..641efb5376 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -1716,6 +1716,11 @@ func (w *Wallet) CombinePsbt(_ context.Context, psbts ...*psbt.Packet) ( // Iterate through ALL packets (including the first) and merge their // contents into the combined packet. for _, p := range psbts { + // Merge Global Unknowns. + combined.Unknowns = deduplicateUnknowns( + combined.Unknowns, p.Unknowns, + ) + // Merge Inputs. for j := range combined.Inputs { err := mergePsbtInputs( @@ -1787,7 +1792,7 @@ func validatePsbtMerge(psbts []*psbt.Packet) (*psbt.Packet, error) { UnsignedTx: base.UnsignedTx.Copy(), Inputs: make([]psbt.PInput, nInputs), Outputs: make([]psbt.POutput, nOutputs), - Unknowns: base.Unknowns, + Unknowns: make([]*psbt.Unknown, 0), } return combined, nil @@ -1844,6 +1849,9 @@ func mergePsbtInputs(dest, src *psbt.PInput) error { return err } + // Merge Unknowns. + dest.Unknowns = deduplicateUnknowns(dest.Unknowns, src.Unknowns) + return nil } @@ -1874,6 +1882,9 @@ func mergePsbtOutputs(dest, src *psbt.POutput) error { return err } + // Merge Unknowns. + dest.Unknowns = deduplicateUnknowns(dest.Unknowns, src.Unknowns) + return nil } @@ -2287,3 +2298,21 @@ func PsbtPrevOutputFetcher(packet *psbt.Packet) ( return fetcher, nil } + +// deduplicateUnknowns adds new Unknowns from src to dest, avoiding duplicates +// based on Key. +// +// TODO(yy): A more efficient approach would be to use a map to track the keys +// of unknowns already in the dest slice, reducing the complexity to O(N+M). +func deduplicateUnknowns(dest, src []*psbt.Unknown) []*psbt.Unknown { + for _, unknown := range src { + if !slices.ContainsFunc(dest, func(dU *psbt.Unknown) bool { + return bytes.Equal(dU.Key, unknown.Key) + }) { + + dest = append(dest, unknown) + } + } + + return dest +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index d5d245f6e7..fa69f23f50 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -4629,3 +4629,68 @@ func TestCombinePsbt(t *testing.T) { require.ErrorIs(t, err, ErrDifferentTransactions) }) } + +// TestDeduplicateUnknowns tests that deduplicateUnknowns correctly adds new +// unknowns from src to dest while avoiding duplicates based on the key. +func TestDeduplicateUnknowns(t *testing.T) { + t.Parallel() + + // Arrange: Create some sample unknowns. + unknown1 := &psbt.Unknown{Key: []byte{1}, Value: []byte{1}} + unknown2 := &psbt.Unknown{Key: []byte{2}, Value: []byte{2}} + unknown3 := &psbt.Unknown{Key: []byte{3}, Value: []byte{3}} + + // Arrange: Create a duplicate of unknown1 (same key, different value to + // ensure we only check key). + unknown1Dup := &psbt.Unknown{Key: []byte{1}, Value: []byte{99}} + + tests := []struct { + name string + dest []*psbt.Unknown + src []*psbt.Unknown + expected []*psbt.Unknown + }{ + { + name: "no duplicates", + dest: []*psbt.Unknown{unknown1}, + src: []*psbt.Unknown{unknown2, unknown3}, + expected: []*psbt.Unknown{unknown1, unknown2, unknown3}, + }, + { + name: "duplicates in src", + dest: []*psbt.Unknown{unknown1}, + src: []*psbt.Unknown{unknown1Dup, unknown2}, + expected: []*psbt.Unknown{unknown1, unknown2}, + }, + { + name: "empty dest", + dest: []*psbt.Unknown{}, + src: []*psbt.Unknown{unknown1, unknown2}, + expected: []*psbt.Unknown{unknown1, unknown2}, + }, + { + name: "empty src", + dest: []*psbt.Unknown{unknown1}, + src: []*psbt.Unknown{}, + expected: []*psbt.Unknown{unknown1}, + }, + { + name: "all duplicates", + dest: []*psbt.Unknown{unknown1, unknown2}, + src: []*psbt.Unknown{unknown1Dup, unknown2}, + expected: []*psbt.Unknown{unknown1, unknown2}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Act: Call deduplicateUnknowns. + got := deduplicateUnknowns(tc.dest, tc.src) + + // Assert: Verify the result. + require.Equal(t, tc.expected, got) + }) + } +} From b04b6355dd383ba967d4f431256ad1d93a6eb23b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Dec 2025 18:40:45 +0800 Subject: [PATCH 237/691] wallet: fix existing linter errors --- wallet/recovery_test.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/wallet/recovery_test.go b/wallet/recovery_test.go index cc65d74f76..53819751b5 100644 --- a/wallet/recovery_test.go +++ b/wallet/recovery_test.go @@ -1,10 +1,10 @@ package wallet_test import ( - "runtime" "testing" "github.com/btcsuite/btcwallet/wallet" + "github.com/stretchr/testify/require" ) // Harness holds the BranchRecoveryState being tested, the recovery window being @@ -238,9 +238,6 @@ func assertNumInvalid(t *testing.T, i int, have, want uint32) { } func assertHaveWant(t *testing.T, i int, msg string, have, want uint32) { - _, _, line, _ := runtime.Caller(2) - if want != have { - t.Fatalf("[line: %d, step: %d] %s: got %d, want %d", - line, i, msg, have, want) - } + t.Helper() + require.Equal(t, want, have, "[step: %d] %s", i, msg) } From ea5d4c4f9dac3d9c0c4ba597e4f6698b206529e0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 19:22:19 +0800 Subject: [PATCH 238/691] wallet: mark deprecated methods in `recovery.go` --- wallet/recovery.go | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/wallet/recovery.go b/wallet/recovery.go index b49074d01d..fd27cfb32b 100644 --- a/wallet/recovery.go +++ b/wallet/recovery.go @@ -16,6 +16,8 @@ import ( // RecoveryManager maintains the state required to recover previously used // addresses, and coordinates batched processing of the blocks to search. +// +// TODO(yy): Deprecated, remove. type RecoveryManager struct { // recoveryWindow defines the key-derivation lookahead used when // attempting to recover the set of used addresses. @@ -40,6 +42,8 @@ type RecoveryManager struct { // NewRecoveryManager initializes a new RecoveryManager with a derivation // look-ahead of `recoveryWindow` child indexes, and pre-allocates a backing // array for `batchSize` blocks to scan at once. +// +// TODO(yy): Deprecated, remove. func NewRecoveryManager(recoveryWindow, batchSize uint32, chainParams *chaincfg.Params) *RecoveryManager { @@ -56,6 +60,8 @@ func NewRecoveryManager(recoveryWindow, batchSize uint32, // have been previously found. This method ensures that the recovery state's // horizons properly start from the last found address of a prior recovery // attempt. +// +// TODO(yy): Deprecated, remove. func (rm *RecoveryManager) Resurrect(ns walletdb.ReadBucket, scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, credits []wtxmgr.Credit) error { @@ -145,6 +151,8 @@ func (rm *RecoveryManager) Resurrect(ns walletdb.ReadBucket, // AddToBlockBatch appends the block information, consisting of hash and height, // to the batch of blocks to be searched. +// +// TODO(yy): Deprecated, remove. func (rm *RecoveryManager) AddToBlockBatch(hash *chainhash.Hash, height int32, timestamp time.Time) { @@ -166,16 +174,22 @@ func (rm *RecoveryManager) AddToBlockBatch(hash *chainhash.Hash, height int32, } // BlockBatch returns a buffer of blocks that have not yet been searched. +// +// TODO(yy): Deprecated, remove. func (rm *RecoveryManager) BlockBatch() []wtxmgr.BlockMeta { return rm.blockBatch } // ResetBlockBatch resets the internal block buffer to conserve memory. +// +// TODO(yy): Deprecated, remove. func (rm *RecoveryManager) ResetBlockBatch() { rm.blockBatch = rm.blockBatch[:0] } // State returns the current RecoveryState. +// +// TODO(yy): Deprecated, remove. func (rm *RecoveryManager) State() *RecoveryState { return rm.state } @@ -201,12 +215,16 @@ type RecoveryState struct { recoveryWindow uint32 // scopes maintains a map of each requested key scope to its active - // RecoveryState. + // RecoveryState. Used for legacy compatibility. + // + // TODO(yy): Deprecated, remove. scopes map[waddrmgr.KeyScope]*ScopeRecoveryState // watchedOutPoints contains the set of all outpoints known to the // wallet. This is updated iteratively as new outpoints are found during // a rescan. + // + // TODO(yy): Deprecated, remove. watchedOutPoints map[wire.OutPoint]btcutil.Address } @@ -223,9 +241,11 @@ func NewRecoveryState(recoveryWindow uint32) *RecoveryState { } } -// StateForScope returns a ScopeRecoveryState for the provided key scope. If one -// does not already exist, a new one will be generated with the RecoveryState's -// recoveryWindow. +// StateForScope returns the recovery state for the default account of the +// provided key scope. This exists for backward compatibility with legacy +// recovery logic which only supports the default account. +// +// TODO(yy): Deprecated, remove. func (rs *RecoveryState) StateForScope( keyScope waddrmgr.KeyScope) *ScopeRecoveryState { @@ -243,12 +263,16 @@ func (rs *RecoveryState) StateForScope( // WatchedOutPoints returns the global set of outpoints that are known to belong // to the wallet during recovery. +// +// TODO(yy): Deprecated, remove. func (rs *RecoveryState) WatchedOutPoints() map[wire.OutPoint]btcutil.Address { return rs.watchedOutPoints } // AddWatchedOutPoint updates the recovery state's set of known outpoints that // we will monitor for spends during recovery. +// +// TODO(yy): Deprecated, remove. func (rs *RecoveryState) AddWatchedOutPoint(outPoint *wire.OutPoint, addr btcutil.Address) { @@ -258,6 +282,8 @@ func (rs *RecoveryState) AddWatchedOutPoint(outPoint *wire.OutPoint, // ScopeRecoveryState is used to manage the recovery of addresses generated // under a particular BIP32 account. Each account tracks both an external and // internal branch recovery state, both of which use the same recovery window. +// +// TODO(yy): Deprecated, remove. type ScopeRecoveryState struct { // ExternalBranch is the recovery state of addresses generated for // external use, i.e. receiving addresses. @@ -270,6 +296,8 @@ type ScopeRecoveryState struct { // NewScopeRecoveryState initializes an ScopeRecoveryState with the chosen // recovery window. +// +// TODO(yy): Deprecated, remove. func NewScopeRecoveryState(recoveryWindow uint32) *ScopeRecoveryState { return &ScopeRecoveryState{ ExternalBranch: NewBranchRecoveryState(recoveryWindow), From 394dfd0142452062cb4b4a771a5f2ee1d4b8e6cc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 21:47:45 +0800 Subject: [PATCH 239/691] wallet: move deprecated fields to deprecated.go --- wallet/deprecated.go | 13 +++++++++++++ wallet/wallet.go | 19 +++---------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index cd29e04351..192cf72ff7 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -6185,6 +6185,19 @@ type walletDeprecated struct { quit chan struct{} quitMu sync.Mutex + // publicPassphrase is the passphrase used to encrypt and decrypt public + // data in the address manager. + publicPassphrase []byte + + // db is the underlying key-value database where all wallet data is + // persisted. + db walletdb.DB + + // recoveryWindow specifies the number of additional keys to derive + // beyond the last used one to look for previously used addresses + // during a rescan or recovery. + recoveryWindow uint32 + chainClient chain.Interface chainClientLock sync.Mutex chainClientSynced bool diff --git a/wallet/wallet.go b/wallet/wallet.go index e5cc7e60a7..b2bc73fad4 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -184,14 +184,6 @@ type Wallet struct { // these should be phased out as refactoring progresses. *walletDeprecated - // publicPassphrase is the passphrase used to encrypt and decrypt public - // data in the address manager. - publicPassphrase []byte - - // db is the underlying key-value database where all wallet data is - // persisted. - db walletdb.DB - // addrStore is the address and key manager responsible for hierarchical // deterministic (HD) derivation and storage of cryptographic keys. addrStore waddrmgr.AddrStore @@ -200,11 +192,6 @@ type Wallet struct { // querying the wallet's transaction history and unspent outputs. txStore wtxmgr.TxStore - // recoveryWindow specifies the number of additional keys to derive - // beyond the last used one to look for previously used addresses - // during a rescan or recovery. - recoveryWindow uint32 - // NtfnServer handles the delivery of wallet-related events (e.g., new // transactions, block connections) to connected clients. NtfnServer *NotificationServer @@ -477,6 +464,9 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, deprecated := &walletDeprecated{ lockedOutpoints: map[wire.OutPoint]struct{}{}, + publicPassphrase: pubPass, + db: db, + recoveryWindow: recoveryWindow, rescanAddJob: make(chan *RescanJob), rescanBatch: make(chan *rescanBatch), rescanNotifications: make(chan interface{}), @@ -495,11 +485,8 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, } w := &Wallet{ - publicPassphrase: pubPass, - db: db, addrStore: addrMgr, txStore: txMgr, - recoveryWindow: recoveryWindow, walletDeprecated: deprecated, } From aaebb497249d4c06c33a004ccaa8e08b6302e6d1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Dec 2025 20:49:55 +0800 Subject: [PATCH 240/691] wallet: fix `mockChain` --- wallet/mock_test.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 47293a5949..a15e641b37 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -873,7 +873,7 @@ func (m *mockChain) GetBestBlock() (*chainhash.Hash, int32, error) { args := m.Called() hash, _ := args.Get(0).(*chainhash.Hash) - return hash, int32(args.Int(1)), args.Error(2) + return hash, args.Get(1).(int32), args.Error(2) } // GetBlock implements the chain.Interface interface. @@ -902,27 +902,29 @@ func (m *mockChain) GetBlockHeader( return header, args.Error(1) } -func (m *mockChain) GetBlockHashes(int64, int64) ([]chainhash.Hash, error) { - args := m.Called() +func (m *mockChain) GetBlockHashes(start, end int64) ([]chainhash.Hash, error) { + args := m.Called(start, end) return args.Get(0).([]chainhash.Hash), args.Error(1) } func (m *mockChain) GetBlockHeaders( - []chainhash.Hash) ([]*wire.BlockHeader, error) { + hashes []chainhash.Hash) ([]*wire.BlockHeader, error) { - args := m.Called() + args := m.Called(hashes) return args.Get(0).([]*wire.BlockHeader), args.Error(1) } -func (m *mockChain) GetCFilters([]chainhash.Hash, wire.FilterType) ( - []*gcs.Filter, error) { +func (m *mockChain) GetCFilters(hashes []chainhash.Hash, + filterType wire.FilterType) ([]*gcs.Filter, error) { - args := m.Called() + args := m.Called(hashes, filterType) return args.Get(0).([]*gcs.Filter), args.Error(1) } -func (m *mockChain) GetBlocks([]chainhash.Hash) ([]*wire.MsgBlock, error) { - args := m.Called() +func (m *mockChain) GetBlocks( + hashes []chainhash.Hash) ([]*wire.MsgBlock, error) { + + args := m.Called(hashes) return args.Get(0).([]*wire.MsgBlock), args.Error(1) } From 498dce46bda4bac4d0e75429af3a9b9e260daee4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 31 Dec 2025 15:12:11 +0800 Subject: [PATCH 241/691] golangci: ignore wrapcheck from `ctx.Err()` There's little value to wrap the error returned from `context`, as the error itself is explicit enough. --- .golangci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 76e41e7659..6411961e23 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -83,6 +83,11 @@ linters: multi-func: true multi-if: true + wrapcheck: + ignore-sig-regexps: + # Allow returning .Err() from context.Context without wrapping it. + - context\.Context.*\.Err\(\) + gomoddirectives: replace-local: true replace-allow-list: From b2a657aa9f5cb87ace2bd8737cca0ccdbc058ae2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 13 Jan 2026 20:50:56 +0800 Subject: [PATCH 242/691] gemini: ignore reviewing deprecated code and fix doc name --- .gemini/config.yaml | 2 +- .gemini/styleguide.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gemini/config.yaml b/.gemini/config.yaml index 307f783561..0494774758 100644 --- a/.gemini/config.yaml +++ b/.gemini/config.yaml @@ -33,4 +33,4 @@ code_review: # List of glob patterns to ignore (files and directories). # Type: array of string, default: []. -ignore_patterns: [] +ignore_patterns: ["deprecated.go"] diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index f0d52a1e1e..98c22a3dce 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -1,4 +1,4 @@ -# LND Style Guide +# Btcwallet Style Guide ## Code Documentation and Commenting From 99985b714540bc73399a356f03bd9d41b4bc1e1c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Dec 2025 00:26:36 +0800 Subject: [PATCH 243/691] docs: add design docs and ADRs Adds comprehensive design documents covering state management, targeted rescans, and multi-wallet synchronization to guide the refactoring process. --- .../0002-controller-syncer-architecture.md | 51 ++++++ .../adr/0003-optimistic-cfilter-batching.md | 54 ++++++ .../adr/0004-targeted-rescan-vs-rewind.md | 58 ++++++ .../adr/0005-no-auto-rescan-on-import.md | 45 +++++ docs/developer/scanning_sync_architecture.md | 167 ++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 docs/developer/adr/0002-controller-syncer-architecture.md create mode 100644 docs/developer/adr/0003-optimistic-cfilter-batching.md create mode 100644 docs/developer/adr/0004-targeted-rescan-vs-rewind.md create mode 100644 docs/developer/adr/0005-no-auto-rescan-on-import.md create mode 100644 docs/developer/scanning_sync_architecture.md diff --git a/docs/developer/adr/0002-controller-syncer-architecture.md b/docs/developer/adr/0002-controller-syncer-architecture.md new file mode 100644 index 0000000000..46ab623e3a --- /dev/null +++ b/docs/developer/adr/0002-controller-syncer-architecture.md @@ -0,0 +1,51 @@ +# ADR 0002: Controller-Syncer-State Architecture + +## 1. Context + +The legacy `btcwallet` architecture tightly coupled lifecycle management, synchronization logic, and state tracking within a single `Wallet` struct. This monolithic design led to several issues: +* **Race Conditions:** Ambiguity between "Started" and "Syncing" states made it difficult to safely manage concurrent access. +* **Blocking Operations:** Long-running sync operations would block control-plane requests (like `Stop` or `Info`). +* **Testing Difficulty:** The tight coupling made it nearly impossible to unit test synchronization logic in isolation from the full wallet stack. + +We need a robust, testable, and concurrent architecture to support modern features like multi-wallet management and targeted rescans. + +## 2. Decision + +We will adopt a **Controller-Syncer-State** pattern with an **Orthogonal State Model**. + +### 2.1 The Components + +1. **Controller (`Controller` interface / `Wallet` struct):** + * **Role:** The public API surface and lifecycle manager. + * **Responsibility:** Validates requests, manages the `Start/Stop` lifecycle, and delegates long-running tasks. It never blocks on chain operations. + +2. **Syncer (`chainSyncer` interface / `syncer` struct):** + * **Role:** The background worker. + * **Responsibility:** Executes the chain loop, communicates with the backend, and manages the database state for synchronization. It is isolated and testable. + +3. **State (`walletState` struct):** + * **Role:** The source of truth for the wallet's status. + * **Responsibility:** Maintains state across three independent dimensions (Lifecycle, Sync, Auth) using atomic operations. + +### 2.2 Orthogonal State Model + +Instead of a single status enum, we track three separate dimensions: +* **Lifecycle:** `Stopped` -> `Starting` -> `Started` -> `Stopping` +* **Synchronization:** `BackendSyncing` -> `Syncing` -> `Synced` | `Rescanning` +* **Authentication:** `Locked` | `Unlocked` + +## 3. Consequences + +### Pros +* **Concurrency Safety:** State transitions are atomic and explicitly managed, eliminating race conditions. +* **Responsiveness:** The Controller remains responsive to user requests even while the Syncer is performing heavy I/O. +* **Testability:** The `Syncer` can be tested with a mock `Chain` and `Store` without instantiating a full `Wallet`. The `Controller` can be tested with a mock `Syncer`. +* **Clarity:** The separation of concerns makes the codebase easier to navigate and reason about. + +### Cons +* **Complexity:** Increases the number of distinct types and files. +* **Indirection:** Calls to sync functionality now go through a channel-based request mechanism rather than direct method calls. + +## 4. Status + +Accepted and Implemented. diff --git a/docs/developer/adr/0003-optimistic-cfilter-batching.md b/docs/developer/adr/0003-optimistic-cfilter-batching.md new file mode 100644 index 0000000000..fb179b2e65 --- /dev/null +++ b/docs/developer/adr/0003-optimistic-cfilter-batching.md @@ -0,0 +1,54 @@ +# ADR 0003: Optimistic CFilter Batch Scanning + +## 1. Context + +Synchronizing a wallet using BIP 157/158 Compact Filters (CFilters) presents a performance challenge. +* **Latency:** Fetching filters and blocks sequentially (Header -> Filter -> Block) incurs significant network round-trip time (RTT), especially for high-latency backends like Neutrino. +* **The Horizon Problem:** BIP 32 wallets must expand their "lookahead window" (derive new addresses) when used addresses are discovered. If a block contains a transaction to the last address in the window, the wallet must immediately derive more addresses and re-scan subsequent blocks to ensure no funds are missed. + +We need a scanning algorithm that maximizes throughput (minimizing RTT) while guaranteeing correctness (respecting the gap limit). + +## 2. Decision + +We will implement an **Optimistic Batching strategy with In-Place Resume**. + +### 2.1 The Strategy + +1. **Optimistic Fetch:** The wallet fetches headers, CFilters, and (if matched) blocks for a large batch (e.g., 100 blocks) in parallel, assuming the current address lookahead window is sufficient. +2. **Sequential Process:** The downloaded blocks are processed sequentially in memory. +3. **In-Place Resume:** If processing Block `N` triggers a horizon expansion (new addresses derived): + * The processing loop pauses. + * The wallet updates its internal watchlist with the new addresses. + * The wallet **re-scans** the remaining blocks in the *current batch* (Blocks `N+1` to `End`) using the updated watchlist. + * If necessary, it fetches missing blocks that now match the new filters. + +### 2.2 Logic Flow + +``` +Batch Loop: + 1. Fetch Filters for Batch [Start, End] + 2. Match Filters against Current Watchlist + 3. Fetch Matched Blocks + 4. Block Loop (i from Start to End): + a. Process Block(i) + b. If Horizon Expanded: + i. Update Watchlist + ii. Re-Match Filters for [i+1, End] + iii. Fetch Newly Matched Blocks + iv. Continue Loop +``` + +## 3. Consequences + +### Pros +* **High Throughput:** In the common case (no sequential expansion), the wallet fetches data in large, efficient batches, saturating the network connection. +* **Correctness:** The "In-Place Resume" logic guarantees that even if a user receives a chain of payments to sequential addresses in a single batch, the wallet will discover all of them. +* **Efficiency:** It avoids the naive "Stop-and-Go" approach of processing one block at a time, which is prohibitively slow. + +### Cons +* **Complexity:** The resumption logic adds complexity to the scan loop implementation. +* **Redundant Work (Edge Case):** In the worst-case scenario (sequential expansion in every block), the algorithm degrades to re-matching filters repeatedly. However, this is rare in practice. + +## 4. Status + +Accepted and Implemented. diff --git a/docs/developer/adr/0004-targeted-rescan-vs-rewind.md b/docs/developer/adr/0004-targeted-rescan-vs-rewind.md new file mode 100644 index 0000000000..bd4da5944b --- /dev/null +++ b/docs/developer/adr/0004-targeted-rescan-vs-rewind.md @@ -0,0 +1,58 @@ +# ADR 0004: Targeted Rescan vs. Global Rewind + +## 1. Context + +In `btcwallet`, discovering missing transactions has historically required a "Rescan." The legacy implementation treated all rescans as a "Rewind": +1. Set the wallet's global `SyncedTo` height back to the start block. +2. Force the wallet into a `Syncing` state. +3. Re-process all blocks from that height forward. + +This "Global Rewind" approach is problematic for modern use cases like importing a single private key or account. +* **Disruption:** It forces the entire wallet to be "unsynced" for minutes or hours, blocking critical operations like creating transactions, even though the existing keys are perfectly up-to-date. +* **Inefficiency:** It re-scans the chain for *all* wallet addresses, not just the imported ones. + +We need a mechanism to scan for specific keys without disrupting the global wallet state. + +## 2. Decision + +We will implement two distinct types of history recovery, managed by the `Syncer` but differentiated by their effect on the global state. + +### 2.1 Global Rewind (Manual Rescan) +* **Trigger:** Explicit user request via `Resync(...)`. +* **Behavior:** + * **Rewinds** the global `SyncedTo` watermark in the database. + * Sets state to `Syncing`. + * Re-scans for **all** known wallet addresses. +* **Use Case:** Recovering from a corrupted database, a chain reorganization deep in history, or a user explicitly wanting to "reset" the wallet's view. + +### 2.2 Targeted Rescan (Import Scan) +* **Trigger:** Importing keys/accounts (e.g., `ImportPrivateKey`, `ImportAccount`), or a user request with specific targets. +* **Behavior:** + * **Does NOT** rewind the global `SyncedTo` watermark. + * Sets state to a new `Rescanning` sub-state. + * Constructs a **Partial Recovery State** containing *only* the specific targets (addresses/scripts). + * Scans the requested block range for these targets. + * Inserts found transactions into the database. +* **Use Case:** Adding a new key to an existing, synced wallet. + +## 3. Concurrency and Safety + +To prevent race conditions during these operations, we enforce strict access control based on the Orthogonal State Model. + +* **`CreateTransaction` / `FundPsbt`**: Blocked if state is `Syncing` or `Rescanning`. The UTXO set is considered unstable during any scan. +* **`Balance` / `ListUnspent`**: Allowed during `Rescanning`. They return the state of the *existing* (synced) keys, which is safe because the targeted rescan only *adds* new data; it doesn't invalidate existing confirmed history. + +## 4. Consequences + +### Pros +* **User Experience:** Importing a key is a background task. The user can continue to use their existing funds immediately. +* **Performance:** Scanning for 1 key is significantly faster than scanning for 10,000 keys (especially with CFilters). +* **Safety:** Explicitly differentiating the states prevents the "accidental rewind" that scares users. + +### Cons +* **Complexity:** The `Syncer` logic must handle two different "modes" of operation (Global Loop vs. Ad-hoc Job). +* **Database Complexity:** We must ensure that inserting transactions during a targeted rescan doesn't conflict with the global sync loop if they happen to overlap (though the design serializes them in the `chainLoop`). + +## 5. Status + +Accepted and Implemented. diff --git a/docs/developer/adr/0005-no-auto-rescan-on-import.md b/docs/developer/adr/0005-no-auto-rescan-on-import.md new file mode 100644 index 0000000000..52039a3cc8 --- /dev/null +++ b/docs/developer/adr/0005-no-auto-rescan-on-import.md @@ -0,0 +1,45 @@ +# ADR 0005: Explicit Rescan on Import + +## 1. Context + +When importing new keys, addresses, or accounts into a wallet (e.g., via `ImportPrivateKey` or `ImportAccount`), the wallet needs to scan the blockchain history to discover any existing funds associated with these new credentials. + +A common pattern in some wallet implementations is to automatically trigger a rescan immediately upon import. However, this approach introduces several issues: +* **Performance Storms:** If a user or application imports a batch of 100 keys sequentially, an automatic trigger would launch 100 overlapping, redundant rescan jobs. +* **Blocking Behavior:** If the import method waits for the scan, a simple database insertion becomes a potentially hour-long operation. +* **API Ambiguity:** It blurs the line between "State Management" (adding a key) and "Network Operation" (scanning the chain). + +## 2. Decision + +`btcwallet` will **not** automatically trigger a blockchain rescan when keys, addresses, or accounts are imported. + +* **Import Methods are Purely Database Operations:** Methods like `ImportPrivateKey`, `ImportAccount`, and `ImportScript` will only persist the data to the wallet database and return immediately. +* **Rescans Must Be Explicit:** The caller is responsible for explicitly requesting a rescan (via `Rescan(...)`) after the import is complete. + +## 3. Rationale + +### 3.1 Batch Efficiency +This design allows downstream applications (like `lnd` or custom scripts) to batch imports efficiently. An application can import 1,000 keys in a loop and then trigger a **single** targeted rescan for the aggregate birthday of those keys. This is orders of magnitude more efficient than 1,000 individual scans. + +### 3.2 API Clarity +Separating the concerns of "Storage" and "Synchronization" makes the API predictable. +* `ImportXXX`: "I want to save this key." (Fast, Atomic, Synchronous) +* `Rescan`: "I want to look for money." (Slow, Asynchronous, Cancellable) + +### 3.3 User Control +The user (or calling software) retains control over system resources. They may choose to import keys now but defer the heavy scanning operation until a maintenance window or when bandwidth is available. + +## 4. Consequences + +### Pros +* **Performance:** Eliminates redundant scanning during bulk imports. +* **Responsiveness:** Import RPCs remain consistently fast. +* **Flexibility:** Allows advanced import workflows (e.g., offline imports). + +### Cons +* **Usability Pitfall:** A naive user might import a key and be confused why their balance shows `0`. Documentation and RPC output must clearly indicate that a rescan is required to see funds. +* **Client Burden:** Clients must implement the "Import -> Rescan" logic themselves. + +## 5. Status + +Accepted and Implemented. diff --git a/docs/developer/scanning_sync_architecture.md b/docs/developer/scanning_sync_architecture.md new file mode 100644 index 0000000000..ca6a5111e5 --- /dev/null +++ b/docs/developer/scanning_sync_architecture.md @@ -0,0 +1,167 @@ +# Wallet Synchronization and Scanning Architecture + +This document details the architecture of the `btcwallet` synchronization subsystem. It explains how the wallet maintains consensus with the blockchain, discovers relevant transactions, and manages the recovery of funds. + +## 1. High-Level Architecture + +The synchronization system is designed around a **Controller-Worker-State** pattern, separating the public API from the background work and the core logic. + +```mermaid +graph TD + User[User / RPC] -->|Calls Start/Rescan/Unlock| Controller + + subgraph "Wallet Package" + Controller[Controller] -->|Manages| State[Wallet State] + Controller -->|Sends Req| Syncer[Syncer] + + Syncer -->|Maintains| RecoveryState[Recovery State] + Syncer -->|Reads/Writes| DB[(Wallet DB)] + Syncer -->|Fetches Data| Chain[Chain Backend] + end +``` + +### 1.1 Key Components + +* **Controller (`wallet/controller.go`)**: The public face of the wallet. It manages the wallet's lifecycle (`Start`, `Stop`), handles authentication (`Lock`, `Unlock`), and acts as the gatekeeper for state transitions. It does *not* perform blocking chain operations directly. +* **Syncer (`wallet/syncer.go`)**: A dedicated background worker responsible for the main synchronization loop. It communicates with the chain backend (e.g., `bitcoind`, `neutrino`), orchestrates batch scanning, and handles blockchain reorganizations (rollbacks). +* **RecoveryState (`wallet/recovery.go`)**: A specialized state machine that encapsulates the logic for *what* to scan for. It manages BIP32 derivation horizons, address lookahead windows, and the set of watched outpoints. It is purely logic and memory-based, decoupled from the I/O mechanisms of the Syncer. + +--- + +## 2. State Management: The Orthogonal Model + +To manage concurrency and API availability safely, the wallet employs an **Orthogonal State Model**. Instead of a single monolithic status (e.g., "Syncing"), we track three independent dimensions of state. This decoupling allows for precise representation of complex conditions (e.g., a wallet can be "Started" AND "Syncing" AND "Locked") without state explosion. + +### 2.1 Lifecycle (System State) +Tracks the runtime status of the wallet's main event loop and background processes. +* **Stopped**: The wallet is idle. No background routines are running. +* **Starting**: The wallet is in the middle of its synchronous startup sequence (e.g., loading accounts, verifying birthday). +* **Started**: The wallet is fully operational. `mainLoop` and `chainLoop` are running. +* **Stopping**: A shutdown signal has been sent; the wallet is waiting for background routines to exit. + +### 2.2 Synchronization (Chain State) +Tracks data freshness relative to the blockchain backend. +* **BackendSyncing**: Waiting for the chain backend (e.g., bitcoind) to finish its own synchronization. +* **Syncing**: The wallet is actively downloading blocks or filters to catch up to the chain tip. +* **Synced**: The wallet is fully caught up with the current chain tip. +* **Rescanning**: The wallet is performing a targeted historical scan for specific accounts or addresses. This is a sub-state that does not rewind the global sync watermark. + +### 2.3 Authentication (Security State) +Tracks the accessibility of sensitive private key material. +* **Locked**: Private keys are encrypted and inaccessible in memory. +* **Unlocked**: Private keys have been decrypted and are available for signing. +* **Security Note**: The system tracks the `unlocked` flag such that the zero-value (false) defaults to the secure **Locked** state. The wallet is forcefully locked during any `Stop` or `Stopping` transition. + +--- + +## 3. Synchronization Modes + +The `Syncer` operates in two primary modes: + +### 3.1 Chain Synchronization (Global Sync) +This is the default background process that ensures the wallet maintains consensus with the blockchain. + +* **Goal**: Advance the global `SyncedTo` pointer to the current chain tip. +* **Mechanism**: Sequential forward scanning of block batches. +* **Persistence**: Upon successful completion of a batch, the wallet updates its global "sync tip" in the database. + +### 3.2 Targeted Rescan (Import Scanning) +Triggered by user actions like importing a new account, a private key, or an XPUB. + +* **Goal**: Discover historical transactions for the *newly added* keys without affecting the synchronization status of existing keys. +* **Mechanism**: Ad-hoc scanning of a specific block range (typically from the birthday of the imported key to the current tip). +* **Persistence**: Found transactions are inserted into the database, but the global `SyncedTo` watermark is **not** altered. This allows the wallet to remain "Synced" for the rest of its keys while processing the import in the background. + +--- + +## 4. Data Preparation + +Before scanning can begin, the Syncer must prepare a `RecoveryState` object. This object acts as the "Checklist" of things to look for in the blocks. The source of this data depends on the sync mode. + +### 4.1 Loading for Global Sync +When performing the standard chain sync, the wallet loads **all** active data from the database: +1. **Accounts**: Iterates through all active BIP32 accounts in the `waddrmgr`. +2. **Horizons**: For each account, retrieves the current external and internal branch horizons (the index of the last used address). +3. **Historical Addresses**: Loads every address that has ever received funds. +4. **UTXOs**: Loads all unspent transaction outputs to detect spends. + +The `RecoveryState` is initialized with this data and immediately derives `N` new lookahead addresses (based on the `RecoveryWindow`) for every account branch. + +### 4.2 Loading for Targeted Rescan +When performing a targeted rescan (e.g., after `ImportAccount`), the caller provides specific targets. The wallet constructs a **Partial Recovery State**: +1. **Targets**: Only the specific accounts or addresses requested by the caller are loaded. +2. **Isolation**: Existing, fully-synced accounts are **excluded** from this state. +3. **Efficiency**: This ensures the scanner only spends CPU cycles matching the new keys, ignoring the thousands of keys that are already up-to-date. + +--- + +## 5. Full Block Scanning Algorithm + +This is the traditional scanning method, used when bandwidth is abundant or when privacy filters are not supported by the backend. + +### 5.1 The Algorithm +1. **Fetch Batch**: The Syncer requests a batch of full blocks (e.g., 20 blocks) directly from the backend (RPC `getblock` or P2P `MSG_BLOCK`). +2. **Process Sequentially**: It iterates through each block in memory. +3. **Transaction Matching**: + * **Inputs**: Checked against the `watchedOutPoints` map to detect spends. + * **Outputs**: Checked against the `addrFilters` map to detect receives. +4. **Horizon Expansion**: If a transaction pays to a lookahead address: + * Mark address as used. + * Derive new lookahead addresses. + * **No Restart Needed**: Since we have the full block data, we simply add the new addresses to the map and continue processing. Future blocks in the batch will be checked against the updated map. + +--- + +## 6. CFilter Scanning Algorithm + +This method uses BIP 157/158 Compact Filters to minimize bandwidth usage. It is complex because filters are probabilistic and abstract; we don't have the transaction data until we fetch the block. + +### 6.1 Optimistic Batch Processing with In-Place Resume +To overcome the latency of fetching headers -> filters -> blocks sequentially, we use an **Optimistic** strategy. + +1. **Parallel Fetch**: + * Assume the current lookahead window is sufficient. + * Fetch a large batch (e.g., 250 blocks) of **Headers** and **CFilters** in parallel. + +2. **Local Filtering**: + * Match the CFilters against the `RecoveryState`'s watchlist (Addresses + Outpoints). + * Queue only the *matching* blocks for download. + +3. **Sequential Process & Resume Loop**: + * Iterate through the batch. + * **Horizon Expansion Event**: If Block `N` contains a payment to a lookahead address, we must expand the window. + * **The Problem**: The filters for blocks `N+1` to `End` were checked against the *old* watchlist. They might contain payments to the *new* addresses we just derived. + * **The Fix (In-Place Resume)**: + * Pause processing. + * Update the watchlist with new addresses. + * **Re-Match** the filters for the remainder of the batch (`N+1`...`End`) against the new watchlist. + * Fetch any *newly* matched blocks. + * Resume processing from `N+1`. + +--- + +## 7. Strategy Comparison & Selection + +The wallet automatically selects the best strategy based on the environment and state. + +| Feature | Full Block Scanning | CFilter Scanning | +| :--- | :--- | :--- | +| **Bandwidth** | High (All data) | Low (Headers + Filters + Matched Blocks) | +| **CPU Usage** | Low (Hash map lookups) | High (Elliptic Curve ops + SIPHash matching) | +| **Latency** | Low (Local) / High (Remote) | Low (Parallel Fetch) | +| **Privacy** | High (Indistinguishable) | Medium (Leaks Block Interest) | +| **Best For** | Local Bitcoind, Huge Wallets | Mobile, Light Clients, Bandwidth Cap | + +### 7.1 Selection Logic (`SyncMethodAuto`) +The wallet uses a heuristic to choose: +1. **Backend Capability**: If the backend doesn't support BIP 157 (CFilters), fall back to Full Blocks. +2. **Watchlist Size**: If the wallet is watching > 100,000 items (addresses + UTXOs), CFilter matching becomes CPU-prohibitive. The wallet switches to **Full Block** scanning, as checking a map is O(1) regardless of size. +3. **Default**: Use **CFilters** for efficiency and privacy. + +--- + +## 8. Performance and Efficiency + +* **Write Batching**: Database writes are the single biggest bottleneck. The Syncer aggregates all findings (transactions, state updates) from a batch and commits them in a **single database transaction**. This reduces disk I/O by orders of magnitude compared to per-block commits. +* **Lookahead Derivation**: Address derivation is cached. The `RecoveryState` ensures we don't re-derive keys we've already generated, even if the scan is restarted. +* **Non-Blocking**: All scanning happens in a dedicated goroutine. The Wallet Controller remains responsive to `Info` and `Balance` requests even during a massive re-sync. \ No newline at end of file From 0295afe7ee68d41c481e2a6b74e1dddf3103b62d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 31 Dec 2025 15:36:24 +0800 Subject: [PATCH 244/691] wallet: introduce `Controller` interface Defines the high-level Controller interface and the internal chainSyncer interface, establishing the contract for the modernized wallet architecture. --- wallet/controller.go | 124 +++++++++++++++++++++++++++++++++++++++++++ wallet/mock_test.go | 89 +++++++++++++++++++++++++++++++ wallet/wallet.go | 114 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 wallet/controller.go diff --git a/wallet/controller.go b/wallet/controller.go new file mode 100644 index 0000000000..d254d77aa1 --- /dev/null +++ b/wallet/controller.go @@ -0,0 +1,124 @@ +package wallet + +import ( + "context" + "errors" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcwallet/waddrmgr" +) + +var ( + // ErrWalletNotStopped is returned when an attempt is made to start the + // wallet when it is not in the stopped state. + ErrWalletNotStopped = errors.New("wallet not in stopped state") + + // ErrWalletAlreadyStarted is returned when an attempt is made to start + // the wallet when it is already started. + ErrWalletAlreadyStarted = errors.New("wallet already started") + + // ErrStateChanged is returned when the wallet state changes + // unexpectedly during an operation, such as a rescan setup. + ErrStateChanged = errors.New("wallet state changed unexpectedly") +) + +// UnlockRequest contains the parameters for unlocking the wallet. +type UnlockRequest struct { + // Passphrase is the private passphrase to unlock the wallet. + Passphrase []byte + + // Timeout defines the duration after which the wallet should + // automatically lock. If zero, it defaults to the wallet's configured + // AutoLockDuration. If negative, the wallet remains unlocked until + // explicitly locked or stopped. + Timeout time.Duration +} + +// Info provides a comprehensive snapshot of the wallet's static configuration +// and dynamic synchronization state. +type Info struct { + // BirthdayBlock is the block from which the wallet started scanning. + BirthdayBlock waddrmgr.BlockStamp + + // Backend is the name of the chain backend (e.g. "neutrino", + // "bitcoind"). + Backend string + + // ChainParams are the parameters of the chain the wallet is connected + // to. + ChainParams *chaincfg.Params + + // Locked indicates if the wallet is currently locked. + Locked bool + + // Synced indicates if the wallet is synced to the chain tip. + Synced bool + + // SyncedTo is the block to which the wallet is currently synced. + SyncedTo waddrmgr.BlockStamp + + // IsRecoveryMode indicates if the wallet is currently in recovery + // mode. + IsRecoveryMode bool + + // RecoveryProgress is the progress of the recovery (0.0 - 1.0). + RecoveryProgress float64 +} + +// ChangePassphraseRequest contains the parameters for changing wallet +// passphrases. It supports changing the public passphrase, the private +// passphrase, or both simultaneously. +type ChangePassphraseRequest struct { + // ChangePublic indicates whether the public passphrase should be + // changed. + ChangePublic bool + PublicOld []byte + PublicNew []byte + + // ChangePrivate indicates whether the private passphrase should be + // changed. + ChangePrivate bool + PrivateOld []byte + PrivateNew []byte +} + +// Controller provides an interface for managing the wallet's lifecycle and +// state. +type Controller interface { + // Unlock unlocks the wallet with a passphrase. The wallet will remain + // unlocked until explicitly locked or the provided lock duration + // expires. + Unlock(ctx context.Context, req UnlockRequest) error + + // Lock locks the wallet, clearing any cached private key material. + Lock(ctx context.Context) error + + // ChangePassphrase changes the wallet's passphrases according to the + // request. + ChangePassphrase(ctx context.Context, req ChangePassphraseRequest) error + + // Info returns a comprehensive snapshot of the wallet's static + // configuration and dynamic synchronization state. + Info(ctx context.Context) (*Info, error) + + // Start starts the background processes necessary to manage the wallet. + // It returns an error if the wallet is already started. + Start(ctx context.Context) error + + // Stop signals all wallet background processes to shutdown and blocks + // until they have all exited. It returns an error if the context is + // canceled before the shutdown is complete. + Stop(ctx context.Context) error + + // Resync rewinds the wallet's synchronization state to a specific + // block height. + Resync(ctx context.Context, startHeight uint32) error + + // Rescan initiates a targeted rescan for specific accounts or addresses + // starting from the given block height. This operation scans for + // relevant transactions without rewinding the wallet's global + // synchronization state. + Rescan(ctx context.Context, startHeight uint32, + targets []waddrmgr.AccountScope) error +} diff --git a/wallet/mock_test.go b/wallet/mock_test.go index a15e641b37..285dc71d2f 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -1159,3 +1159,92 @@ func (m *mockSpendDetails) Sign(params *RawSigParams, // isSpendDetails implements the SpendDetails interface. func (m *mockSpendDetails) isSpendDetails() {} + +// mockController is a mock implementation of the Controller interface. +type mockController struct { + mock.Mock +} + +// Compile-time check to ensure mockController implements Controller. +var _ Controller = (*mockController)(nil) + +// Unlock implements the Controller interface. +func (m *mockController) Unlock(ctx context.Context, req UnlockRequest) error { + args := m.Called(ctx, req) + return args.Error(0) +} + +// Lock implements the Controller interface. +func (m *mockController) Lock(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +// ChangePassphrase implements the Controller interface. +func (m *mockController) ChangePassphrase(ctx context.Context, + req ChangePassphraseRequest) error { + + args := m.Called(ctx, req) + return args.Error(0) +} + +// Info implements the Controller interface. +func (m *mockController) Info(ctx context.Context) (*Info, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*Info), args.Error(1) +} + +// Start implements the Controller interface. +func (m *mockController) Start(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +// Stop implements the Controller interface. +func (m *mockController) Stop(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +// Resync implements the Controller interface. +func (m *mockController) Resync(ctx context.Context, startHeight uint32) error { + args := m.Called(ctx, startHeight) + return args.Error(0) +} + +// Rescan implements the Controller interface. +func (m *mockController) Rescan(ctx context.Context, startHeight uint32, + targets []waddrmgr.AccountScope) error { + + args := m.Called(ctx, startHeight, targets) + return args.Error(0) +} + +// mockTxPublisher is a mock implementation of the TxPublisher interface. +type mockTxPublisher struct { + mock.Mock +} + +// A compile-time check to ensure that mockTxPublisher implements the +// TxPublisher interface. +var _ TxPublisher = (*mockTxPublisher)(nil) + +// CheckMempoolAcceptance implements the TxPublisher interface. +func (m *mockTxPublisher) CheckMempoolAcceptance(ctx context.Context, + tx *wire.MsgTx) error { + + args := m.Called(ctx, tx) + return args.Error(0) +} + +// Broadcast implements the TxPublisher interface. +func (m *mockTxPublisher) Broadcast(ctx context.Context, tx *wire.MsgTx, + label string) error { + + args := m.Called(ctx, tx, label) + return args.Error(0) +} diff --git a/wallet/wallet.go b/wallet/wallet.go index b2bc73fad4..00b99c77ef 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -22,6 +22,7 @@ import ( "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/walletdb/migration" @@ -87,6 +88,109 @@ var ( wtxmgrNamespaceKey = []byte("wtxmgr") ) +// SyncMethod determines the strategy used to synchronize the wallet with the +// blockchain. +type SyncMethod uint8 + +const ( + // SyncMethodAuto defaults to CFilters if available (Neutrino/Bitcoind), + // falling back to Full Block scan if not. + // + // Use Case: Default for most users. + // + // Logic: + // 1. Checks if the number of watched items (Addresses + UTXOs) exceeds + // a heuristic threshold (100,000). If so, switches to Full Block + // scanning to avoid the CPU bottleneck of client-side filter + // matching. + // 2. Attempts to fetch CFilters. If successful, uses CFilters. + // 3. If CFilters are unavailable, falls back to Full Block scanning. + SyncMethodAuto SyncMethod = iota + + // SyncMethodCFilters forces the use of Compact Filters (BIP 157/158). + // The sync process will fail if the backend does not support filters. + // + // Use Case: Bandwidth-constrained environments (mobile) or when privacy + // is paramount (Neutrino P2P). + // + // Pros: + // - Minimal Bandwidth: Only downloads headers and filters (approx 4MB + // per 200 blocks) plus relevant blocks. Ideal for sparse wallets. + // + // Cons: + // - CPU Intensive: Client-side matching is O(N*M) where N=Blocks, + // M=Addresses. Can be slow for massive wallets (>100k addresses). + // - Slower if Match Rate is High: If the wallet has transactions in + // nearly every block, it downloads filters AND blocks, resulting in + // higher overhead than full block scanning. + SyncMethodCFilters + + // SyncMethodFullBlocks forces the use of full block downloading and + // scanning, bypassing filters entirely. + // + // Use Case: High-bandwidth/Local environments (Bitcoind on localhost) + // or massive wallets (exchanges, heavy users). + // + // Pros: + // - Low CPU: Block parsing and map lookup is extremely fast compared + // to filter matching. Scaling is O(1) or O(TxOutputs) for address + // lookups, independent of watchlist size. + // - Faster for High Match Rates: Avoids the overhead of + // fetching/matching filters when most blocks are going to be + // downloaded anyway. + // + // Cons: + // - High Bandwidth: Downloads all block data (approx 200MB per 200 + // blocks). Slow on limited connections. + SyncMethodFullBlocks +) + +// Config holds the configuration options for creating a new +// WalletController. +type Config struct { + // DB is the underlying database for the wallet. + DB walletdb.DB + + // Chain is the interface to the blockchain (e.g. bitcoind, + // neutrino). If set, the wallet will automatically synchronize with + // the chain upon Start. + Chain chain.Interface + + // ChainParams defines the network parameters (e.g. mainnet, testnet). + ChainParams *chaincfg.Params + + // RecoveryWindow specifies the address lookahead for recovery. + RecoveryWindow uint32 + + // WalletSyncRetryInterval is the interval at which the wallet should + // retry syncing to the chain if it encounters an error. + WalletSyncRetryInterval time.Duration + + // SyncMethod specifies the synchronization strategy to use. + SyncMethod SyncMethod + + // AutoLockDuration is the default duration after which the wallet will + // automatically lock itself if no specific duration is provided during + // unlock. If zero or negative, the wallet will default to a hardcoded + // safe duration (e.g. 10m) unless explicitly overridden by the unlock + // request. + AutoLockDuration time.Duration + + // Name is the unique identifier for the wallet. It is used to track + // active wallet instances within the Manager. + Name string + + // PubPassphrase is the public passphrase for the wallet. + PubPassphrase []byte + + // MaxCFilterItems is the threshold of watched items (addresses + + // outpoints) above which the wallet will fallback to full block + // scanning when SyncMethodAuto is used. This avoids the CPU bottleneck + // of client-side filter matching for large watchlists. If 0, a default + // of 100,000 is used. + MaxCFilterItems int +} + // locateBirthdayBlock returns a block that meets the given birthday timestamp // by a margin of +/-2 hours. This is safe to do as the timestamp is already 2 // days in the past of the actual timestamp. @@ -173,12 +277,11 @@ func locateBirthdayBlock(chainClient chainConn, return birthdayBlock, nil } -// Wallet is a structure containing all the components for a -// complete wallet. It contains the Armory-style key store -// addresses and keys), // Wallet is a structure containing all the components for a complete wallet. // It manages the cryptographic keys, transaction history, and synchronization // with the blockchain. +// +//nolint:unused // TODO(yy): remove it once implemented type Wallet struct { // walletDeprecated embeds the legacy state and channels. Access to // these should be phased out as refactoring progresses. @@ -199,6 +302,10 @@ type Wallet struct { // wg is a wait group used to track and wait for all long-running // background goroutines to finish during a graceful shutdown. wg sync.WaitGroup + + // cfg holds the static configuration parameters provided when the + // wallet was created or loaded. + cfg Config } // AccountAddresses returns the addresses for every created address for an @@ -497,3 +604,4 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, return w, nil } + From 5d1bc2ebfc5d7ca04b8ea56743352f9836a9cbda Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 31 Dec 2025 15:36:46 +0800 Subject: [PATCH 245/691] wallet: introduce chain `syncer` implementation Adds the skeleton of the syncer struct and its initialization logic, serving as the foundation for the new chain synchronization loop. --- wallet/syncer.go | 162 ++++++++++++++++++++++++++++++++++++++++++ wallet/syncer_test.go | 123 ++++++++++++++++++++++++++++++++ wallet/wallet.go | 5 +- 3 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 wallet/syncer.go create mode 100644 wallet/syncer_test.go diff --git a/wallet/syncer.go b/wallet/syncer.go new file mode 100644 index 0000000000..f184e8f638 --- /dev/null +++ b/wallet/syncer.go @@ -0,0 +1,162 @@ +//nolint:unused,revive // TODO(yy): remove it once implemented +package wallet + +import ( + "context" + "fmt" + "sync/atomic" + + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" +) + +// syncState represents the synchronization status of the wallet with the +// blockchain. +type syncState uint32 + +const ( + // syncStateBackendSyncing indicates the wallet is waiting for the + // chain backend to finish syncing. + syncStateBackendSyncing syncState = iota + + // syncStateSyncing indicates the wallet is running but catching up to + // the chain tip (or rewinding). + syncStateSyncing + + // syncStateSynced indicates the wallet is running and synced to the + // chain tip. + syncStateSynced + + // syncStateRescanning indicates the wallet is running a historical + // scan for specific user-provided targets, such as accounts or + // addresses, without rewinding the global synchronization state. + syncStateRescanning +) + +// String returns the string representation of a syncState. +func (s syncState) String() string { + switch s { + case syncStateBackendSyncing: + return "backend-syncing" + + case syncStateSyncing: + return "syncing" + + case syncStateSynced: + return "synced" + + case syncStateRescanning: + return "rescanning" + + default: + return "unknown sync state" + } +} + +// scanType represents the type of rescan being requested. +type scanType uint8 + +const ( + // scanTypeRewind represents a full rescan which rewinds the wallet's + // state to a specific point and scans forward. + scanTypeRewind scanType = iota + + // scanTypeTargeted represents a targeted rescan for specific addresses + // or accounts without altering the global sync state. + scanTypeTargeted +) + +// scanReq is an internal request to perform a rescan. +type scanReq struct { + // typ specifies the type of rescan to perform. + typ scanType + + // startBlock specifies the block height and hash to start the rescan + // from. + startBlock waddrmgr.BlockStamp + + // targets specifies the accounts to scan for. This is only used for + // targeted rescans. + targets []waddrmgr.AccountScope +} + +// chainSyncer is a private interface that abstracts the chain synchronization +// logic, allowing it to be mocked for testing the wallet and controller. +type chainSyncer interface { + // run executes the main synchronization loop. + run(ctx context.Context) error + + // requestScan submits a rescan job to the syncer. + requestScan(ctx context.Context, req *scanReq) error + + // syncState returns the current synchronization state. + syncState() syncState +} + +// syncer is a stateless blocking worker responsible for synchronizing the +// wallet with the blockchain. It operates within the lifecycle provided by the +// caller via context and manages the chain loop, scanning, and reorg handling. +type syncer struct { + // cfg holds the configuration parameters for the syncer. + cfg Config + + // addrStore is the address and key manager. + addrStore waddrmgr.AddrStore + + // txStore is the transaction manager. + txStore wtxmgr.TxStore + + // state tracks the chain synchronization status. + state atomic.Uint32 + + // scanReqChan is the internal mailbox used to receive scan requests + // from the controller. It is buffered to ensure that submitting a + // request does not unnecessarily block the calling goroutine. + scanReqChan chan *scanReq + + // publisher is the component responsible for broadcasting transactions + // to the network. It is primarily used during the maintenance phase to + // ensure unmined transactions remain in the mempool. + publisher TxPublisher +} + +// newSyncer creates a new syncer instance. +func newSyncer(cfg Config, addrStore waddrmgr.AddrStore, + txStore wtxmgr.TxStore, publisher TxPublisher) *syncer { + + return &syncer{ + cfg: cfg, + addrStore: addrStore, + txStore: txStore, + scanReqChan: make(chan *scanReq, 1), + publisher: publisher, + } +} + +// syncState returns the current synchronization state of the wallet. +func (s *syncer) syncState() syncState { + return syncState(s.state.Load()) +} + +// isRecoveryMode returns true if the wallet is currently syncing or +// rescanning. +func (s *syncer) isRecoveryMode() bool { + status := s.syncState() + return status == syncStateSyncing || status == syncStateRescanning +} + +// run executes the main synchronization loop. +func (s *syncer) run(ctx context.Context) error { + return nil +} + +// requestScan submits a rescan job to the syncer. +func (s *syncer) requestScan(ctx context.Context, req *scanReq) error { + select { + case s.scanReqChan <- req: + return nil + + case <-ctx.Done(): + return fmt.Errorf("context done: %w", ctx.Err()) + } +} diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go new file mode 100644 index 0000000000..1757a627bb --- /dev/null +++ b/wallet/syncer_test.go @@ -0,0 +1,123 @@ +package wallet + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// TestSyncerInitialization verifies that a new syncer is created with the +// correct default state. +func TestSyncerInitialization(t *testing.T) { + t.Parallel() + + // Arrange: Initialize mock dependencies for the syncer. + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + // Act: Create a new syncer instance with a recovery window of 1. + s := newSyncer( + Config{RecoveryWindow: 1}, mockAddrStore, mockTxStore, + mockPublisher, + ) + + // Assert: Verify that the syncer is correctly initialized in the + // backend syncing state and is not in recovery mode. + require.NotNil(t, s) + require.Equal(t, syncStateBackendSyncing, s.syncState()) + require.False(t, s.isRecoveryMode()) +} + +// TestSyncerRequestScan verifies that scan requests are correctly accepted +// by the syncer's buffered channel. +func TestSyncerRequestScan(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and a rewind scan request. + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer(Config{}, mockAddrStore, mockTxStore, mockPublisher) + + req := &scanReq{ + typ: scanTypeRewind, + startBlock: waddrmgr.BlockStamp{ + Height: 100, + }, + } + + // Act: Submit request. + err := s.requestScan(context.Background(), req) + + // Assert: Ensure the request is accepted without error and is + // correctly placed in the scan request channel. + require.NoError(t, err) + + select { + case received := <-s.scanReqChan: + require.Equal(t, req, received) + default: + require.Fail(t, "request not received") + } +} + +// TestSyncerRequestScanBlocked verifies behavior when the channel is full. +func TestSyncerRequestScanBlocked(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and fill its scan request buffer. + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer(Config{}, mockAddrStore, mockTxStore, mockPublisher) + + // Fill the buffer (size 1). + s.scanReqChan <- &scanReq{} + + // Act: Submit another request with a canceled context. + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + err := s.requestScan(ctx, &scanReq{}) + + // Assert: Verify that the request fails as expected due to the + // context cancellation. + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) +} + +// TestSyncerRun verifies the run implementation. +func TestSyncerRun(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock its chain and address store. + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + ) + + // Mock expectations for the initial chain sync sequence. + // Use Maybe() because the run loop might exit immediately due to + // context cancellation. + mockAddrStore.On("Birthday").Return(time.Now()).Maybe() + mockChain.On("IsCurrent").Return(true).Maybe() + mockAddrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{}).Maybe() + mockChain.On("NotifyBlocks").Return(nil).Maybe() + + // Act: Run with canceled context to stop loop immediately. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Assert: The run loop should exit without error. + err := s.run(ctx) + require.NoError(t, err) +} diff --git a/wallet/wallet.go b/wallet/wallet.go index 00b99c77ef..5fc908b162 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -306,6 +306,10 @@ type Wallet struct { // cfg holds the static configuration parameters provided when the // wallet was created or loaded. cfg Config + + // sync is the dedicated synchronization component that manages the + // chain loop, scanning, and reorganization handling. + sync chainSyncer } // AccountAddresses returns the addresses for every created address for an @@ -604,4 +608,3 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, return w, nil } - From 751c1902086ef83543c397276ad4cd8913900068 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 19:17:19 +0800 Subject: [PATCH 246/691] wallet: implement orthogonal state model for atomic status tracking Introduces walletState to manage lifecycle, synchronization, and locking states independently, ensuring thread-safe and atomic state transitions. --- wallet/mock_test.go | 27 +++ wallet/state.go | 274 ++++++++++++++++++++++++++++ wallet/state_test.go | 417 +++++++++++++++++++++++++++++++++++++++++++ wallet/wallet.go | 5 + 4 files changed, 723 insertions(+) create mode 100644 wallet/state.go create mode 100644 wallet/state_test.go diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 285dc71d2f..811a7d060c 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -1224,6 +1224,33 @@ func (m *mockController) Rescan(ctx context.Context, startHeight uint32, return args.Error(0) } +// mockChainSyncer is a mock implementation of the chainSyncer interface. +type mockChainSyncer struct { + mock.Mock +} + +// A compile-time assertion to ensure that mockChainSyncer implements the +// chainSyncer interface. +var _ chainSyncer = (*mockChainSyncer)(nil) + +// run implements the chainSyncer interface. +func (m *mockChainSyncer) run(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +// requestScan implements the chainSyncer interface. +func (m *mockChainSyncer) requestScan(ctx context.Context, req *scanReq) error { + args := m.Called(ctx, req) + return args.Error(0) +} + +// syncState implements the chainSyncer interface. +func (m *mockChainSyncer) syncState() syncState { + args := m.Called() + return args.Get(0).(syncState) +} + // mockTxPublisher is a mock implementation of the TxPublisher interface. type mockTxPublisher struct { mock.Mock diff --git a/wallet/state.go b/wallet/state.go new file mode 100644 index 0000000000..6b85611a29 --- /dev/null +++ b/wallet/state.go @@ -0,0 +1,274 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "errors" + "fmt" + "sync/atomic" +) + +var ( + // ErrStateForbidden is returned when an operation cannot be performed + // due to the current state of the wallet (e.g., locked, not started, + // not synced). + ErrStateForbidden = errors.New("operation forbidden in current state") +) + +// lifecycle represents the lifecycle state of the wallet's main event loop. +type lifecycle uint32 + +const ( + // lifecycleStopped indicates the wallet is stopped. + lifecycleStopped lifecycle = iota + + // lifecycleStarting indicates the wallet is starting up. + lifecycleStarting + + // lifecycleStarted indicates the wallet is started. + lifecycleStarted + + // lifecycleStopping indicates the wallet is currently stopping. + lifecycleStopping +) + +// String returns the string representation of a lifecycle. +func (l lifecycle) String() string { + switch l { + case lifecycleStopped: + return "stopped" + + case lifecycleStarting: + return "starting" + + case lifecycleStarted: + return "started" + + case lifecycleStopping: + return "stopping" + + default: + return "unknown lifecycle state" + } +} + +// walletState is a thread-safe wrapper that manages the state of the wallet +// across three orthogonal dimensions. These dimensions are independent of each +// other, allowing for a precise representation of the wallet's condition at any +// given moment. +// +// The three dimensions are: +// 1. Lifecycle (System State): Tracks whether the wallet is running, stopped, +// or in transition. This dictates whether background processes are active. +// 2. Synchronization (Chain State): Tracks the wallet's progress in syncing +// with the blockchain (e.g., syncing, synced, scanning). This dictates +// data freshness and availability. +// 3. Authentication (Security State): Tracks whether the wallet is locked or +// unlocked. This dictates the ability to perform sensitive operations like +// signing. +type walletState struct { + // lifecycle tracks the start/stop state of the wallet. + lifecycle atomic.Uint32 + + // syncer is the interface used to retrieve the current chain + // synchronization status from the synchronization component. + // + // This approach is chosen to enforce a strict separation of concerns + // and ownership: + // 1. Ownership: The syncer exclusively owns and manages the writes to + // the sync state as it is the only component driving the sync. + // 2. Decoupling: walletState provides a unified view of the wallet's + // atomic conditions without needing to know the implementation + // details of the synchronization subsystem. + // 3. Consistency: By reading directly from the syncer's internal + // state (via this interface), we ensure that the wallet always + // reports a real-time, consistent view of its data freshness. + syncer chainSyncer + + // unlocked tracks whether the wallet is unlocked (true) or locked + // (false). The zero value is false (Locked), which is secure by + // default. + unlocked atomic.Bool +} + +// newWalletState creates a new walletState initialized with the provided +// syncer and secure defaults: +// - Lifecycle: Stopped (awaiting Start() call). +// - Synchronization: BackendSyncing (until syncer is running and connected). +// - Authentication: Locked (secure by default). +func newWalletState(syncer chainSyncer) walletState { + return walletState{ + syncer: syncer, + } +} + +// String returns a summary of the wallet's state. +func (s *walletState) String() string { + lc := lifecycle(s.lifecycle.Load()) + sync := s.syncState() + unlocked := s.unlocked.Load() + + return fmt.Sprintf("status=%v, sync=%v, locked=%v", lc, sync, !unlocked) +} + +// toStarting transitions the wallet state from Stopped to Starting. +// It initializes the synchronization and authentication states to their +// secure defaults. It returns an error if the wallet is already started or +// not in the Stopped state. +func (s *walletState) toStarting() error { + // 1. Lifecycle (System State): Atomic transition from Stopped to + // Starting. + if !s.lifecycle.CompareAndSwap( + uint32(lifecycleStopped), uint32(lifecycleStarting)) { + + return fmt.Errorf("%w: current state is %v", + ErrWalletAlreadyStarted, lifecycle(s.lifecycle.Load())) + } + + // 2. Authentication (Security State): Reset to Locked. This ensures + // the wallet always starts in a secure state. + s.unlocked.Store(false) + + return nil +} + +// toStarted marks the wallet as fully started. This should be called only +// after all resource initialization is complete. +func (s *walletState) toStarted() { + s.lifecycle.Store(uint32(lifecycleStarted)) +} + +// toStopping transitions the wallet from Started to Stopping. +// It returns an error if the wallet is not running. +func (s *walletState) toStopping() error { + // Atomic transition from Started to Stopping. + if !s.lifecycle.CompareAndSwap( + uint32(lifecycleStarted), uint32(lifecycleStopping)) { + + // If we are not Started, we cannot Stop. + // This covers Stopped, Starting, and Stopping. + return ErrStateForbidden + } + + // Lock the wallet during shutdown to prevent any further signing + // operations. + s.unlocked.Store(false) + + return nil +} + +// toStopped marks the wallet as fully stopped. +func (s *walletState) toStopped() { + s.lifecycle.Store(uint32(lifecycleStopped)) + + // Force lock the wallet on shutdown for security. + s.unlocked.Store(false) +} + +// toUnlocked marks the wallet as unlocked. +func (s *walletState) toUnlocked() { + s.unlocked.Store(true) +} + +// toLocked marks the wallet as locked. +func (s *walletState) toLocked() { + s.unlocked.Store(false) +} + +// syncState returns the current synchronization state. +func (s *walletState) syncState() syncState { + if s.syncer == nil { + return syncStateBackendSyncing + } + + return s.syncer.syncState() +} + +// isSynced returns true if the wallet is fully synchronized with the +// blockchain. +func (s *walletState) isSynced() bool { + return s.syncState() == syncStateSynced +} + +// isUnlocked returns true if the wallet is currently unlocked. +func (s *walletState) isUnlocked() bool { + return s.unlocked.Load() +} + +// isStarted returns true if the wallet is in the Started state. +func (s *walletState) isStarted() bool { + return lifecycle(s.lifecycle.Load()) == lifecycleStarted +} + +// isRunning returns true if the wallet is in any active state (not stopped +// or stopping). +func (s *walletState) isRunning() bool { + lc := lifecycle(s.lifecycle.Load()) + return lc != lifecycleStopped && lc != lifecycleStopping +} + +// canSign checks if the wallet is in a state allowing message/transaction +// signing. The wallet must be Started and Unlocked. +func (s *walletState) canSign() error { + if !s.isStarted() { + return fmt.Errorf("%w: wallet not started", ErrStateForbidden) + } + + if !s.isUnlocked() { + return fmt.Errorf("%w: wallet locked", ErrStateForbidden) + } + + return nil +} + +// validateSynced checks if the wallet is running and fully synchronized. +// It returns an error if the wallet is not started or if it is currently +// syncing/rescanning. +func (s *walletState) validateSynced() error { + if !s.isStarted() { + return fmt.Errorf("%w: wallet not started", ErrStateForbidden) + } + + // TODO(yy): Should we allow creating txs while syncing? + // Currently we enforce sync to ensure accurate coin selection. + sync := s.syncState() + if sync != syncStateSynced { + return fmt.Errorf("%w: wallet is currently %s", + ErrStateForbidden, sync) + } + + return nil +} + +// validateStarted checks if the wallet is currently running. +func (s *walletState) validateStarted() error { + if !s.isStarted() { + return fmt.Errorf("%w: wallet not started", ErrStateForbidden) + } + + return nil +} + +// canUnlock checks if the wallet is in a state that allows unlocking. +func (s *walletState) canUnlock() error { + return s.validateStarted() +} + +// canLock checks if the wallet is in a state that allows locking. +func (s *walletState) canLock() error { + return s.validateStarted() +} + +// canChangePassphrase checks if the wallet is in a state that allows changing +// the passphrase. +func (s *walletState) canChangePassphrase() error { + return s.validateStarted() +} + +// isRecoveryMode returns true if the wallet is currently syncing or rescanning. +func (s *walletState) isRecoveryMode() bool { + sync := s.syncState() + return sync == syncStateSyncing || sync == syncStateRescanning +} diff --git a/wallet/state_test.go b/wallet/state_test.go new file mode 100644 index 0000000000..59634a1fe6 --- /dev/null +++ b/wallet/state_test.go @@ -0,0 +1,417 @@ +package wallet + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestStateSecureByDefault verifies that the zero-value of walletState +// represents a safe, locked condition. +func TestStateSecureByDefault(t *testing.T) { + t.Parallel() + + // Arrange: Create a new state in Stopped (default) mode. + syncer := &mockChainSyncer{} + s := newWalletState(syncer) + + // Act & Assert: Verify initial state. + require.False(t, s.isStarted()) + require.False(t, s.isRunning()) + + // Act: Transition to Starting. + err := s.toStarting() + require.NoError(t, err) + + // Act: Transition to Started. + s.toStarted() + require.True(t, s.isStarted()) + require.True(t, s.isRunning()) + + // Act: Transition to Stopping. + err = s.toStopping() + require.NoError(t, err) + require.False(t, s.isStarted()) + + // Stopping is NOT running. + require.False(t, s.isRunning()) + + // Act: Transition to Stopped. + s.toStopped() + require.False(t, s.isRunning()) + + // Assert: Invalid transition (Stop when already Stopped). + err = s.toStopping() + require.ErrorIs(t, err, ErrStateForbidden) +} + +// TestStateAuthentication verifies locking and unlocking logic. +func TestStateAuthentication(t *testing.T) { + t.Parallel() + + syncer := &mockChainSyncer{} + s := newWalletState(syncer) + + // Arrange: Start the wallet (must be started to be useful). + s.toStarted() + + // Assert: Default is Locked. + require.False(t, s.isUnlocked()) + + // Act: Unlock. + s.toUnlocked() + require.True(t, s.isUnlocked()) + + // Act: Lock. + s.toLocked() + require.False(t, s.isUnlocked()) + + // Act: Verify canSign checks. + // Case 1: Locked -> Error. + err := s.canSign() + require.ErrorIs(t, err, ErrStateForbidden) + require.ErrorContains(t, err, "wallet locked") + + // Case 2: Unlocked -> Success. + s.toUnlocked() + err = s.canSign() + require.NoError(t, err) + + // Case 3: Stopped -> Error (even if unlocked, though stopped forces + // lock). + s.toStopped() + // Note: toStopped forces lock, so we must check that logic too. + require.False(t, s.isUnlocked()) + + // Manually unlock while stopped to test canSign check. + s.toUnlocked() + err = s.canSign() + require.ErrorIs(t, err, ErrStateForbidden) + require.ErrorContains(t, err, "wallet not started") +} + +// TestStateSynchronization verifies that the wallet state correctly reflects +// the syncer's status. +func TestStateSynchronization(t *testing.T) { + t.Parallel() + + syncer := &mockChainSyncer{} + s := newWalletState(syncer) + s.toStarted() + + // Arrange: Mock syncer to return Synced. + syncer.On("syncState").Return(syncStateSynced) + + // Act & Assert. + require.Equal(t, syncStateSynced, s.syncState()) + require.True(t, s.isSynced()) + require.False(t, s.isRecoveryMode()) + + // Arrange: Mock syncer to return Syncing. + // Note: We need to reset expectations or use a new mock/state if rigid. + // testify/mock allows updating expectations usually. + syncer.ExpectedCalls = nil + syncer.On("syncState").Return(syncStateSyncing) + + // Act & Assert. + require.Equal(t, syncStateSyncing, s.syncState()) + require.False(t, s.isSynced()) + require.True(t, s.isRecoveryMode()) +} + +// TestStateNilSyncer verifies behavior when syncer is nil (defensive check). +func TestStateNilSyncer(t *testing.T) { + t.Parallel() + + s := newWalletState(nil) + + // Act & Assert: Should default to BackendSyncing safely. + require.Equal(t, syncStateBackendSyncing, s.syncState()) +} + +// TestStateThreadSafety verifies that state transitions are safe under +// concurrent access. +func TestStateThreadSafety(t *testing.T) { + t.Parallel() + + syncer := &mockChainSyncer{} + s := newWalletState(syncer) + + // Arrange: Hammer the start/stop transitions. + var wg sync.WaitGroup + + start := make(chan struct{}) + + for range 100 { + wg.Add(1) + + go func() { + defer wg.Done() + + <-start + // Try to start. + _ = s.toStarting() + // Try to stop. + _ = s.toStopping() + }() + } + + close(start) + wg.Wait() + + // Assert: State should be valid (either stopped, starting, or + // stopping). + // Just ensure no panics occurred. +} + +// TestValidateSynced verifies the validation logic for operations requiring +// synchronization. +func TestValidateSynced(t *testing.T) { + t.Parallel() + + syncer := &mockChainSyncer{} + s := newWalletState(syncer) + + // Case 1: Not started. + err := s.validateSynced() + require.ErrorIs(t, err, ErrStateForbidden) + + // Case 2: Started but not synced. + s.toStarted() + syncer.On("syncState").Return(syncStateSyncing) + + err = s.validateSynced() + require.ErrorIs(t, err, ErrStateForbidden) + + // Case 3: Started and synced. + syncer.ExpectedCalls = nil + syncer.On("syncState").Return(syncStateSynced) + + err = s.validateSynced() + require.NoError(t, err) +} + +// TestStateLifecycleTransitions verifies valid and invalid lifecycle +// state transitions. +func TestStateLifecycleTransitions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lifecycle lifecycle + running bool + }{ + { + name: "started is running", + lifecycle: lifecycleStarted, + running: true, + }, + { + name: "stopped is not running", + lifecycle: lifecycleStopped, + running: false, + }, + { + name: "stopping is not running", + lifecycle: lifecycleStopping, + running: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Arrange: Setup state. + state := newWalletState(nil) + state.lifecycle.Store(uint32(tc.lifecycle)) + + // Act & Assert: Verify isRunning result. + require.Equal(t, tc.running, state.isRunning()) + }) + } +} + +// TestStateString verifies the summary string format. +func TestStateString(t *testing.T) { + t.Parallel() + + // Arrange: Create a specific state. + ms := &mockChainSyncer{} + ms.On("syncState").Return(syncStateSyncing) + + state := newWalletState(ms) + state.lifecycle.Store(uint32(lifecycleStarted)) + state.unlocked.Store(true) + + // Act: Get the summary string. + got := state.String() + + // Assert: Verify exact format and values. + // Note: String uses !unlocked for "locked" boolean value. + expected := "status=started, sync=syncing, locked=false" + require.Equal(t, expected, got) +} + +// TestStateStartStop verifies the transition logic for start and stop. +func TestStateStartStop(t *testing.T) { + t.Parallel() + + t.Run("start success", func(t *testing.T) { + t.Parallel() + + state := newWalletState(nil) + + // Set initial random state to verify reset. + state.unlocked.Store(true) + + err := state.toStarting() + require.NoError(t, err) + require.Equal(t, uint32(lifecycleStarting), + state.lifecycle.Load()) + require.False(t, state.unlocked.Load()) + + // Now mark as started. + state.toStarted() + require.Equal(t, uint32(lifecycleStarted), + state.lifecycle.Load()) + }) + + t.Run("start fail already started", func(t *testing.T) { + t.Parallel() + + state := newWalletState(nil) + state.lifecycle.Store(uint32(lifecycleStarted)) + + err := state.toStarting() + require.ErrorIs(t, err, ErrWalletAlreadyStarted) + }) + + t.Run("stop success", func(t *testing.T) { + t.Parallel() + + state := newWalletState(nil) + state.lifecycle.Store(uint32(lifecycleStarted)) + state.unlocked.Store(true) + + err := state.toStopping() + require.NoError(t, err) + + require.Equal(t, uint32(lifecycleStopping), + state.lifecycle.Load()) + require.False(t, state.unlocked.Load()) + }) + + t.Run("stop fail not started", func(t *testing.T) { + t.Parallel() + + state := newWalletState(nil) + state.lifecycle.Store(uint32(lifecycleStopped)) + + err := state.toStopping() + require.ErrorIs(t, err, ErrStateForbidden) + }) +} + +// TestStateValidateStarted verifies the validateStarted check. +func TestStateValidateStarted(t *testing.T) { + t.Parallel() + + t.Run("success started", func(t *testing.T) { + t.Parallel() + + state := newWalletState(nil) + state.lifecycle.Store(uint32(lifecycleStarted)) + require.NoError(t, state.validateStarted()) + }) + + t.Run("fail stopped", func(t *testing.T) { + t.Parallel() + + state := newWalletState(nil) + state.lifecycle.Store(uint32(lifecycleStopped)) + require.ErrorIs(t, state.validateStarted(), ErrStateForbidden) + }) +} + +// TestStateAuthChecks verifies the semantic auth check methods. +func TestStateAuthChecks(t *testing.T) { + t.Parallel() + + // Helper to set state + setState := func(s *walletState, lc lifecycle) { + s.lifecycle.Store(uint32(lc)) + } + + t.Run("started allowed", func(t *testing.T) { + t.Parallel() + + state := newWalletState(nil) + + setState(&state, lifecycleStarted) + require.NoError(t, state.canUnlock()) + require.NoError(t, state.canLock()) + require.NoError(t, state.canChangePassphrase()) + }) + + t.Run("stopped forbidden", func(t *testing.T) { + t.Parallel() + + state := newWalletState(nil) + + setState(&state, lifecycleStopped) + require.ErrorIs(t, state.canUnlock(), ErrStateForbidden) + require.ErrorIs(t, state.canLock(), ErrStateForbidden) + require.ErrorIs(t, state.canChangePassphrase(), + ErrStateForbidden) + }) +} + +// TestStateIsRecoveryMode verifies the recovery mode check. +func TestStateIsRecoveryMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sync syncState + isRecovery bool + }{ + {"backend syncing", syncStateBackendSyncing, false}, + {"syncing", syncStateSyncing, true}, + {"synced", syncStateSynced, false}, + {"rescanning", syncStateRescanning, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ms := &mockChainSyncer{} + ms.On("syncState").Return(tc.sync) + + state := newWalletState(ms) + require.Equal(t, tc.isRecovery, state.isRecoveryMode()) + }) + } +} + +// TestStateAuxiliaryMethods verifies helper methods like canUnlock, canLock, +// and canChangePassphrase. +func TestStateAuxiliaryMethods(t *testing.T) { + t.Parallel() + + syncer := &mockChainSyncer{} + s := newWalletState(syncer) + + // Case 1: Stopped -> All forbidden. + require.ErrorIs(t, s.canUnlock(), ErrStateForbidden) + require.ErrorIs(t, s.canLock(), ErrStateForbidden) + require.ErrorIs(t, s.canChangePassphrase(), ErrStateForbidden) + + // Case 2: Started -> All allowed. + s.toStarted() + require.NoError(t, s.canUnlock()) + require.NoError(t, s.canLock()) + require.NoError(t, s.canChangePassphrase()) +} diff --git a/wallet/wallet.go b/wallet/wallet.go index 5fc908b162..2290108575 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -310,6 +310,11 @@ type Wallet struct { // sync is the dedicated synchronization component that manages the // chain loop, scanning, and reorganization handling. sync chainSyncer + + // state maintains the wallet's atomic, three-dimensional status: + // Lifecycle (System), Synchronization (Chain), and Authentication + // (Security). + state walletState } // AccountAddresses returns the addresses for every created address for an From 373c8d036fa1b9ecc4d4ca59e68ac6bbf9ea52e6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 15 Jan 2026 18:24:40 +0800 Subject: [PATCH 247/691] wallet: strictly enforce CAS in lifecycle state transitions --- wallet/state.go | 44 ++++++++++++++++++++++++++++++++++++++++---- wallet/state_test.go | 40 +++++++++++++++++++++++++++++++--------- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/wallet/state.go b/wallet/state.go index 6b85611a29..ae86c83ad8 100644 --- a/wallet/state.go +++ b/wallet/state.go @@ -136,8 +136,15 @@ func (s *walletState) toStarting() error { // toStarted marks the wallet as fully started. This should be called only // after all resource initialization is complete. -func (s *walletState) toStarted() { - s.lifecycle.Store(uint32(lifecycleStarted)) +func (s *walletState) toStarted() error { + if !s.lifecycle.CompareAndSwap( + uint32(lifecycleStarting), uint32(lifecycleStarted)) { + + return fmt.Errorf("%w: cannot transition to started from %v", + ErrStateForbidden, lifecycle(s.lifecycle.Load())) + } + + return nil } // toStopping transitions the wallet from Started to Stopping. @@ -160,11 +167,40 @@ func (s *walletState) toStopping() error { } // toStopped marks the wallet as fully stopped. -func (s *walletState) toStopped() { - s.lifecycle.Store(uint32(lifecycleStopped)) +func (s *walletState) toStopped() error { + // We allow transition from Stopping (normal shutdown) or Starting + // (failure during startup). + // + // We use a CAS loop here to handle potential races where the state + // might change between Load and CompareAndSwap. + // + // This loop is guaranteed to terminate because: + // 1. If CAS succeeds, we break. + // 2. If CAS fails, it means the state changed. We reload the new state. + // 3. If the new state is not Stopping or Starting (e.g. it became + // Started or already Stopped), the validation check fails and we + // return an error. + for { + current := s.lifecycle.Load() + lc := lifecycle(current) + + if lc != lifecycleStopping && lc != lifecycleStarting { + return fmt.Errorf("%w: cannot transition to stopped "+ + "from %v", ErrStateForbidden, lc) + } + + if s.lifecycle.CompareAndSwap( + current, uint32(lifecycleStopped), + ) { + + break + } + } // Force lock the wallet on shutdown for security. s.unlocked.Store(false) + + return nil } // toUnlocked marks the wallet as unlocked. diff --git a/wallet/state_test.go b/wallet/state_test.go index 59634a1fe6..a48f8ef208 100644 --- a/wallet/state_test.go +++ b/wallet/state_test.go @@ -25,7 +25,8 @@ func TestStateSecureByDefault(t *testing.T) { require.NoError(t, err) // Act: Transition to Started. - s.toStarted() + err = s.toStarted() + require.NoError(t, err) require.True(t, s.isStarted()) require.True(t, s.isRunning()) @@ -38,7 +39,8 @@ func TestStateSecureByDefault(t *testing.T) { require.False(t, s.isRunning()) // Act: Transition to Stopped. - s.toStopped() + err = s.toStopped() + require.NoError(t, err) require.False(t, s.isRunning()) // Assert: Invalid transition (Stop when already Stopped). @@ -54,22 +56,26 @@ func TestStateAuthentication(t *testing.T) { s := newWalletState(syncer) // Arrange: Start the wallet (must be started to be useful). - s.toStarted() + require.NoError(t, s.toStarting()) + err := s.toStarted() + require.NoError(t, err) // Assert: Default is Locked. require.False(t, s.isUnlocked()) // Act: Unlock. s.toUnlocked() + require.NoError(t, err) require.True(t, s.isUnlocked()) // Act: Lock. s.toLocked() + require.NoError(t, err) require.False(t, s.isUnlocked()) // Act: Verify canSign checks. // Case 1: Locked -> Error. - err := s.canSign() + err = s.canSign() require.ErrorIs(t, err, ErrStateForbidden) require.ErrorContains(t, err, "wallet locked") @@ -80,7 +86,9 @@ func TestStateAuthentication(t *testing.T) { // Case 3: Stopped -> Error (even if unlocked, though stopped forces // lock). - s.toStopped() + require.NoError(t, s.toStopping()) + err = s.toStopped() + require.NoError(t, err) // Note: toStopped forces lock, so we must check that logic too. require.False(t, s.isUnlocked()) @@ -98,7 +106,8 @@ func TestStateSynchronization(t *testing.T) { syncer := &mockChainSyncer{} s := newWalletState(syncer) - s.toStarted() + require.NoError(t, s.toStarting()) + require.NoError(t, s.toStarted()) // Arrange: Mock syncer to return Synced. syncer.On("syncState").Return(syncStateSynced) @@ -150,8 +159,18 @@ func TestStateThreadSafety(t *testing.T) { defer wg.Done() <-start + + // NOTE: We ignore errors here because we are + // purposefully hammering the state machine from + // multiple goroutines. Many of these transitions will + // fail (e.g., trying to start an already starting + // wallet), which is expected behavior. We are + // primarily verifying that no data races or panics + // occur. + // // Try to start. _ = s.toStarting() + // Try to stop. _ = s.toStopping() }() @@ -178,7 +197,8 @@ func TestValidateSynced(t *testing.T) { require.ErrorIs(t, err, ErrStateForbidden) // Case 2: Started but not synced. - s.toStarted() + require.NoError(t, s.toStarting()) + require.NoError(t, s.toStarted()) syncer.On("syncState").Return(syncStateSyncing) err = s.validateSynced() @@ -273,7 +293,8 @@ func TestStateStartStop(t *testing.T) { require.False(t, state.unlocked.Load()) // Now mark as started. - state.toStarted() + err = state.toStarted() + require.NoError(t, err) require.Equal(t, uint32(lifecycleStarted), state.lifecycle.Load()) }) @@ -410,7 +431,8 @@ func TestStateAuxiliaryMethods(t *testing.T) { require.ErrorIs(t, s.canChangePassphrase(), ErrStateForbidden) // Case 2: Started -> All allowed. - s.toStarted() + require.NoError(t, s.toStarting()) + require.NoError(t, s.toStarted()) require.NoError(t, s.canUnlock()) require.NoError(t, s.canLock()) require.NoError(t, s.canChangePassphrase()) From 39f8b6d6f0769f0858e1f5f6e38af92c6b77f409 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Dec 2025 20:23:45 +0800 Subject: [PATCH 248/691] wallet: introduce `BranchRecoveryState` logic --- wallet/recovery.go | 105 ++++++++++++++++++++++++++++++++++++++-- wallet/recovery_test.go | 94 +++++++++++++++++++++++++++++++++-- 2 files changed, 192 insertions(+), 7 deletions(-) diff --git a/wallet/recovery.go b/wallet/recovery.go index fd27cfb32b..89033668ed 100644 --- a/wallet/recovery.go +++ b/wallet/recovery.go @@ -1,6 +1,8 @@ package wallet import ( + "errors" + "fmt" "time" "github.com/btcsuite/btcd/btcutil" @@ -279,6 +281,25 @@ func (rs *RecoveryState) AddWatchedOutPoint(outPoint *wire.OutPoint, rs.watchedOutPoints[*outPoint] = addr } +// AddrEntry holds the derivation info for an address to support +// reverse lookups during filtering. +type AddrEntry struct { + // Address is the cached address for script generation. + Address btcutil.Address + + // Credit records the transaction credit metadata (index, change) + // when this address matches a transaction output. + Credit wtxmgr.CreditEntry + + // IsLookahead indicates whether this address is part of the current + // lookahead window. If true, finding this address *in the block* + // triggers horizon expansion. + IsLookahead bool + + // addrScope identifies the specific address derivation path. + addrScope waddrmgr.AddrScope +} + // ScopeRecoveryState is used to manage the recovery of addresses generated // under a particular BIP32 account. Each account tracks both an external and // internal branch recovery state, both of which use the same recovery window. @@ -300,8 +321,8 @@ type ScopeRecoveryState struct { // TODO(yy): Deprecated, remove. func NewScopeRecoveryState(recoveryWindow uint32) *ScopeRecoveryState { return &ScopeRecoveryState{ - ExternalBranch: NewBranchRecoveryState(recoveryWindow), - InternalBranch: NewBranchRecoveryState(recoveryWindow), + ExternalBranch: NewBranchRecoveryState(recoveryWindow, nil), + InternalBranch: NewBranchRecoveryState(recoveryWindow, nil), } } @@ -335,15 +356,22 @@ type BranchRecoveryState struct { // invalidChildren records the set of child indexes that derive to // invalid keys. invalidChildren map[uint32]struct{} + + // manager is the scoped key manager used to derive addresses for this + // branch. + manager waddrmgr.AccountStore } // NewBranchRecoveryState creates a new BranchRecoveryState that can be used to // track either the external or internal branch of an account's derivation path. -func NewBranchRecoveryState(recoveryWindow uint32) *BranchRecoveryState { +func NewBranchRecoveryState(recoveryWindow uint32, + manager waddrmgr.AccountStore) *BranchRecoveryState { + return &BranchRecoveryState{ recoveryWindow: recoveryWindow, addresses: make(map[uint32]btcutil.Address), invalidChildren: make(map[uint32]struct{}), + manager: manager, } } @@ -436,3 +464,74 @@ func (brs *BranchRecoveryState) NumInvalidInHorizon() uint32 { return nInvalid } + +// buildAddrFilters is a helper method that maintains the address lookahead +// window for this branch. It performs two main tasks: +// 1. Syncs the branch state to the provided `lastKnownIndex` (if non-zero), +// ensuring the state reflects what is known from disk or previous scans. +// 2. Extends the lookahead window if necessary, deriving new addresses and +// creating filter entries for them. +// +// The returned entries are used to populate the batch-wide address filter. +func (brs *BranchRecoveryState) buildAddrFilters(bs waddrmgr.BranchScope, + lastKnownIndex uint32) ([]AddrEntry, error) { + + // 1. Sync State. + // If a last known index is provided (e.g., from DB during + // initialization), we update our state to reflect that we've found + // addresses up to this point. + if lastKnownIndex > 0 { + brs.ReportFound(lastKnownIndex - 1) + } + + // 2. Compute Extension. + // Determine the current horizon and how many new addresses are needed + // to maintain the required lookahead window (recoveryWindow) beyond + // the last found address. + curHorizon, windowToDerive := brs.ExtendHorizon() + count, childIndex := uint32(0), curHorizon + + var newEntries []AddrEntry + + // 3. Derive & Cache. + // Iterate to derive the required number of new addresses. + for count < windowToDerive { + addr, _, err := brs.manager.DeriveAddr( + bs.Account, bs.Branch, childIndex, + ) + if err != nil { + // Handle invalid children (rare in HD, but possible). + // We skip the invalid index, mark it, and continue to + // ensure we still generate the full window of *valid* + // addresses. + if errors.Is(err, hdkeychain.ErrInvalidChild) { + brs.MarkInvalidChild(childIndex) + childIndex++ + + continue + } + + return nil, fmt.Errorf("derive addr: %w", err) + } + + // Cache the valid address in the branch state for future + // lookups. + brs.AddAddr(childIndex, addr) + + // Create a filter entry for the new address. This entry + // contains the metadata (Scope, Account, Branch, Index) needed + // to map a future hit back to this specific derivation path. + as := waddrmgr.AddrScope{BranchScope: bs, Index: childIndex} + entry := AddrEntry{ + Address: addr, + addrScope: as, + IsLookahead: true, + } + newEntries = append(newEntries, entry) + + childIndex++ + count++ + } + + return newEntries, nil +} diff --git a/wallet/recovery_test.go b/wallet/recovery_test.go index 53819751b5..f20b094086 100644 --- a/wallet/recovery_test.go +++ b/wallet/recovery_test.go @@ -1,9 +1,10 @@ -package wallet_test +package wallet import ( "testing" + "time" - "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/stretchr/testify/require" ) @@ -12,7 +13,7 @@ import ( // and next unfound values. type Harness struct { t *testing.T - brs *wallet.BranchRecoveryState + brs *BranchRecoveryState recoveryWindow uint32 expHorizon uint32 expNextUnfound uint32 @@ -209,7 +210,7 @@ func TestBranchRecoveryState(t *testing.T) { // Expected horizon: 30. } - brs := wallet.NewBranchRecoveryState(recoveryWindow) + brs := NewBranchRecoveryState(recoveryWindow, nil) harness := &Harness{ t: t, brs: brs, @@ -241,3 +242,88 @@ func assertHaveWant(t *testing.T, i int, msg string, have, want uint32) { t.Helper() require.Equal(t, want, have, "[step: %d] %s", i, msg) } + +// TestRecoveryManagerBatch verifies that the RecoveryManager correctly tracks +// and resets its internal batch of processed blocks. +func TestRecoveryManagerBatch(t *testing.T) { + t.Parallel() + + // Arrange: Create a new recovery manager with a recovery window of 10 + // and a lookahead distance of 5. + rm := NewRecoveryManager(10, 5, &chainParams) + + // Act: Add a block to the current batch. + hash := chainhash.Hash{0x01} + rm.AddToBlockBatch(&hash, 100, time.Now()) + + // Assert: Verify that the block was correctly added to the batch. + batch := rm.BlockBatch() + require.Len(t, batch, 1) + require.Equal(t, int32(100), batch[0].Height) + + // Act: Clear the current batch. + rm.ResetBlockBatch() + + // Assert: Verify that the batch is now empty. + require.Empty(t, rm.BlockBatch()) +} + +// TestBranchRecoveryStateHorizon verifies horizon expansion logic. +func TestBranchRecoveryStateHorizon(t *testing.T) { + t.Parallel() + + // Arrange: Window 10. + brs := NewBranchRecoveryState(10, nil) + + // Act: Initial horizon extend. + // Horizon is 0. NextUnfound is 0. MinValid = 0 + 10 = 10. + // Delta = 10 - 0 = 10. + // Returns current horizon (start index) and delta. + horizon, delta := brs.ExtendHorizon() + require.Equal(t, uint32(0), horizon) + require.Equal(t, uint32(10), delta) + + // Act: Report found at 5. + brs.ReportFound(5) + + // NextUnfound becomes 6. + require.Equal(t, uint32(6), brs.NextUnfound()) + + // Act: Extend again. + // MinValid = 6 + 10 = 16. + // Current Horizon = 10. + // Delta = 16 - 10 = 6. + horizon, delta = brs.ExtendHorizon() + require.Equal(t, uint32(10), horizon) + require.Equal(t, uint32(6), delta) +} + +// TestBranchRecoveryStateInvalidChild verifies handling of invalid keys. +func TestBranchRecoveryStateInvalidChild(t *testing.T) { + t.Parallel() + + brs := NewBranchRecoveryState(5, nil) + // Initial: Horizon 5. + brs.ExtendHorizon() + + // Act: Mark index 2 as invalid. + brs.MarkInvalidChild(2) + + // Assert: Horizon incremented to 6. + require.Equal(t, uint32(1), brs.NumInvalidInHorizon()) + + // Act: Extend. + // NextUnfound = 0. Window = 5. Invalid = 1. + // MinValid = 0 + 5 + 1 = 6. + // Current Horizon = 6. + // Delta = 0. + horizon, delta := brs.ExtendHorizon() + require.Equal(t, uint32(6), horizon) + require.Equal(t, uint32(0), delta) + + // Act: Found 3. + brs.ReportFound(3) + + // Invalid child 2 is < 3, so it should be pruned. + require.Equal(t, uint32(0), brs.NumInvalidInHorizon()) +} From 2f12a8a05ea2a7549d46d1cf00d1b8e85f51e491 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 14 Jan 2026 17:31:21 +0800 Subject: [PATCH 249/691] wallet: expand `RecoveryState` to handle addr filters --- wallet/recovery.go | 102 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 5 deletions(-) diff --git a/wallet/recovery.go b/wallet/recovery.go index 89033668ed..6bff4437cc 100644 --- a/wallet/recovery.go +++ b/wallet/recovery.go @@ -53,7 +53,9 @@ func NewRecoveryManager(recoveryWindow, batchSize uint32, recoveryWindow: recoveryWindow, blockBatch: make([]wtxmgr.BlockMeta, 0, batchSize), chainParams: chainParams, - state: NewRecoveryState(recoveryWindow), + state: NewRecoveryState( + recoveryWindow, chainParams, nil, + ), } } @@ -222,24 +224,57 @@ type RecoveryState struct { // TODO(yy): Deprecated, remove. scopes map[waddrmgr.KeyScope]*ScopeRecoveryState + // branchStates maintains the recovery state for every branch (scope + + // account + branch). This is the source of truth. + branchStates map[waddrmgr.BranchScope]*BranchRecoveryState + // watchedOutPoints contains the set of all outpoints known to the // wallet. This is updated iteratively as new outpoints are found during // a rescan. // // TODO(yy): Deprecated, remove. watchedOutPoints map[wire.OutPoint]btcutil.Address + + // chainParams are the parameters that describe the chain we're trying + // to recover funds on. These are set at initialization and remain + // constant. + chainParams *chaincfg.Params + + // addrMgr is the address manager used to derive new keys and manage + // account state. + addrMgr waddrmgr.AddrStore + + // outpoints tracks unspent outpoints to detect spends. The value is + // the PkScript of the outpoint. This map is transient, initialized by + // InitScanState at the beginning of a batch scan and pruned by Prune() + // at the end to manage memory. + outpoints map[wire.OutPoint][]byte + + // addrFilters maps encoded addresses to their derivation info for + // identifying incoming payments. This map is transient, initialized by + // InitScanState at the beginning of a batch scan and pruned by Prune() + // at the end to manage memory. + addrFilters map[string]AddrEntry } // NewRecoveryState creates a new RecoveryState using the provided // recoveryWindow. Each RecoveryState that is subsequently initialized for a // particular key scope will receive the same recoveryWindow. -func NewRecoveryState(recoveryWindow uint32) *RecoveryState { - scopes := make(map[waddrmgr.KeyScope]*ScopeRecoveryState) +func NewRecoveryState(recoveryWindow uint32, + chainParams *chaincfg.Params, + addrMgr waddrmgr.AddrStore) *RecoveryState { return &RecoveryState{ - recoveryWindow: recoveryWindow, - scopes: scopes, + recoveryWindow: recoveryWindow, + scopes: make( + map[waddrmgr.KeyScope]*ScopeRecoveryState, + ), + branchStates: make( + map[waddrmgr.BranchScope]*BranchRecoveryState, + ), watchedOutPoints: make(map[wire.OutPoint]btcutil.Address), + chainParams: chainParams, + addrMgr: addrMgr, } } @@ -281,6 +316,61 @@ func (rs *RecoveryState) AddWatchedOutPoint(outPoint *wire.OutPoint, rs.watchedOutPoints[*outPoint] = addr } +// String returns a summary of the recovery state. +func (rs *RecoveryState) String() string { + return fmt.Sprintf("RecoveryState(addrs=%d, outpoints=%d)", + len(rs.addrFilters), len(rs.outpoints)) +} + +// Empty returns true if there are no addresses or outpoints to watch. +func (rs *RecoveryState) Empty() bool { + return len(rs.addrFilters) == 0 && len(rs.outpoints) == 0 +} + +// WatchListSize returns the total number of items (addresses + outpoints) +// in the current watchlist. +func (rs *RecoveryState) WatchListSize() int { + return len(rs.addrFilters) + len(rs.outpoints) +} + +// GetBranchState returns the recovery state for the provided branch scope. +// It acts as the source of truth for branch states by either retrieving an +// existing in-memory BranchRecoveryState for the given `bs` (branch scope) +// or creating a new one if it doesn't already exist. +// +// When a new state is created, it fetches the appropriate AccountStore (key +// manager) from the Address Manager. This ensures that the BranchRecoveryState +// is correctly linked to its derivation logic and maintains a consistent, +// up-to-date view of the branch's lookahead horizon and derived addresses +// throughout the recovery process. This centralization prevents redundant +// state creation and ensures all recovery operations for a specific branch +// operate on the same instance. +func (rs *RecoveryState) GetBranchState(bs waddrmgr.BranchScope) ( + *BranchRecoveryState, error) { + + if s, ok := rs.branchStates[bs]; ok { + return s, nil + } + + // We assume the scope is valid and active if we are requesting state + // for it. + var mgr waddrmgr.AccountStore + if rs.addrMgr != nil { + var err error + + mgr, err = rs.addrMgr.FetchScopedKeyManager(bs.Scope) + if err != nil { + return nil, fmt.Errorf("failed to fetch manager for "+ + "scope %v: %w", bs.Scope, err) + } + } + + s := NewBranchRecoveryState(rs.recoveryWindow, mgr) + rs.branchStates[bs] = s + + return s, nil +} + // AddrEntry holds the derivation info for an address to support // reverse lookups during filtering. type AddrEntry struct { @@ -337,6 +427,8 @@ func NewScopeRecoveryState(recoveryWindow uint32) *ScopeRecoveryState { // - Reporting that an address has been found. // - Retrieving all currently derived addresses for the branch. // - Looking up a particular address by its child index. +// +// TODO(yy): Privatize this struct and all its methods. type BranchRecoveryState struct { // recoveryWindow defines the key-derivation lookahead used when // attempting to recover the set of addresses on this branch. From 6a91fee6b06d9b33337b3ac5541f34c80da4af79 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 14 Jan 2026 17:32:24 +0800 Subject: [PATCH 250/691] wallet: add block processing on `RecoveryState` Thus the `RecoveryState` will be a pure data processor - it will now be responsible for processing block and returning the matched data, which all live in the memory. --- wallet/recovery.go | 370 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) diff --git a/wallet/recovery.go b/wallet/recovery.go index 6bff4437cc..355ee1da73 100644 --- a/wallet/recovery.go +++ b/wallet/recovery.go @@ -390,6 +390,376 @@ type AddrEntry struct { addrScope waddrmgr.AddrScope } +// Initialize prepares the recovery state for a new batch scan by syncing +// horizons, populating history/UTXOs, and expanding the lookahead window. +// +// TODO(yy): Once RecoveryManager is removed, privatize this method and call +// it directly from NewRecoveryState to simplify the initialization flow. +func (rs *RecoveryState) Initialize(accounts []*waddrmgr.AccountProperties, + initialAddrs []btcutil.Address, initialUnspent []wtxmgr.Credit) error { + + rs.outpoints = make(map[wire.OutPoint][]byte) + rs.addrFilters = make(map[string]AddrEntry) + + // 1. Sync Horizons & Derive Lookahead. + // + // We iterate over all accounts loaded from the database (horizonData) + // to sync the recovery horizons. This loop will also populate the + // rs.branchStates map with all active branches. For each branch, it + // will derive addresses up to the recovery window size and add them to + // rs.addrFilters. + for _, props := range accounts { + err := rs.initAccountState(props) + if err != nil { + return err + } + } + + // 2. Populate the filter with "History" - addresses that are already + // active/used in the wallet database. We monitor these to detect any + // new payments to existing keys. + for _, addr := range initialAddrs { + addrStr := addr.EncodeAddress() + + entry := AddrEntry{ + Address: addr, + IsLookahead: false, + } + rs.addrFilters[addrStr] = entry + } + + // 3. Populate the set of unspent outputs (UTXOs) to watch. We monitor + // these outpoints to detect when they are spent by a transaction in a + // block. + for _, u := range initialUnspent { + rs.outpoints[u.OutPoint] = u.PkScript + } + + return nil +} + +// BuildCFilterData constructs the list of scripts (addresses + outpoints) used +// for CFilter matching. This is an expensive operation (script derivation) and +// should only be called when filters are actually used. +func (rs *RecoveryState) BuildCFilterData() ([][]byte, error) { + // Calculate size: addrFilters (Addrs) + outpoints (UTXOs). + size := len(rs.addrFilters) + len(rs.outpoints) + watchList := make([][]byte, 0, size) + + for _, entry := range rs.addrFilters { + script, err := txscript.PayToAddrScript(entry.Address) + if err != nil { + return nil, fmt.Errorf("failed to gen script for %s: "+ + "%w", entry.Address, err) + } + + watchList = append(watchList, script) + } + + for _, script := range rs.outpoints { + watchList = append(watchList, script) + } + + return watchList, nil +} + +// TxEntry pairs a transaction record with its extracted address entries. +type TxEntry struct { + Rec *wtxmgr.TxRecord + Entries []AddrEntry +} + +// TxEntries is a list of matched transaction entries, preserving the order of +// transactions. +type TxEntries []TxEntry + +// BlockProcessResult contains the results of processing a block for recovery. +type BlockProcessResult struct { + // RelevantTxs is a slice of transactions within the block that are + // relevant to the wallet (i.e., they spend one of our watched + // outpoints or send funds to one of our addresses). + RelevantTxs []*btcutil.Tx + + // FoundHorizons maps the BranchScope to the highest child index found + // in this block. This is used for persistent horizon expansion. + FoundHorizons map[waddrmgr.BranchScope]uint32 + + // RelevantOutputs holds the details of transaction outputs that + // matched the wallet's filters. This allows efficient access to + // derivation information without re-parsing scripts or re-fetching + // addresses. + RelevantOutputs TxEntries + + // Expanded indicates whether any new addresses were derived and added + // to the address filters as a result of processing this block (i.e., a + // lookahead horizon expansion was triggered). + Expanded bool +} + +// ProcessBlock filters a block for relevant transactions and expands the +// recovery horizons if new addresses are found. It handles the "Filter -> +// Expand -> Retry" loop internally and returns the relevant transactions, +// found horizons (for state update), relevant matches (for efficient +// ingestion), and a boolean indicating if any expansion occurred. +func (rs *RecoveryState) ProcessBlock(block *wire.MsgBlock) ( + *BlockProcessResult, error) { + + var ( + expanded bool + relevantTxs []*btcutil.Tx + foundScopes map[waddrmgr.AddrScope]struct{} + relevantOutputs TxEntries + foundHorizons map[waddrmgr.BranchScope]uint32 + ) + + for { + relevantTxs, foundScopes, relevantOutputs = rs.filterBlock( + block, + ) + + foundHorizons = rs.reportFound(foundScopes) + if len(foundHorizons) == 0 { + break + } + + expandedNow, err := rs.expandHorizons() + if err != nil { + return nil, fmt.Errorf("expand horizons: %w", err) + } + + if !expandedNow { + break + } + + expanded = true + } + + return &BlockProcessResult{ + RelevantTxs: relevantTxs, + FoundHorizons: foundHorizons, + RelevantOutputs: relevantOutputs, + Expanded: expanded, + }, nil +} + +// initAccountState initializes the recovery state for a specific account by +// setting up branch recovery states for both external and internal branches. +// It iterates through the known address counts (from the provided account +// properties) to sync the horizons and populate the address filters with +// derived addresses up to the recovery window. +func (rs *RecoveryState) initAccountState( + props *waddrmgr.AccountProperties) error { + + initBranch := func(branch uint32, lastKnownIndex uint32) error { + bs := waddrmgr.BranchScope{ + Scope: props.KeyScope, + Account: props.AccountNumber, + Branch: branch, + } + + branchState, err := rs.GetBranchState(bs) + if err != nil { + return err + } + + entries, err := branchState.buildAddrFilters(bs, lastKnownIndex) + if err != nil { + return err + } + + for _, entry := range entries { + rs.addrFilters[entry.Address.EncodeAddress()] = entry + } + + return nil + } + + err := initBranch(waddrmgr.ExternalBranch, props.ExternalKeyCount) + if err != nil { + return fmt.Errorf("derive external addrs for %s/%d': %w", + props.KeyScope, props.AccountNumber, err) + } + + err = initBranch(waddrmgr.InternalBranch, props.InternalKeyCount) + if err != nil { + return fmt.Errorf("derive internal addrs for %s/%d': %w", + props.KeyScope, props.AccountNumber, err) + } + + return nil +} + +// reportFound updates the recovery state with any addresses found in the +// current block. It returns the set of found horizons (max index per branch). +func (rs *RecoveryState) reportFound( + found map[waddrmgr.AddrScope]struct{}) map[waddrmgr.BranchScope]uint32 { + + foundHorizons := make(map[waddrmgr.BranchScope]uint32) + + // Group by branch and find max index. + for addrScope := range found { + bs := addrScope.BranchScope + + idx := addrScope.Index + if currentMax, ok := foundHorizons[bs]; !ok || + idx > currentMax { + + foundHorizons[bs] = idx + } + } + + // Update memory state. + for bs, maxIdx := range foundHorizons { + state, err := rs.GetBranchState(bs) + if err != nil { + // This should theoretically not happen if the found + // map was populated correctly from filters that + // correspond to valid branch states. Log this as an + // error for debugging. + log.Errorf("Failed to get branch state for %v: %v", bs, + err) + + continue + } + + state.ReportFound(maxIdx) + } + + return foundHorizons +} + +// filterBlock checks a block for any transactions relevant to the wallet. +// It returns the relevant transactions and the set of found addresses (by +// branch scope and index). +// +// NOTE: This method mutates the recovery state's outpoints in-place by +// removing spent inputs and adding new relevant outputs. This handles +// intra-block chains correctly. +func (rs *RecoveryState) filterBlock(block *wire.MsgBlock) ([]*btcutil.Tx, + map[waddrmgr.AddrScope]struct{}, TxEntries) { + + var relevant []*btcutil.Tx + + foundScopes := make(map[waddrmgr.AddrScope]struct{}) + + var relevantOutputs TxEntries + for _, tx := range block.Transactions { + isRelevant, entries := rs.filterTx(tx, foundScopes) + if isRelevant { + relevant = append(relevant, btcutil.NewTx(tx)) + + // We create a temporary record here. The timestamp + // will be updated during commitment. + rec, _ := wtxmgr.NewTxRecordFromMsgTx( + tx, time.Time{}, + ) + + relevantOutputs = append(relevantOutputs, TxEntry{ + Rec: rec, + Entries: entries, + }) + } + } + + return relevant, foundScopes, relevantOutputs +} + +// filterTx checks a single transaction for relevance and returns any matching +// address entries. +func (rs *RecoveryState) filterTx(tx *wire.MsgTx, + foundScopes map[waddrmgr.AddrScope]struct{}) (bool, []AddrEntry) { + + var ( + isRelevant bool + entries []AddrEntry + ) + + // Check if the transaction spends any of our watched outpoints. If so, + // it's relevant (a debit). + for _, txIn := range tx.TxIn { + if _, ok := rs.outpoints[txIn.PreviousOutPoint]; ok { + isRelevant = true + + delete(rs.outpoints, txIn.PreviousOutPoint) + } + } + + // Check if the transaction pays to any of our watched addresses. If + // so, it's relevant (a credit). + for i, txOut := range tx.TxOut { + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + txOut.PkScript, rs.chainParams, + ) + if err != nil { + log.Debugf("Could not extract addresses from script "+ + "%x: %v", txOut.PkScript, err) + + continue + } + + for _, a := range addrs { + entry, ok := rs.addrFilters[a.EncodeAddress()] + if !ok { + continue + } + + isRelevant = true + + if entry.IsLookahead { + foundScopes[entry.addrScope] = struct{}{} + } + + //nolint:gosec // Output index fits in uint32. + idx := uint32(i) + + // Create result entry with Credit populated. + entry.Credit.Index = idx + entries = append(entries, entry) + + // Add new output to map immediately to catch + // intra-block spends. + op := wire.OutPoint{Hash: tx.TxHash(), Index: idx} + rs.outpoints[op] = txOut.PkScript + } + } + + return isRelevant, entries +} + +// expandHorizons ensures that the recovery state's lookahead horizon is +// sufficient by deriving new addresses if needed, and then updates the +// internal batch artifacts (addrFilters) with the lookahead addresses. +func (rs *RecoveryState) expandHorizons() (bool, error) { + // We iterate over all active branch states and ensure their lookahead + // windows are sufficiently expanded. + // + // NOTE: rs.branchStates contains the set of all active branches + // determined at initialization. This set remains static for the + // duration of the batch scan, even as the internal state of each + // branch (horizon) evolves. + var expanded bool + for bs, branchState := range rs.branchStates { + // Passing 0 for lastKnownIndex means we don't want to update + // the found status based on historical data, just ensure the + // lookahead is sufficient based on the current state. + newEntries, err := branchState.buildAddrFilters(bs, 0) + if err != nil { + return false, err + } + + if len(newEntries) > 0 { + expanded = true + + for _, entry := range newEntries { + rs.addrFilters[entry.Address.EncodeAddress()] = + entry + } + } + } + + return expanded, nil +} + // ScopeRecoveryState is used to manage the recovery of addresses generated // under a particular BIP32 account. Each account tracks both an external and // internal branch recovery state, both of which use the same recovery window. From 388d9c554e6fb44815058d345cfd39cabc22ccdf Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 14 Jan 2026 17:34:11 +0800 Subject: [PATCH 251/691] wallet: patch unit tests for recovery logic --- wallet/mock_test.go | 35 ++ wallet/recovery_test.go | 752 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 787 insertions(+) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 811a7d060c..4d0ebdb2a0 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -1275,3 +1275,38 @@ func (m *mockTxPublisher) Broadcast(ctx context.Context, tx *wire.MsgTx, args := m.Called(ctx, tx, label) return args.Error(0) } + +// mockAddress is a mock implementation of the btcutil.Address interface. +// It embeds mock.Mock to allow for flexible stubbing of its methods, +// enabling granular control over address behavior in tests. +type mockAddress struct { + mock.Mock +} + +// EncodeAddress mocks the EncodeAddress method. +// It returns a predefined string based on mock expectations. +func (m *mockAddress) EncodeAddress() string { + args := m.Called() + return args.String(0) +} + +// ScriptAddress mocks the ScriptAddress method. +// It returns a predefined byte slice based on mock expectations. +func (m *mockAddress) ScriptAddress() []byte { + args := m.Called() + return args.Get(0).([]byte) +} + +// IsForNet mocks the IsForNet method. +// It returns a predefined boolean based on mock expectations. +func (m *mockAddress) IsForNet(params *chaincfg.Params) bool { + args := m.Called(params) + return args.Bool(0) +} + +// String mocks the String method. +// It returns a predefined string based on mock expectations. +func (m *mockAddress) String() string { + args := m.Called() + return args.String(0) +} diff --git a/wallet/recovery_test.go b/wallet/recovery_test.go index f20b094086..b4c52ca4a2 100644 --- a/wallet/recovery_test.go +++ b/wallet/recovery_test.go @@ -1,10 +1,18 @@ package wallet import ( + "errors" + "fmt" "testing" "time" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/require" ) @@ -327,3 +335,747 @@ func TestBranchRecoveryStateInvalidChild(t *testing.T) { // Invalid child 2 is < 3, so it should be pruned. require.Equal(t, uint32(0), brs.NumInvalidInHorizon()) } + +// TestGetBranchState verifies the GetBranchState method of RecoveryState. +// It ensures that the method correctly fetches and caches BranchRecoveryState +// instances based on the provided BranchScope, optimizing for subsequent +// lookups by returning the cached state instead of re-creating it. +func TestGetBranchState(t *testing.T) { + t.Parallel() + + addrMgr := &mockAddrStore{} + defer addrMgr.AssertExpectations(t) + + scope := waddrmgr.KeyScope{ + Purpose: waddrmgr.KeyScopeBIP0084.Purpose, + Coin: waddrmgr.KeyScopeBIP0084.Coin, + } + bs := waddrmgr.BranchScope{ + Scope: scope, + Account: 0, + Branch: 0, + } + + // Expect FetchScopedKeyManager to be called only once for a given + // scope. + addrMgr.On("FetchScopedKeyManager", scope).Return( + &mockAccountStore{}, nil, + ).Once() + + rs := NewRecoveryState(10, &chainParams, addrMgr) + + // First call should fetch the manager and create a new state. + state1, err := rs.GetBranchState(bs) + require.NoError(t, err) + require.NotNil(t, state1) + + // Second call with the same scope should return the cached state. + state2, err := rs.GetBranchState(bs) + require.NoError(t, err) + require.Equal(t, state1, state2) +} + +// TestInitialize verifies the public Initialize method of RecoveryState. +// It ensures the method correctly sets up transient address filters and +// outpoints based on account properties and mock address derivations. +// This includes verifying that the lookahead horizons are properly synced +// and that the address filters are populated with the expected number of +// derived addresses for both external and internal branches. +func TestInitialize(t *testing.T) { + t.Parallel() + + addrMgr := &mockAddrStore{} + defer addrMgr.AssertExpectations(t) + + accountStore := &mockAccountStore{} + defer accountStore.AssertExpectations(t) + + scope := waddrmgr.KeyScope{Purpose: 84, Coin: 0} + + props := &waddrmgr.AccountProperties{ + KeyScope: scope, + AccountNumber: 0, + ExternalKeyCount: 5, // 5 found addresses + InternalKeyCount: 3, // 3 found addresses + } + + // FetchScopedKeyManager is called twice (once for external, once for + // internal branch) + addrMgr.On("FetchScopedKeyManager", scope).Return( + accountStore, nil, + ).Times(2) + + // Helper to mock DeriveAddr calls for a given branch and + // range of indices. + mockDerive := func(branch, count uint32) { + for i := range count { + id := int(branch)*1000 + int(i) + addr := &mockAddress{} + addrStr := fmt.Sprintf("addr-%d", id) + script := fmt.Appendf(nil, "script-%d", id) + + // Configure mockAddress expectations. + addr.On("EncodeAddress").Return(addrStr) + addr.On("ScriptAddress").Return(script) + + accountStore.On( + "DeriveAddr", uint32(0), branch, i, + ).Return(addr, script, nil).Once() + } + } + + // External branch: 5 found, recovery window 10. Total 15 derivations + // (0-14). + mockDerive(0, 15) + + // Internal branch: 3 found, recovery window 10. Total 13 derivations + // (0-12). + mockDerive(1, 13) + + rs := NewRecoveryState(10, &chainParams, addrMgr) + + err := rs.Initialize([]*waddrmgr.AccountProperties{props}, nil, nil) + require.NoError(t, err) + + // Verify that the address filters are populated with the expected + // number of addresses. + require.Len(t, rs.addrFilters, 15+13) + require.Equal(t, 15+13, rs.WatchListSize()) +} + +// TestProcessBlock verifies the core private filterTx and expandHorizons +// methods through the public ProcessBlock entry point. It simulates a +// block containing transactions that trigger address discovery and horizon +// expansion. This test ensures the method correctly identifies relevant +// transactions, updates outpoints, and manages the lookahead window by +// repeatedly filtering the block and expanding horizons until convergence, +// correctly handling intra-block chains and lookahead expansions. +func TestProcessBlock(t *testing.T) { + t.Parallel() + + addrMgr := &mockAddrStore{} + defer addrMgr.AssertExpectations(t) + + accountStore := &mockAccountStore{} + defer accountStore.AssertExpectations(t) + + scope := waddrmgr.KeyScope{Purpose: 84, Coin: 0} + props := &waddrmgr.AccountProperties{ + KeyScope: scope, + AccountNumber: 0, + + // Start fresh for easier expansion testing. + ExternalKeyCount: 0, + InternalKeyCount: 0, + } + + addrMgr.On("FetchScopedKeyManager", scope).Return( + accountStore, nil, + ).Maybe() // Called by GetBranchState within Initialize/ProcessBlock + + // Store generated addresses to construct block data. + addrs := make(map[int]btcutil.Address) + + // Helper to mock DeriveAddr and store the generated address. + setupDerive := func(branch, idx uint32) { + id := int(branch)*1000 + int(idx) + + // Create a valid P2PKH address for deterministic scripts. + hash := make([]byte, 20) + hash[0] = byte(id >> 8) + hash[1] = byte(id) + addr, _ := btcutil.NewAddressPubKeyHash( + hash, &chainParams, + ) + addrs[id] = addr + + // Set up mock expectation for DeriveAddr. + script, _ := txscript.PayToAddrScript(addr) + accountStore.On( + "DeriveAddr", uint32(0), branch, idx, + ).Return(addr, script, nil).Maybe() + } + + // 1. Initialize RecoveryState (derives initial lookahead 0-9 for both + // branches). + for i := range uint32(10) { + setupDerive(0, i) // External addresses + setupDerive(1, i) // Internal addresses + } + + rs := NewRecoveryState(10, &chainParams, addrMgr) + err := rs.Initialize([]*waddrmgr.AccountProperties{props}, nil, nil) + require.NoError(t, err) + + // 2. Setup expectations for subsequent horizon expansions: + // + // Finding address at index 5 (External) should make next unfound 6. + // Horizon expands to 6 + window (10) = 16. New addresses derived from + // 10 to 15. + for i := uint32(10); i < 16; i++ { + setupDerive(0, i) + } + + // Finding address at index 12 (External) should make next unfound 13. + // Horizon expands to 13 + window (10) = 23. New addresses derived from + // 16 to 22. + for i := uint32(16); i < 23; i++ { + setupDerive(0, i) + } + + // 3. Construct a mock block with transactions. + block := wire.NewMsgBlock(wire.NewBlockHeader( + 0, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + + // Tx1: Pays to Addr 5 (External Branch) - an address within initial + // lookahead. + addr5 := addrs[5] // Corresponds to id 5 (branch 0, index 5) + script5, _ := txscript.PayToAddrScript(addr5) + tx1 := wire.NewMsgTx(2) + tx1.AddTxOut(wire.NewTxOut(1000, script5)) + _ = block.AddTransaction(tx1) + + // Tx2: Pays to Addr 12 (External Branch) - an address initially + // outside the lookahead, but becomes visible after the first + // expansion. + addr12 := addrs[12] // Corresponds to id 12 (branch 0, index 12) + script12, _ := txscript.PayToAddrScript(addr12) + tx2 := wire.NewMsgTx(2) + tx2.AddTxOut(wire.NewTxOut(2000, script12)) + _ = block.AddTransaction(tx2) + + // 4. Process the block and verify results. + res, err := rs.ProcessBlock(block) + require.NoError(t, err) + + // Expect expansion occurred due to finding index 12. + require.True(t, res.Expanded) + + // Expect both transactions to be identified as relevant. + require.Len(t, res.RelevantTxs, 2) + + // Verify the maximum index found for Branch 0 (External) is 12. + bs := waddrmgr.BranchScope{Scope: scope, Account: 0, Branch: 0} + require.Contains(t, res.FoundHorizons, bs) + require.Equal(t, uint32(12), res.FoundHorizons[bs]) +} + +// TestBuildCFilterData verifies the BuildCFilterData method of RecoveryState. +// It ensures that the method correctly aggregates all relevant scripts from +// both the transient address filters and the watched outpoints into a single +// list, which is then used for CFilter construction. This tests the data +// aggregation logic, independent of address derivation or block processing. +func TestBuildCFilterData(t *testing.T) { + t.Parallel() + + addrMgr := &mockAddrStore{} + defer addrMgr.AssertExpectations(t) + + rs := NewRecoveryState(10, &chainParams, addrMgr) + + // Initially, the recovery state should be empty. + require.True(t, rs.Empty()) + + // Manually initialize maps as Initialize is not called for this test. + rs.addrFilters = make(map[string]AddrEntry) + rs.outpoints = make(map[wire.OutPoint][]byte) + + // Add a sample address filter entry. + addr1, _ := btcutil.DecodeAddress( + "mrCDrCybB6J1vRfbwM5hemdJz73FwDBC8r", &chainParams, + ) + rs.addrFilters[addr1.EncodeAddress()] = AddrEntry{ + Address: addr1, + } + + // Add a sample watched outpoint. + op := wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0} + pkScript := []byte{0x00, 0x14, 0x01, 0x02} // Dummy P2WPKH script + rs.outpoints[op] = pkScript + + // Verify WatchListSize reflects the manually added entries. + require.Equal(t, 2, rs.WatchListSize()) + + // Build the CFilter data. + data, err := rs.BuildCFilterData() + require.NoError(t, err) + + // Construct the expected script for addr1. + script1, _ := txscript.PayToAddrScript(addr1) + + // Verify the returned data contains both scripts. + require.Len(t, data, 2) + require.Contains(t, data, script1) + require.Contains(t, data, pkScript) +} + +// TestInitAccountState verifies the private initAccountState method. +// It focuses on ensuring that when an account's properties are processed, +// the method correctly initializes branch recovery states for both external +// and internal branches. This involves verifying that the address manager's +// FetchScopedKeyManager is called appropriately and that the recovery +// state's addrFilters are populated with the initial set of derived +// addresses based on the configured recovery window. +func TestInitAccountState(t *testing.T) { + t.Parallel() + + addrMgr := &mockAddrStore{} + defer addrMgr.AssertExpectations(t) + + accountStore := &mockAccountStore{} + defer accountStore.AssertExpectations(t) + + scope := waddrmgr.KeyScope{Purpose: 84, Coin: 0} + props := &waddrmgr.AccountProperties{ + KeyScope: scope, + AccountNumber: 0, + ExternalKeyCount: 0, + InternalKeyCount: 0, + } + + // RecoveryWindow 2. + rs := NewRecoveryState(2, &chainParams, addrMgr) + rs.addrFilters = make(map[string]AddrEntry) + + // FetchScopedKeyManager is called twice (once for external, once for + // internal branch). + addrMgr.On("FetchScopedKeyManager", scope).Return( + accountStore, nil, + ).Times(2) + + // Helper to mock DeriveAddr calls for a given branch and range of + // indices. + mockDerive := func(branch uint32) { + for i := range uint32(2) { + id := int(branch)*1000 + int(i) + addr := &mockAddress{} + addrStr := fmt.Sprintf("b%d-%d", branch, i) + script := fmt.Appendf(nil, "script-%d", id) + + // Configure mockAddress expectations. + addr.On("EncodeAddress").Return(addrStr) + addr.On("ScriptAddress").Return(script) + + accountStore.On( + "DeriveAddr", uint32(0), branch, i, + ).Return(addr, script, nil).Once() + } + } + + mockDerive(0) // External + mockDerive(1) // Internal + + err := rs.initAccountState(props) + require.NoError(t, err) + require.Len(t, rs.addrFilters, 4) +} + +// TestExpandHorizons verifies the private expandHorizons method. +// It ensures that the lookahead horizon is correctly expanded when new +// addresses are reported as found. This test specifically checks that new +// addresses are derived (via the mocked AccountStore) to maintain the recovery +// window, and that these newly derived addresses are subsequently added to the +// RecoveryState's transient addrFilters. +func TestExpandHorizons(t *testing.T) { + t.Parallel() + + store := &mockAccountStore{} + defer store.AssertExpectations(t) + + rs := NewRecoveryState(2, nil, nil) + rs.addrFilters = make(map[string]AddrEntry) + + // Setup a branch state manually. + bs := waddrmgr.BranchScope{Branch: 0} + brs := NewBranchRecoveryState(2, store) + rs.branchStates[bs] = brs + + // Simulate finding index 0, which requires derivation of 0, 1, 2 + // because NextUnfound becomes 1, MinHorizon = 1+2=3. + brs.ReportFound(0) + + // Expect derivation of 0, 1, 2. + for i := range uint32(3) { + addr := &mockAddress{} + addrStr := fmt.Sprintf("addr-%d", i) + script := fmt.Appendf(nil, "script-%d", i) + + addr.On("EncodeAddress").Return(addrStr) + addr.On("ScriptAddress").Return(script) + + store.On("DeriveAddr", uint32(0), uint32(0), i).Return( + addr, script, nil, + ).Once() + } + + expanded, err := rs.expandHorizons() + require.NoError(t, err) + require.True(t, expanded) + require.Len(t, rs.addrFilters, 3) +} + +// TestReportFound verifies the private reportFound method. +// It ensures that the method correctly processes a map of found AddrScopes, +// identifying the maximum index found for each BranchScope. It then verifies +// that the corresponding BranchRecoveryState's internal `nextUnfound` value is +// updated appropriately based on these findings, triggering potential future +// horizon expansions. +func TestReportFound(t *testing.T) { + t.Parallel() + + rs := NewRecoveryState(10, nil, nil) + bs := waddrmgr.BranchScope{Branch: 0} + brs := NewBranchRecoveryState(10, nil) + rs.branchStates[bs] = brs + + // Simulate finding index 5 on this branch. + found := map[waddrmgr.AddrScope]struct{}{ + {BranchScope: bs, Index: 5}: {}, + } + + horizons := rs.reportFound(found) + + require.Contains(t, horizons, bs) + require.Equal(t, uint32(5), horizons[bs]) + require.Equal(t, uint32(6), brs.NextUnfound()) +} + +// TestFilterTx verifies the private filterTx method. +// It simulates a single transaction and checks its relevance against the +// RecoveryState's configured address filters and watched outpoints. This test +// ensures that the method correctly identifies credits (payments to our +// addresses) and debits (spends from our outpoints), updates the transient +// outpoints map (removing spent inputs, adding new relevant outputs), and +// populates the foundScopes and relevantOutputs maps for subsequent +// processing. +func TestFilterTx(t *testing.T) { + t.Parallel() + + rs := NewRecoveryState(10, &chainParams, nil) + rs.addrFilters = make(map[string]AddrEntry) + rs.outpoints = make(map[wire.OutPoint][]byte) + + // 1. Setup Watched Address. + // Use real address for Script parsing interaction with txscript. + addr, _ := btcutil.DecodeAddress( + "mrCDrCybB6J1vRfbwM5hemdJz73FwDBC8r", &chainParams, + ) + + rs.addrFilters[addr.EncodeAddress()] = AddrEntry{ + Address: addr, + addrScope: waddrmgr.AddrScope{ + BranchScope: waddrmgr.BranchScope{Branch: 0}, + Index: 10, + }, + IsLookahead: true, + } + + // 2. Setup Watched Outpoint. + opHash := chainhash.Hash{0x01} + op := wire.OutPoint{Hash: opHash, Index: 0} + rs.outpoints[op] = []byte{0x00} // Dummy script + + // 3. Construct Tx. + // Input spending 'op'. + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&op, nil, nil)) + + // Output paying to 'addr'. + pkScript, _ := txscript.PayToAddrScript(addr) + tx.AddTxOut(wire.NewTxOut(1000, pkScript)) + + // 4. Filter. + foundScopes := make(map[waddrmgr.AddrScope]struct{}) + isRelevant, entries := rs.filterTx(tx, foundScopes) + require.True(t, isRelevant) + + // Verify Outpoint spent (removed). + _, ok := rs.outpoints[op] + require.False(t, ok, "outpoint should be removed") + + // Verify Output matched. + txHash := tx.TxHash() + + require.Len(t, entries, 1) + + // Verify Scope found. + expectedScope := waddrmgr.AddrScope{ + BranchScope: waddrmgr.BranchScope{Branch: 0}, + Index: 10, + } + require.Contains(t, foundScopes, expectedScope) + + // Verify new outpoint added. + newOp := wire.OutPoint{Hash: txHash, Index: 0} + _, ok = rs.outpoints[newOp] + require.True(t, ok, "new outpoint should be watched") +} + +// TestRecoveryStateWatchedOutPoints verifies the management of persistent +// watched outpoints through AddWatchedOutPoint and WatchedOutPoints. +func TestRecoveryStateWatchedOutPoints(t *testing.T) { + t.Parallel() + + rs := NewRecoveryState(10, nil, nil) + + // Initially, no watched outpoints. + require.Empty(t, rs.WatchedOutPoints()) + + op1 := wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0} + addr1 := &mockAddress{} + op2 := wire.OutPoint{Hash: chainhash.Hash{2}, Index: 1} + addr2 := &mockAddress{} + + rs.AddWatchedOutPoint(&op1, addr1) + rs.AddWatchedOutPoint(&op2, addr2) + + watched := rs.WatchedOutPoints() + require.Len(t, watched, 2) + require.Equal(t, addr1, watched[op1]) + require.Equal(t, addr2, watched[op2]) +} + +// TestRecoveryStateStringAndEmpty verifies the String and Empty methods of +// RecoveryState. It ensures that the String method produces a non-empty +// summary and that the Empty method accurately reflects the presence or +// absence of filters and outpoints. +func TestRecoveryStateStringAndEmpty(t *testing.T) { + t.Parallel() + + rs := NewRecoveryState(10, nil, nil) + + // Initially, state should be empty. + require.True(t, rs.Empty()) + require.Contains(t, rs.String(), "RecoveryState(addrs=0, outpoints=0)") + + // Add an address filter entry. + rs.addrFilters = make(map[string]AddrEntry) + rs.addrFilters["a"] = AddrEntry{} + + require.False(t, rs.Empty()) + require.Contains(t, rs.String(), "RecoveryState(addrs=1, outpoints=0)") + + // Add an outpoint. + rs.outpoints = make(map[wire.OutPoint][]byte) + op := wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0} + rs.outpoints[op] = []byte{} + + require.False(t, rs.Empty()) + require.Contains(t, rs.String(), "RecoveryState(addrs=1, outpoints=1)") +} + +// Define a static error for testing FetchScopedKeyManager failure. +var errFetch = errors.New("fetch error") + +// TestExpandHorizonsWithInvalidChild verifies that expandHorizons correctly +// handles hdkeychain.ErrInvalidChild by skipping the invalid index and +// continuing derivation until the window is full. +func TestExpandHorizonsWithInvalidChild(t *testing.T) { + t.Parallel() + + store := &mockAccountStore{} + defer store.AssertExpectations(t) + + rs := NewRecoveryState(2, nil, nil) + rs.addrFilters = make(map[string]AddrEntry) + + // Setup a branch state manually. + bs := waddrmgr.BranchScope{Branch: 0} + brs := NewBranchRecoveryState(2, store) + rs.branchStates[bs] = brs + + // Simulate finding index 0. This triggers expansion. + brs.ReportFound(0) + + // Expect derivation of 0 -> Success + addr0 := &mockAddress{} + addr0.On("EncodeAddress").Return("addr-0") + addr0.On("ScriptAddress").Return([]byte("script-0")) + store.On("DeriveAddr", uint32(0), uint32(0), uint32(0)).Return( + addr0, []byte("script-0"), nil, + ).Once() + + // Expect derivation of 1 -> ErrInvalidChild + store.On("DeriveAddr", uint32(0), uint32(0), uint32(1)).Return( + nil, nil, hdkeychain.ErrInvalidChild, + ).Once() + + // Expect derivation of 2 -> Success + addr2 := &mockAddress{} + addr2.On("EncodeAddress").Return("addr-2") + addr2.On("ScriptAddress").Return([]byte("script-2")) + store.On("DeriveAddr", uint32(0), uint32(0), uint32(2)).Return( + addr2, []byte("script-2"), nil, + ).Once() + + // Expect derivation of 3 -> Success (to fill window) + addr3 := &mockAddress{} + addr3.On("EncodeAddress").Return("addr-3") + addr3.On("ScriptAddress").Return([]byte("script-3")) + store.On("DeriveAddr", uint32(0), uint32(0), uint32(3)).Return( + addr3, []byte("script-3"), nil, + ).Once() + + expanded, err := rs.expandHorizons() + require.NoError(t, err) + require.True(t, expanded) + + // Verify filters contain 0, 2, 3 (3 valid addresses). + require.Len(t, rs.addrFilters, 3) + require.Contains(t, rs.addrFilters, "addr-0") + require.Contains(t, rs.addrFilters, "addr-2") + require.Contains(t, rs.addrFilters, "addr-3") + require.NotContains(t, rs.addrFilters, "addr-1") +} + +// TestInitializeError verifies that Initialize propagates errors from +// initAccountState (e.g. FetchScopedKeyManager failures). +func TestInitializeError(t *testing.T) { + t.Parallel() + + addrMgr := &mockAddrStore{} + defer addrMgr.AssertExpectations(t) + + rs := NewRecoveryState(10, nil, addrMgr) + scope := waddrmgr.KeyScope{Purpose: 84, Coin: 0} + props := &waddrmgr.AccountProperties{KeyScope: scope} + + // Mock failure. + addrMgr.On("FetchScopedKeyManager", scope).Return(nil, errFetch).Once() + + err := rs.Initialize([]*waddrmgr.AccountProperties{props}, nil, nil) + require.ErrorIs(t, err, errFetch) +} + +// TestInitAccountStateDeriveError verifies that initAccountState propagates +// errors from DeriveAddr. +func TestInitAccountStateDeriveError(t *testing.T) { + t.Parallel() + + addrMgr := &mockAddrStore{} + accountStore := &mockAccountStore{} + + defer addrMgr.AssertExpectations(t) + defer accountStore.AssertExpectations(t) + + rs := NewRecoveryState(10, nil, addrMgr) + rs.addrFilters = make(map[string]AddrEntry) + scope := waddrmgr.KeyScope{Purpose: 84, Coin: 0} + props := &waddrmgr.AccountProperties{KeyScope: scope} + + // First call succeeds (External). + addrMgr.On("FetchScopedKeyManager", scope).Return( + accountStore, nil, + ).Once() + + // Derive fails immediately. + accountStore.On( + "DeriveAddr", uint32(0), uint32(0), uint32(0), + ).Return(nil, nil, errFetch).Once() + + err := rs.initAccountState(props) + require.ErrorIs(t, err, errFetch) +} + +// TestExpandHorizonsError verifies that expandHorizons propagates errors from +// DeriveAddr when attempting to extend the lookahead window. +func TestExpandHorizonsError(t *testing.T) { + t.Parallel() + + accountStore := &mockAccountStore{} + defer accountStore.AssertExpectations(t) + + rs := NewRecoveryState(2, nil, nil) + rs.addrFilters = make(map[string]AddrEntry) + bs := waddrmgr.BranchScope{Branch: 0} + brs := NewBranchRecoveryState(2, accountStore) + rs.branchStates[bs] = brs + + // Trigger expansion requirement. + brs.ReportFound(0) + + // Mock DeriveAddr error. + accountStore.On( + "DeriveAddr", uint32(0), uint32(0), uint32(0), + ).Return(nil, nil, errFetch).Once() + + _, err := rs.expandHorizons() + require.ErrorIs(t, err, errFetch) +} + +// TestInitializeWithState verifies Initialize with existing state. +func TestInitializeWithState(t *testing.T) { + t.Parallel() + + addrMgr := &mockAddrStore{} + defer addrMgr.AssertExpectations(t) + + rs := NewRecoveryState(10, nil, addrMgr) + + // Mock address and outpoint. + addr := &mockAddress{} + addr.On("EncodeAddress").Return("addr1") + + outpoint := wtxmgr.Credit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, + PkScript: []byte{1}, + } + + err := rs.Initialize( + nil, []btcutil.Address{addr}, []wtxmgr.Credit{outpoint}, + ) + require.NoError(t, err) + require.Len(t, rs.addrFilters, 1) + require.Len(t, rs.outpoints, 1) +} + +// TestProcessBlockError verifies that ProcessBlock propagates errors from +// expandHorizons. +func TestProcessBlockError(t *testing.T) { + t.Parallel() + + store := &mockAccountStore{} + defer store.AssertExpectations(t) + + rs := NewRecoveryState(10, &chainParams, nil) + rs.addrFilters = make(map[string]AddrEntry) + rs.outpoints = make(map[wire.OutPoint][]byte) + + // Setup branch. + bs := waddrmgr.BranchScope{Branch: 0} + brs := NewBranchRecoveryState(10, store) + rs.branchStates[bs] = brs + + // Add filter entry that triggers expansion (using real address for + // txscript compatibility). + realAddr, _ := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chainParams, + ) + rs.addrFilters[realAddr.EncodeAddress()] = AddrEntry{ + Address: realAddr, + addrScope: waddrmgr.AddrScope{ + BranchScope: bs, Index: 0, + }, + IsLookahead: true, + } + + // Block with tx paying to realAddr. + block := wire.NewMsgBlock(&wire.BlockHeader{}) + tx := wire.NewMsgTx(2) + txOut := wire.NewTxOut(1000, nil) + + var err error + + txOut.PkScript, err = txscript.PayToAddrScript(realAddr) + require.NoError(t, err) + tx.AddTxOut(txOut) + _ = block.AddTransaction(tx) + + // Mock failure. + store.On("DeriveAddr", uint32(0), uint32(0), uint32(0)).Return( + nil, nil, errFetch).Once() + + _, err = rs.ProcessBlock(block) + require.ErrorIs(t, err, errFetch) +} From 38a341068d45c627e19109aa00018857730a7b86 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:40:41 +0800 Subject: [PATCH 252/691] wallet: add database operation helpers for modernization Introduces a suite of atomic database helper methods (db_ops.go) to streamline chain updates and state persistence. --- wallet/common_test.go | 104 ++++++ wallet/db_ops.go | 737 ++++++++++++++++++++++++++++++++++++++++++ wallet/db_ops_test.go | 612 +++++++++++++++++++++++++++++++++++ wallet/syncer.go | 10 + 4 files changed, 1463 insertions(+) create mode 100644 wallet/common_test.go create mode 100644 wallet/db_ops.go create mode 100644 wallet/db_ops_test.go diff --git a/wallet/common_test.go b/wallet/common_test.go new file mode 100644 index 0000000000..e12eb29bbe --- /dev/null +++ b/wallet/common_test.go @@ -0,0 +1,104 @@ +package wallet + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" + "github.com/stretchr/testify/require" +) + +var ( + errDBMock = errors.New("db error") + errMock = errors.New("mock error") +) + +// setupTestDB creates a temporary database for testing. +func setupTestDB(t *testing.T) (walletdb.DB, func()) { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "wallet-test-*.db") + require.NoError(t, err) + + dbPath := f.Name() + require.NoError(t, f.Close()) + require.NoError(t, os.Remove(dbPath)) + + db, err := walletdb.Create("bdb", dbPath, true, time.Second*10, false) + require.NoError(t, err) + + cleanup := func() { + _ = db.Close() + _ = os.Remove(dbPath) + } + + // Create buckets. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + _, err := tx.CreateTopLevelBucket(waddrmgrNamespaceKey) + if err != nil { + return err + } + + _, err = tx.CreateTopLevelBucket(wtxmgrNamespaceKey) + + return err + }) + require.NoError(t, err) + + return db, cleanup +} + +// mockWalletDeps holds the mocked dependencies for the Wallet. +type mockWalletDeps struct { + addrStore *mockAddrStore + txStore *mockTxStore + syncer *mockChainSyncer + chain *mockChain +} + +// createTestWalletWithMocks creates a Wallet instance with mocked +// dependencies. It returns the wallet and the struct holding the mocks for +// assertion. +func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { + t.Helper() + + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockSyncer := &mockChainSyncer{} + mockChain := &mockChain{} + + w := &Wallet{ + addrStore: mockAddrStore, + txStore: mockTxStore, + sync: mockSyncer, + state: newWalletState(mockSyncer), + cfg: Config{ + DB: db, + Chain: mockChain, + ChainParams: &chaincfg.MainNetParams, + }, + } + + deps := &mockWalletDeps{ + addrStore: mockAddrStore, + txStore: mockTxStore, + syncer: mockSyncer, + chain: mockChain, + } + + t.Cleanup(func() { + mockAddrStore.AssertExpectations(t) + mockTxStore.AssertExpectations(t) + mockSyncer.AssertExpectations(t) + mockChain.AssertExpectations(t) + }) + + return w, deps +} diff --git a/wallet/db_ops.go b/wallet/db_ops.go new file mode 100644 index 0000000000..b30660bec3 --- /dev/null +++ b/wallet/db_ops.go @@ -0,0 +1,737 @@ +// Package wallet provides the implementation of a Bitcoin wallet. +// +// TODO(yy): This file will be removed once the Store implementation is +// finished. +package wallet + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" +) + +// DBGetBirthdayBlock retrieves the current birthday block from the database. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `GetWallet` to get the birthday info. +func (w *Wallet) DBGetBirthdayBlock(_ context.Context) (waddrmgr.BlockStamp, + bool, error) { + + var ( + birthdayBlock waddrmgr.BlockStamp + verified bool + ) + + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + var err error + + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + birthdayBlock, verified, err = w.addrStore.BirthdayBlock(ns) + if err != nil { + return fmt.Errorf("get birthday block: %w", err) + } + + return nil + }) + if err != nil { + return waddrmgr.BlockStamp{}, false, fmt.Errorf("view: %w", err) + } + + return birthdayBlock, verified, nil +} + +// DBPutBirthdayBlock updates the wallet's birthday block in the database +// and marks it as verified. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `UpdateWallet` to set the birthday info. +func (w *Wallet) DBPutBirthdayBlock(_ context.Context, + block waddrmgr.BlockStamp) error { + + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + err := w.addrStore.SetBirthdayBlock(ns, block, true) + if err != nil { + return fmt.Errorf("set birthday block: %w", err) + } + + return w.addrStore.SetSyncedTo(ns, &block) + }) + if err != nil { + return fmt.Errorf("update: %w", err) + } + + return nil +} + +// DBDeleteExpiredLockedOutputs removes any expired output locks from the +// transaction store. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `UpdateUTXOs` instead. +func (w *Wallet) DBDeleteExpiredLockedOutputs(_ context.Context) error { + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + return w.txStore.DeleteExpiredLockedOutputs(txmgrNs) + }) + if err != nil { + return fmt.Errorf("cleanup expired locks: %w", err) + } + + return nil +} + +// DBUnlock attempts to unlock the wallet's address manager with the provided +// passphrase. +// +// TODO(yy): Refactor this in the `Store` implementation - the only db +// operation needed is to load the account info and derive the private keys. +func (w *Wallet) DBUnlock(_ context.Context, passphrase []byte) error { + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + return w.addrStore.Unlock(addrmgrNs, passphrase) + }) + if err != nil { + return fmt.Errorf("view: %w", err) + } + + return nil +} + +// DBPutPassphrase updates the wallet's public or private passphrases. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `UpdateWallet` instead. +func (w *Wallet) DBPutPassphrase(_ context.Context, + req ChangePassphraseRequest) error { + + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + if req.ChangePublic { + err := w.addrStore.ChangePassphrase( + addrmgrNs, req.PublicOld, req.PublicNew, + false, &waddrmgr.DefaultScryptOptions, + ) + if err != nil { + return fmt.Errorf("change public passphrase: "+ + "%w", err) + } + } + + if req.ChangePrivate { + err := w.addrStore.ChangePassphrase( + addrmgrNs, req.PrivateOld, + req.PrivateNew, true, + &waddrmgr.DefaultScryptOptions, + ) + if err != nil { + return fmt.Errorf("change private passphrase: "+ + "%w", err) + } + } + + return nil + }) + if err != nil { + return fmt.Errorf("update: %w", err) + } + + return nil +} + +// DBGetAllAccounts ensures all account properties are loaded into the address +// manager's cache. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `ListAccounts` instead, without the balance info. +func (w *Wallet) DBGetAllAccounts(_ context.Context) error { + scopes := w.addrStore.ActiveScopedKeyManagers() + + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + for _, scopedMgr := range scopes { + lastAccount, err := scopedMgr.LastAccount(addrmgrNs) + if err != nil { + if waddrmgr.IsError( + err, waddrmgr.ErrAccountNotFound, + ) { + + continue + } + + return fmt.Errorf("last account: %w", err) + } + + for i := uint32(0); i <= lastAccount; i++ { + _, err := scopedMgr.AccountProperties( + addrmgrNs, i, + ) + if err != nil { + return fmt.Errorf("account: %w", err) + } + } + } + + return nil + }) + if err != nil { + return fmt.Errorf("load all accounts: %w", err) + } + + return nil +} + +// DBGetUnminedTxns retrieves all transactions currently held in the +// wallet's unmined (mempool) store. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `ListTxns` instead. +func (s *syncer) DBGetUnminedTxns(_ context.Context) ([]*wire.MsgTx, error) { + var txs []*wire.MsgTx + + err := walletdb.View( + s.cfg.DB, func(tx walletdb.ReadTx) error { + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + + var err error + + txs, err = s.txStore.UnminedTxs(txmgrNs) + if err != nil { + return fmt.Errorf("unmined txs: %w", + err) + } + + return nil + }, + ) + if err != nil { + return nil, fmt.Errorf("view: %w", err) + } + + return txs, nil +} + +// DBPutBlocks atomically processes a filtered block connected notification +// by inserting relevant transactions and updating the sync tip. +// +// NOTE: This method is used for notifications (not scans). It performs an +// extra step to resolve address scopes (via putRelevantTxns) before +// committing, as notification data does not include scope information. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `UpdateWallet` instead. +func (s *syncer) DBPutBlocks(ctx context.Context, + matches TxEntries, block *wtxmgr.BlockMeta) error { + + err := walletdb.Update(s.cfg.DB, func(tx walletdb.ReadWriteTx) error { + if len(matches) > 0 { + err := s.putRelevantTxns( + ctx, tx, matches, block, + ) + if err != nil { + return err + } + } + + return s.putSyncTip(ctx, tx, *block) + }) + if err != nil { + return fmt.Errorf("process filtered block: %w", err) + } + + return nil +} + +// DBPutTxns parses a batch of relevant transactions, identifies their +// relevant outputs, and commits them to the database. +// +// NOTE: This method is used for notifications (not scans). It performs an +// extra step to resolve address scopes (via putRelevantTxns) before +// committing, as notification data does not include scope information. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `UpdateUTXOs` instead. +func (s *syncer) DBPutTxns(ctx context.Context, matches TxEntries, + block *wtxmgr.BlockMeta) error { + + err := walletdb.Update(s.cfg.DB, func(tx walletdb.ReadWriteTx) error { + return s.putRelevantTxns(ctx, tx, matches, block) + }) + if err != nil { + return fmt.Errorf("process txns: %w", err) + } + + return nil +} + +// DBGetScanData retrieves all necessary data from the database to initialize +// the recovery state. This includes account horizons, active addresses, and +// unspent outputs to watch. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `ListUTXOx+ListAddress` instead, or build a dedicated sql query. +func (s *syncer) DBGetScanData(_ context.Context, + targets []waddrmgr.AccountScope) ([]*waddrmgr.AccountProperties, + []btcutil.Address, []wtxmgr.Credit, error) { + + var ( + horizonData []*waddrmgr.AccountProperties + initialAddrs []btcutil.Address + initialUnspent []wtxmgr.Credit + ) + + // Perform all database reads in a single read-only transaction. + // + // TODO(yy): Refactor to build a single SQL query for these data + // fetches instead of multiple smaller operations within the + // transaction. + // + // NOTE: RecoveryState initialization and mutation are intentionally + // kept outside this transaction to strictly separate database I/O from + // in-memory state management. + err := walletdb.View(s.cfg.DB, func(dbtx walletdb.ReadTx) error { + addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + // 1. Collect Horizons. + for _, target := range targets { + scopedMgr, err := s.addrStore.FetchScopedKeyManager( + target.Scope, + ) + if err != nil { + return fmt.Errorf("fetch scoped manager: %w", + err) + } + + props, err := scopedMgr.AccountProperties( + addrmgrNs, target.Account, + ) + if err != nil { + return fmt.Errorf("account properties: %w", err) + } + + horizonData = append(horizonData, props) + } + + // 2. Load Active Addresses. + err := s.addrStore.ForEachRelevantActiveAddress( + addrmgrNs, func(addr btcutil.Address) error { + initialAddrs = append(initialAddrs, addr) + return nil + }, + ) + if err != nil { + return fmt.Errorf("for each relevant address: %w", err) + } + + // 3. Load UTXOs. + initialUnspent, err = s.txStore.OutputsToWatch(txmgrNs) + if err != nil { + return fmt.Errorf("outputs to watch: %w", err) + } + + return nil + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("load recovery state: %w", err) + } + + return horizonData, initialAddrs, initialUnspent, nil +} + +// DBGetSyncedBlocks retrieves a batch of block hashes from the wallet's +// database for the range [startHeight, endHeight]. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `ListSyncedBlocks` instead on `WalletStore`? +func (s *syncer) DBGetSyncedBlocks(_ context.Context, startHeight, + endHeight int32) ([]*chainhash.Hash, error) { + + var localHashes []*chainhash.Hash + + err := walletdb.View(s.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + count := endHeight - startHeight + 1 + localHashes = make([]*chainhash.Hash, 0, count) + + // We fetch from startHeight to endHeight to match the order + // we'll get from the chain backend (ascending). + for h := startHeight; h <= endHeight; h++ { + hash, err := s.addrStore.BlockHash(addrmgrNs, h) + if err != nil { + return fmt.Errorf("get block hash %d: %w", + h, err) + } + + localHashes = append(localHashes, hash) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("fetch synced block hashes: %w", err) + } + + return localHashes, nil +} + +// DBPutRewind rewinds the wallet state to the specified fork point. +// +// TODO(yy): Refactor this in the `Store` implementation - we need to define a +// new method and build customized query for this. +func (s *syncer) DBPutRewind(_ context.Context, + bs waddrmgr.BlockStamp) error { + + err := walletdb.Update(s.cfg.DB, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) + + err := s.addrStore.SetSyncedTo(addrmgrNs, &bs) + if err != nil { + return fmt.Errorf("set synced to: %w", err) + } + + return s.txStore.Rollback(txmgrNs, bs.Height+1) + }) + if err != nil { + return fmt.Errorf("rollback wallet: %w", err) + } + + return nil +} + +// DBPutSyncBatch updates the database with the results of a batch scan. It +// handles persisting address horizons, transactions, and connecting blocks. +// +// TODO(yy): Refactor this in the `Store` implementation - we need a dedicated +// query for this on `WalletStore`? +func (s *syncer) DBPutSyncBatch(ctx context.Context, + results []scanResult) error { + + // TODO(yy): build a single SQL query for this. + err := walletdb.Update(s.cfg.DB, func(dbtx walletdb.ReadWriteTx) error { + addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + + // 1. Update Address State (Horizons). + err := s.putAddrHorizons(ctx, addrmgrNs, results) + if err != nil { + return err + } + + // 2. Update UTXO State (Transactions). + err = s.putScanTxns(ctx, dbtx, results) + if err != nil { + return err + } + + // 3. Connect Blocks. + // We must process blocks in order and connect each one to + // ensure the address manager's block index remains contiguous. + // + // TODO(yy): This is inefficient as it performs a DB + // write/check for each block. Implement a batch write method + // in waddrmgr (or wait for SQL migration) to validate and + // insert the entire chain segment at once. + for _, res := range results { + err = s.putSyncTip(ctx, dbtx, *res.meta) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + return fmt.Errorf("process scan batch: %w", err) + } + + return nil +} + +// DBPutTargetedBatch updates the database with the results of a targeted +// rescan. It persists address horizons and transactions but does NOT connect +// blocks or update the wallet's synced tip. +// +// TODO(yy): Refactor this in the `Store` implementation - we need a dedicated +// query for this on `WalletStore`? +func (s *syncer) DBPutTargetedBatch(ctx context.Context, + results []scanResult) error { + + err := walletdb.Update(s.cfg.DB, func(dbtx walletdb.ReadWriteTx) error { + addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + + // 1. Update Address State (Horizons). + err := s.putAddrHorizons(ctx, addrmgrNs, results) + if err != nil { + return err + } + + // 2. Update UTXO State (Transactions). + err = s.putScanTxns(ctx, dbtx, results) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return fmt.Errorf("process rescan batch: %w", err) + } + + return nil +} + +// DBPutSyncTip handles a chain server notification by marking a wallet +// that's currently in-sync with the chain server as being synced up to the +// passed block. +// +// TODO(yy): Refactor this in the `Store` implementation - we can call +// `UpdateWallet` instead. +func (s *syncer) DBPutSyncTip(ctx context.Context, + b wtxmgr.BlockMeta) error { + + err := walletdb.Update(s.cfg.DB, func(tx walletdb.ReadWriteTx) error { + return s.putSyncTip(ctx, tx, b) + }) + if err != nil { + return fmt.Errorf("commit sync tip: %w", err) + } + + return nil +} + +// putRelevantTxns identifies the branch scopes for a batch of relevant +// transactions received from notifications (not scans), resolves them, and +// commits them to the database. +func (s *syncer) putRelevantTxns(ctx context.Context, + dbtx walletdb.ReadWriteTx, matches TxEntries, + block *wtxmgr.BlockMeta) error { + + // 1. Resolution: Resolve scopes and finalize entries. + err := s.resolveTxMatches(ctx, dbtx, matches) + if err != nil { + return err + } + + // 2. Commit: Insert each transaction with its resolved credits. + return s.putTxns(ctx, dbtx, matches, block) +} + +// resolveTxMatches identifies the branch scopes for a batch of pre-extracted +// transactions and address entries, filtering out invalid ones. +func (s *syncer) resolveTxMatches(ctx context.Context, + dbtx walletdb.ReadTx, matches TxEntries) error { + + // 1. Resolution: Resolve scopes for all unique addresses. + scopeMap, err := s.filterBranchScopes(ctx, dbtx, matches) + if err != nil { + return err + } + + // 2. Construction: Finalize entries by applying resolved scopes. + for i := range matches { + match := &matches[i] + + valid := make([]AddrEntry, 0, len(match.Entries)) + for _, entry := range match.Entries { + scope, ok := scopeMap[entry.Address.String()] + if !ok { + continue + } + + entry.Credit.Change = scope.Branch == + waddrmgr.InternalBranch + + valid = append(valid, entry) + } + + match.Entries = valid + } + + return nil +} + +// putSyncTip handles a chain server notification by marking a wallet that's +// currently in-sync with the chain server as being synced up to the passed +// block. +func (s *syncer) putSyncTip(_ context.Context, + dbtx walletdb.ReadWriteTx, b wtxmgr.BlockMeta) error { + + addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + bs := waddrmgr.BlockStamp{ + Height: b.Height, + Hash: b.Hash, + Timestamp: b.Time, + } + + err := s.addrStore.SetSyncedTo(addrmgrNs, &bs) + if err != nil { + return fmt.Errorf("failed to set synced to: %w", err) + } + + return nil +} + +// filterBranchScopes retrieves the branch scope for a given set of address +// entries. It returns a map where the key is the address string and the value +// is the corresponding branch scope. +func (s *syncer) filterBranchScopes(_ context.Context, dbtx walletdb.ReadTx, + matches TxEntries) (map[string]waddrmgr.BranchScope, error) { + + ns := dbtx.ReadBucket(waddrmgrNamespaceKey) + + // Deduplicate addresses from the input entries to minimize expensive + // database lookups for transactions with multiple outputs to the same + // address. + uniqueAddrs := make(map[string]btcutil.Address) + for _, match := range matches { + for _, entry := range match.Entries { + uniqueAddrs[entry.Address.String()] = entry.Address + } + } + + // Resolve the branch scope (Scope, Account, Branch) for each unique + // address. Addresses not found in the manager are skipped. + scopes := make(map[string]waddrmgr.BranchScope, len(uniqueAddrs)) + for addrStr, addr := range uniqueAddrs { + ma, err := s.addrStore.Address(ns, addr) + if err != nil { + if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { + continue + } + + return nil, fmt.Errorf("get address info: %w", err) + } + + scopedManager, account, err := s.addrStore.AddrAccount(ns, addr) + if err != nil { + return nil, fmt.Errorf("get addr account: %w", err) + } + + branch := waddrmgr.ExternalBranch + if ma.Internal() { + branch = waddrmgr.InternalBranch + } + + scopes[addrStr] = waddrmgr.BranchScope{ + Scope: scopedManager.Scope(), + Account: account, + Branch: branch, + } + } + + return scopes, nil +} + +// putAddrHorizons aggregates found address horizons from the scan +// results and updates the address manager state (extends horizons) in the +// database. +func (s *syncer) putAddrHorizons(_ context.Context, + ns walletdb.ReadWriteBucket, results []scanResult) error { + + // Aggregate Horizon Expansion. + batchHorizons := make(map[waddrmgr.BranchScope]uint32) + for _, res := range results { + for bs, idx := range res.FoundHorizons { + if current, ok := batchHorizons[bs]; !ok || + idx > current { + + batchHorizons[bs] = idx + } + } + } + + if len(batchHorizons) == 0 { + return nil + } + // Update the database. + for bs, maxFoundIndex := range batchHorizons { + scopedMgr, err := s.addrStore.FetchScopedKeyManager(bs.Scope) + if err != nil { + return fmt.Errorf("fetch scoped manager: %w", err) + } + + err = scopedMgr.ExtendAddresses( + ns, bs.Account, maxFoundIndex, bs.Branch, + ) + if err != nil { + return fmt.Errorf("extend addresses: %w", err) + } + } + + return nil +} + +// putScanTxns processes relevant transactions found during the scan +// and inserts them into the transaction store (and address manager for usage). +func (s *syncer) putScanTxns(ctx context.Context, + dbtx walletdb.ReadWriteTx, results []scanResult) error { + + for _, result := range results { + matches := result.RelevantOutputs + + // The RelevantTxs in scanResult are *btcutil.Tx. We need to + // ensure the TxEntries have the correct *wtxmgr.TxRecord. + for i := range matches { + matches[i].Rec.Received = result.meta.Time + } + + err := s.putTxns(ctx, dbtx, matches, result.meta) + if err != nil { + return err + } + } + + return nil +} + +// putTxns inserts relevant transactions and their credits into the wallet +// using pre-matched output data. +func (s *syncer) putTxns(_ context.Context, dbtx walletdb.ReadWriteTx, + matches TxEntries, block *wtxmgr.BlockMeta) error { + + txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) + addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + + for _, match := range matches { + rec := match.Rec + entries := match.Entries + + credits := make([]wtxmgr.CreditEntry, 0, len(entries)) + for _, entry := range entries { + credits = append(credits, entry.Credit) + + err := s.addrStore.MarkUsed(addrmgrNs, entry.Address) + if err != nil { + return fmt.Errorf("mark used: %w", err) + } + } + + var err error + if block != nil { + err = s.txStore.InsertConfirmedTx( + txmgrNs, rec, block, credits, + ) + } else { + err = s.txStore.InsertUnconfirmedTx( + txmgrNs, rec, credits, + ) + } + + if err != nil { + return fmt.Errorf("insert tx: %w", err) + } + } + + return nil +} diff --git a/wallet/db_ops_test.go b/wallet/db_ops_test.go new file mode 100644 index 0000000000..9e0bba9da9 --- /dev/null +++ b/wallet/db_ops_test.go @@ -0,0 +1,612 @@ +package wallet + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestDBBirthdayBlock verifies that the wallet can successfully persist and +// retrieve its birthday block information. +func TestDBBirthdayBlock(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet with mocked underlying stores. + w, mocks := createTestWalletWithMocks(t) + + block := waddrmgr.BlockStamp{ + Height: 100, + Hash: chainhash.Hash{0x01}, + Timestamp: time.Unix(1000, 0), + } + + // 1. Test DBPutBirthdayBlock. + // + // Arrange: Setup the expected mock calls for updating the birthday + // block and the sync tip in the address manager. + mocks.addrStore.On( + "SetBirthdayBlock", mock.Anything, block, true, + ).Return(nil).Once() + mocks.addrStore.On( + "SetSyncedTo", mock.Anything, &block, + ).Return(nil).Once() + + // Act: Persist the birthday block to the database. + err := w.DBPutBirthdayBlock(t.Context(), block) + + // Assert: Ensure the update completed without error. + require.NoError(t, err) + + // 2. Test DBGetBirthdayBlock. + // + // Arrange: Setup the expected mock call for retrieving the birthday + // block from the address manager. + mocks.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(block, true, nil).Once() + + // Act: Retrieve the persisted birthday block from the database. + retBlock, verified, err := w.DBGetBirthdayBlock(t.Context()) + + // Assert: Verify the retrieved block data and verification status + // matches what was persisted. + require.NoError(t, err) + require.True(t, verified) + require.Equal(t, block, retBlock) +} + +// TestDBUnlock verifies that the wallet can successfully unlock its address +// manager using the provided passphrase. +func TestDBUnlock(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and setup the expected mock call for + // unlocking the address manager. + w, mocks := createTestWalletWithMocks(t) + pass := []byte("password") + + mocks.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() + + // Act: Attempt to unlock the wallet with the passphrase. + err := w.DBUnlock(t.Context(), pass) + + // Assert: Verify that the unlock operation succeeded. + require.NoError(t, err) +} + +// TestDBDeleteExpiredLockedOutputs verifies that the wallet successfully +// invokes the transaction store to remove any expired output locks. +func TestDBDeleteExpiredLockedOutputs(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and setup the expected mock call for + // deleting expired locked outputs. + w, mocks := createTestWalletWithMocks(t) + + mocks.txStore.On( + "DeleteExpiredLockedOutputs", mock.Anything, + ).Return(nil).Once() + + // Act: Trigger the cleanup of expired locked outputs in the database. + err := w.DBDeleteExpiredLockedOutputs(t.Context()) + + // Assert: Verify that the cleanup operation finished without error. + require.NoError(t, err) +} + +// TestDBPutPassphrase verifies that the wallet can successfully update both +// its public and private passphrases in the address manager. +func TestDBPutPassphrase(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and a request to change both + // passphrases. + w, mocks := createTestWalletWithMocks(t) + + req := ChangePassphraseRequest{ + ChangePublic: true, + PublicOld: []byte("old"), + PublicNew: []byte("new"), + ChangePrivate: true, + PrivateOld: []byte("old_priv"), + PrivateNew: []byte("new_priv"), + } + + // Setup mock calls for both passphrase changes. + mocks.addrStore.On( + "ChangePassphrase", mock.Anything, []byte("old"), []byte("new"), + false, mock.Anything, + ).Return(nil).Once() + + mocks.addrStore.On( + "ChangePassphrase", mock.Anything, req.PrivateOld, + req.PrivateNew, true, + mock.MatchedBy(func(opts *waddrmgr.ScryptOptions) bool { + return opts.N == 16 && opts.R == 8 && opts.P == 1 + }), + ).Return(nil).Once() + + // Act: Commit the passphrase changes to the database. + err := w.DBPutPassphrase(t.Context(), req) + + // Assert: Verify that both passphrases were updated successfully. + require.NoError(t, err) +} + +// TestDBPutPassphrase_Error verifies that DBPutPassphrase correctly handles +// and returns errors encountered during the passphrase update process. +func TestDBPutPassphrase_Error(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and setup a mock call that simulates + // a database error during a private passphrase change. + w, mocks := createTestWalletWithMocks(t) + + req := ChangePassphraseRequest{ + ChangePrivate: true, + } + + mocks.addrStore.On( + "ChangePassphrase", mock.Anything, mock.Anything, + mock.Anything, true, mock.Anything, + ).Return(errDBMock).Once() + + // Act: Attempt to change the passphrase, expecting a failure. + err := w.DBPutPassphrase(t.Context(), req) + + // Assert: Verify that the expected database error is returned. + require.ErrorContains(t, err, "db error") +} + +// TestDBPutBlocks_Error verifies that DBPutBlocks correctly handles errors +// that occur during the transaction matching resolution phase. +func TestDBPutBlocks_Error(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer with mocked stores and setup a scenario + // where address lookup fails during transaction resolution. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + addr, _ := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chaincfg.MainNetParams, + ) + matches := TxEntries{{Entries: []AddrEntry{{Address: addr}}}} + + mocks.addrStore.On("Address", mock.Anything, addr).Return( + nil, errDBMock, + ).Once() + + // Act: Attempt to process a block with relevant transactions. + err := s.DBPutBlocks(t.Context(), matches, nil) + + // Assert: Verify that the address lookup error is correctly + // propagated. + require.ErrorContains(t, err, "db error") +} + +// TestDBPutSyncBatch_Error verifies that DBPutSyncBatch correctly propagates +// errors encountered when fetching scoped key managers for horizon updates. +func TestDBPutSyncBatch_Error(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and a scan result that requires updating an + // address manager's horizon. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + res := scanResult{ + meta: &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 100}, + }, + BlockProcessResult: &BlockProcessResult{ + FoundHorizons: map[waddrmgr.BranchScope]uint32{ + { + Scope: waddrmgr.KeyScopeBIP0084, + }: 5, + }, + }, + } + + // Simulate a failure when fetching the scoped key manager. + mocks.addrStore.On( + "FetchScopedKeyManager", waddrmgr.KeyScopeBIP0084, + ).Return(nil, errMock).Once() + + // Act: Attempt to commit a batch of scan results to the database. + err := s.DBPutSyncBatch(t.Context(), []scanResult{res}) + + // Assert: Verify that the expected error is returned. + require.ErrorIs(t, err, errMock) +} + +// TestDBPutBlocks verifies the full lifecycle of DBPutBlocks, including +// resolving transaction scopes, marking addresses as used, inserting confirmed +// transactions, and updating the sync tip. +func TestDBPutBlocks(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and setup test data for a confirmed + // transaction. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + tx := wire.NewMsgTx(1) + rec, _ := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + addr, _ := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chaincfg.MainNetParams, + ) + + matches := TxEntries{{ + Rec: rec, + Entries: []AddrEntry{{ + Address: addr, + }}, + }} + + block := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 100}, + } + + // 1. Transaction Resolution. + // + // Setup mocks to resolve the address scope for the transaction output. + mockAddr := &mockManagedPubKeyAddr{} + mockAddr.On("Internal").Return(false).Once() + mocks.addrStore.On("Address", mock.Anything, addr).Return( + mockAddr, nil, + ).Once() + + scopedMgr := &mockAccountStore{} + scopedMgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + mocks.addrStore.On("AddrAccount", mock.Anything, addr).Return( + scopedMgr, uint32(0), nil, + ).Once() + + // 2. Transaction Insertion. + // + // Expect the address to be marked as used and the transaction to be + // inserted as confirmed. + mocks.addrStore.On("MarkUsed", mock.Anything, addr).Return(nil).Once() + mocks.txStore.On("InsertConfirmedTx", mock.Anything, rec, block, + mock.Anything, + ).Return(nil).Once() + + // 3. Sync Tip Update. + // + // Expect the wallet's sync tip to be updated to the new block. + mocks.addrStore.On("SetSyncedTo", mock.Anything, mock.Anything).Return( + nil, + ).Once() + + // Act: Process the block and its relevant transactions. + err := s.DBPutBlocks(t.Context(), matches, block) + + // Assert: Verify that all operations completed successfully. + require.NoError(t, err) +} + +// TestDBPutTxns verifies that DBPutTxns can successfully resolve and persist +// unconfirmed transactions in the wallet's transaction store. +func TestDBPutTxns(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and setup test data for an unconfirmed + // transaction. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + tx := wire.NewMsgTx(1) + rec, _ := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + addr, _ := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chaincfg.MainNetParams, + ) + + matches := TxEntries{{ + Rec: rec, + Entries: []AddrEntry{{ + Address: addr, + }}, + }} + + // Setup mock calls to resolve the address scope and persist the + // unconfirmed transaction. + mockAddr := &mockManagedPubKeyAddr{} + mockAddr.On("Internal").Return(false).Once() + mocks.addrStore.On("Address", mock.Anything, addr).Return( + mockAddr, nil, + ).Once() + + scopedMgr := &mockAccountStore{} + scopedMgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + mocks.addrStore.On("AddrAccount", mock.Anything, addr).Return( + scopedMgr, uint32(0), nil, + ).Once() + + mocks.addrStore.On("MarkUsed", mock.Anything, addr).Return(nil).Once() + mocks.txStore.On("InsertUnconfirmedTx", mock.Anything, rec, + mock.Anything, + ).Return(nil).Once() + + // Act: Attempt to persist the unconfirmed transaction. + err := s.DBPutTxns(t.Context(), matches, nil) + + // Assert: Verify that the transaction was persisted successfully. + require.NoError(t, err) +} + +// TestPutAddrHorizons verifies that address horizons are correctly extended +// in the database based on scan results. +func TestPutAddrHorizons(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and setup a scan result that indicates a + // horizon expansion is needed for a specific BIP84 account. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + bs := waddrmgr.BranchScope{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: 0, + Branch: waddrmgr.ExternalBranch, + } + + res := []scanResult{{ + BlockProcessResult: &BlockProcessResult{ + FoundHorizons: map[waddrmgr.BranchScope]uint32{ + bs: 10, + }, + }, + }} + + // Setup mock calls for fetching the manager and extending the + // addresses. + scopedMgr := &mockAccountStore{} + mocks.addrStore.On("FetchScopedKeyManager", bs.Scope).Return( + scopedMgr, nil, + ).Once() + + scopedMgr.On("ExtendAddresses", mock.Anything, bs.Account, uint32(10), + bs.Branch, + ).Return(nil).Once() + + // Act: Trigger the horizon expansion within a database transaction. + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + return s.putAddrHorizons(t.Context(), ns, res) + }) + + // Assert: Verify that the horizons were extended without error. + require.NoError(t, err) +} + +// TestDBGetScanData verifies that the wallet can successfully retrieve all +// necessary state (horizons, active addresses, and UTXOs) to initialize a +// chain rescan. +func TestDBGetScanData(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and setup mock expectations for all data + // required during rescan initialization. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + targets := []waddrmgr.AccountScope{{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: 0, + }} + + // 1. Horizons lookup. + scopedMgr := &mockAccountStore{} + mocks.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0084, + ).Return(scopedMgr, nil).Once() + + props := &waddrmgr.AccountProperties{AccountNumber: 0} + scopedMgr.On("AccountProperties", mock.Anything, uint32(0)).Return( + props, nil, + ).Once() + + // 2. Active addresses lookup. + mocks.addrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything, + ).Return(nil).Once() + + // 3. UTXO lookup. + mocks.txStore.On("OutputsToWatch", mock.Anything).Return( + []wtxmgr.Credit(nil), nil, + ).Once() + + // Act: Retrieve the initial scan data from the database. + horizonData, initialAddrs, initialUnspent, err := s.DBGetScanData( + t.Context(), targets, + ) + + // Assert: Verify that the retrieved data matches our expectations and + // that no error occurred. + require.NoError(t, err) + require.Len(t, horizonData, 1) + require.Equal(t, props, horizonData[0]) + require.Empty(t, initialAddrs) + require.Empty(t, initialUnspent) +} + +// TestDBGetSyncedBlocks verifies that the wallet can successfully retrieve a +// range of block hashes from its internal index. +func TestDBGetSyncedBlocks(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and setup a mock expectation for fetching a + // block hash from the address manager. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + hash := chainhash.Hash{0x01} + mocks.addrStore.On("BlockHash", mock.Anything, int32(100)).Return( + &hash, nil, + ).Once() + + // Act: Fetch the block hashes for the requested range. + hashes, err := s.DBGetSyncedBlocks(t.Context(), 100, 100) + + // Assert: Verify that the retrieved hash is correct. + require.NoError(t, err) + require.Len(t, hashes, 1) + require.Equal(t, &hash, hashes[0]) +} + +// TestDBPutRewind verifies that the wallet can successfully rewind its +// synchronized state and transaction history to a specific point. +func TestDBPutRewind(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and setup mock expectations for updating + // the sync tip and rolling back the transaction store. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + bs := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} + + mocks.addrStore.On("SetSyncedTo", mock.Anything, &bs).Return(nil).Once() + mocks.txStore.On("Rollback", + mock.Anything, int32(101), + ).Return(nil).Once() + + // Act: Rewind the wallet state to the specified block height. + err := s.DBPutRewind(t.Context(), bs) + + // Assert: Verify that the rewind operation succeeded. + require.NoError(t, err) +} + +// TestDBPutBirthdayBlock_Error verifies that DBPutBirthdayBlock correctly +// handles and returns database errors during persistence. +func TestDBPutBirthdayBlock_Error(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and setup a mock call that simulates a + // failure when setting the birthday block. + w, mocks := createTestWalletWithMocks(t) + + bs := waddrmgr.BlockStamp{Height: 100} + + mocks.addrStore.On("SetBirthdayBlock", mock.Anything, bs, true).Return( + errDBMock, + ).Once() + + // Act: Attempt to persist the birthday block, expecting a failure. + err := w.DBPutBirthdayBlock(t.Context(), bs) + + // Assert: Verify that the database error is correctly propagated. + require.ErrorContains(t, err, "db error") +} + +// TestDBGetAllAccounts_Error verifies that DBGetAllAccounts correctly +// handles and returns database errors encountered while iterating over +// accounts. +func TestDBGetAllAccounts_Error(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and setup a mock call that simulates a + // failure while querying for the last account index. + w, mocks := createTestWalletWithMocks(t) + scopedMgr := &mockAccountStore{} + + mocks.addrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore{scopedMgr}, + ).Once() + + scopedMgr.On("LastAccount", mock.Anything).Return( + uint32(0), errDBMock, + ).Once() + + // Act: Attempt to load all account properties. + err := w.DBGetAllAccounts(t.Context()) + + // Assert: Verify that the expected error is returned. + require.ErrorContains(t, err, "db error") +} + +// TestDBGetScanData_MultipleTargets verifies that DBGetScanData correctly +// aggregates horizon data when multiple account scopes are requested. +func TestDBGetScanData_MultipleTargets(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and setup test data for multiple accounts + // across different scopes. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + targets := []waddrmgr.AccountScope{ + {Scope: waddrmgr.KeyScopeBIP0084, Account: 0}, + {Scope: waddrmgr.KeyScopeBIP0049Plus, Account: 1}, + } + + // Setup mock calls to handle property retrieval for both targets. + scopedMgr := &mockAccountStore{} + mocks.addrStore.On("FetchScopedKeyManager", mock.Anything).Return( + scopedMgr, nil, + ).Twice() + + scopedMgr.On("AccountProperties", mock.Anything, mock.Anything).Return( + &waddrmgr.AccountProperties{}, nil, + ).Twice() + + mocks.addrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything, + ).Return(nil).Once() + + mocks.txStore.On("OutputsToWatch", mock.Anything).Return( + []wtxmgr.Credit(nil), nil, + ).Once() + + // Act: Retrieve initial scan data for all requested targets. + horizons, _, _, err := s.DBGetScanData(t.Context(), targets) + + // Assert: Verify that data for both targets was successfully + // collected. + require.NoError(t, err) + require.Len(t, horizons, 2) +} + +// TestDBGetScanData_Error verifies that DBGetScanData correctly handles +// and returns database errors during horizon lookup, ensuring no stale +// data is returned. +func TestDBGetScanData_Error(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and setup a mock expectation for a failure + // while fetching a scoped key manager. + w, mocks := createTestWalletWithMocks(t) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + + targets := []waddrmgr.AccountScope{{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: 0, + }} + + mocks.addrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0084, + ).Return(nil, errDBMock).Once() + + // Act: Attempt to retrieve scan data, which is expected to fail. + horizons, addrs, unspent, err := s.DBGetScanData(t.Context(), targets) + + // Assert: Verify that the database error is returned and all returned + // data slices are nil. + require.ErrorContains(t, err, "db error") + require.Nil(t, horizons) + require.Nil(t, addrs) + require.Nil(t, unspent) +} diff --git a/wallet/syncer.go b/wallet/syncer.go index f184e8f638..8fe332d06f 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -80,6 +80,16 @@ type scanReq struct { targets []waddrmgr.AccountScope } +// scanResult holds the result of processing a single block during a batch +// scan. +type scanResult struct { + // BlockProcessResult embeds the results of filtering the block. + *BlockProcessResult + + // meta contains block metadata (hash, height, time). + meta *wtxmgr.BlockMeta +} + // chainSyncer is a private interface that abstracts the chain synchronization // logic, allowing it to be mocked for testing the wallet and controller. type chainSyncer interface { From 00914e013e2d67d24145693aeff3e889276ec1bb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 14 Jan 2026 02:15:40 +0800 Subject: [PATCH 253/691] wallet: remove nolint --- wallet/import_test.go | 1 - wallet/psbt_manager_test.go | 1 - wallet/wallet.go | 2 -- 3 files changed, 4 deletions(-) diff --git a/wallet/import_test.go b/wallet/import_test.go index 17a0701151..1905940a0a 100644 --- a/wallet/import_test.go +++ b/wallet/import_test.go @@ -71,7 +71,6 @@ type testCase struct { } var ( - //nolint:lll testCases = []*testCase{{ name: "bip44 with nested witness address type", masterPriv: "tprv8ZgxMBicQKsPeWwrFuNjEGTTDSY4mRLwd2KDJAPGa1AY" + diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index fa69f23f50..f25fed2216 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -2498,7 +2498,6 @@ func TestAddTaprootSigToPInput(t *testing.T) { LeafHashes: [][]byte{leafHash}, }, // Assert: Expect TaprootScriptSpendSig to be appended. - //nolint:lll expectedPInput: &psbt.PInput{ SighashType: txscript.SigHashDefault, TaprootScriptSpendSig: []*psbt.TaprootScriptSpendSig{{ diff --git a/wallet/wallet.go b/wallet/wallet.go index 2290108575..7c9ed4b1c2 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -280,8 +280,6 @@ func locateBirthdayBlock(chainClient chainConn, // Wallet is a structure containing all the components for a complete wallet. // It manages the cryptographic keys, transaction history, and synchronization // with the blockchain. -// -//nolint:unused // TODO(yy): remove it once implemented type Wallet struct { // walletDeprecated embeds the legacy state and channels. Access to // these should be phased out as refactoring progresses. From c6b832be7e450b699509f613c1b0d36bc6dec48a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 20:39:37 +0800 Subject: [PATCH 254/691] wallet: implement lifecycle control loop and request handling Implements the mainLoop in the Controller to coordinate lifecycle events and serialize request handling. --- wallet/common_test.go | 21 ++++- wallet/controller.go | 189 +++++++++++++++++++++++++++++++++++++ wallet/controller_test.go | 192 ++++++++++++++++++++++++++++++++++++++ wallet/wallet.go | 30 ++++++ 4 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 wallet/controller_test.go diff --git a/wallet/common_test.go b/wallet/common_test.go index e12eb29bbe..8a066d9ef5 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -1,12 +1,14 @@ package wallet import ( + "context" "errors" "os" "testing" "time" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" _ "github.com/btcsuite/btcwallet/walletdb/bdb" "github.com/stretchr/testify/require" @@ -75,10 +77,18 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { mockChain := &mockChain{} w := &Wallet{ - addrStore: mockAddrStore, - txStore: mockTxStore, - sync: mockSyncer, - state: newWalletState(mockSyncer), + addrStore: mockAddrStore, + txStore: mockTxStore, + sync: mockSyncer, + state: newWalletState(mockSyncer), + lifetimeCtx: context.Background(), + cancel: func() {}, + requestChan: make(chan any, 1), + lockTimer: time.NewTimer(time.Hour), + birthdayBlock: waddrmgr.BlockStamp{ + Height: 100, + }, + cfg: Config{ DB: db, Chain: mockChain, @@ -86,6 +96,9 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { }, } + // Stop the timer immediately to avoid leaks. + w.lockTimer.Stop() + deps := &mockWalletDeps{ addrStore: mockAddrStore, txStore: mockTxStore, diff --git a/wallet/controller.go b/wallet/controller.go index d254d77aa1..9cbd2aceaa 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -122,3 +122,192 @@ type Controller interface { Rescan(ctx context.Context, startHeight uint32, targets []waddrmgr.AccountScope) error } + +// mainLoop is the central event loop for the wallet, responsible for +// coordinating and serializing all lifecycle and authentication requests. It +// manages the transition between locked and unlocked states and handles the +// automatic locking of the wallet after a specified duration. +func (w *Wallet) mainLoop() { + defer w.wg.Done() + + for { + select { + case req := <-w.requestChan: + // Process incoming serialized requests. + switch r := req.(type) { + // Perform the unlock. + case unlockReq: + w.handleUnlockReq(r) + + // Perform an explicit lock and stop the timer. + case lockReq: + w.handleLockReq(r) + + // Rotate wallet passphrases. + case changePassphraseReq: + w.handleChangePassphraseReq(r) + + default: + log.Errorf("Wallet received unknown request "+ + "type: %T", req) + } + + // The auto-lock timer has expired. We trigger a lock with a + // dummy response channel to avoid nil checks in the handler. + case <-w.lockTimer.C: + log.Infof("Auto-lock timeout fired, locking wallet") + w.handleLockReq(newLockReq()) + + // The wallet is shutting down. We exit the main loop. + case <-w.lifetimeCtx.Done(): + w.lockTimer.Stop() + + return + } + } +} + +// resultChan is a generic channel for returning errors to callers. +type resultChan chan error + +// unlockReq requests the wallet to be unlocked. +type unlockReq struct { + req UnlockRequest + resp resultChan +} + +// lockReq requests the wallet to be locked. +type lockReq struct { + resp resultChan +} + +// changePassphraseReq requests a change of the wallet's passphrases. +type changePassphraseReq struct { + req ChangePassphraseRequest + resp resultChan +} + +// newUnlockReq creates a new unlock request with a buffered response channel. +// We use this constructor to ensure that the response channel is always +// correctly initialized and buffered, preventing the main loop from blocking +// when reporting the result. +func newUnlockReq(req UnlockRequest) unlockReq { + return unlockReq{ + req: req, + resp: make(resultChan, 1), + } +} + +// newLockReq creates a new lock request with a buffered response channel. +func newLockReq() lockReq { + return lockReq{ + resp: make(resultChan, 1), + } +} + +// newChangePassphraseReq creates a new change passphrase request with a +// buffered response channel. +func newChangePassphraseReq(req ChangePassphraseRequest) changePassphraseReq { + return changePassphraseReq{ + req: req, + resp: make(resultChan, 1), + } +} + +// handleUnlockReq processes an incoming request to unlock the wallet. It +// authenticates the provided passphrase against the database and, on success, +// transitions the wallet to the unlocked state. +func (w *Wallet) handleUnlockReq(req unlockReq) { + // First, validate that the wallet is in a state that allows unlocking. + err := w.state.canUnlock() + if err != nil { + req.resp <- err + return + } + + // Attempt to unlock the underlying address manager. + err = w.DBUnlock(w.lifetimeCtx, req.req.Passphrase) + if err != nil { + req.resp <- err + return + } + + // On success, update the atomic wallet state to reflect that we are + // now unlocked. + w.state.toUnlocked() + + // Handle auto-lock timer. If a timeout is specified, we reset the + // timer to fire in the future. Otherwise, we stop the timer to disable + // auto-locking. + duration := req.req.Timeout + if duration > 0 { + w.lockTimer.Reset(duration) + } else if !w.lockTimer.Stop() { + // If the timer has already fired, we drain its channel to + // prevent a stale signal from being processed by the main + // loop, which would cause an immediate, unexpected lock. + select { + case <-w.lockTimer.C: + default: + } + } + + // Always report the result back to the caller. + req.resp <- nil +} + +// handleLockReq processes an incoming request to lock the wallet. It clears +// any cached private key material from memory and transitions the wallet to +// the locked state. +func (w *Wallet) handleLockReq(req lockReq) { + // First, validate that the wallet is in a state that allows locking. + err := w.state.canLock() + if err != nil { + req.resp <- err + return + } + + // Stop the auto-lock timer since the wallet is now explicitly locked. + if !w.lockTimer.Stop() { + // Drain the channel if the timer has already fired to ensure + // we don't process a stale lock signal in the next iteration. + select { + case <-w.lockTimer.C: + default: + } + } + + // Signal the address manager to lock, clearing sensitive data. + err = w.addrStore.Lock() + if err != nil && !waddrmgr.IsError(err, waddrmgr.ErrLocked) { + log.Errorf("Could not lock wallet: %v", err) + } + + // Even if an error occurred (e.g. already locked), we ensure the + // wallet's high-level state is synchronized to 'locked'. + if err == nil { + w.state.toLocked() + } + + // Report the result back to the caller. + req.resp <- err +} + +// handleChangePassphraseReq processes a request to rotate the wallet's +// passphrases. It can change either the public passphrase, the private +// passphrase, or both in a single atomic database update. +func (w *Wallet) handleChangePassphraseReq(req changePassphraseReq) { + // First, validate that the wallet is in a state that allows changing + // the passphrase. + err := w.state.canChangePassphrase() + if err != nil { + req.resp <- err + return + } + + // Delegate the cryptographic rotation to the database layer. + err = w.DBPutPassphrase(w.lifetimeCtx, req.req) + + // Report the result back to the caller. + req.resp <- err +} diff --git a/wallet/controller_test.go b/wallet/controller_test.go new file mode 100644 index 0000000000..eb91a61f8a --- /dev/null +++ b/wallet/controller_test.go @@ -0,0 +1,192 @@ +package wallet + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestHandleUnlockReq verifies that the handleUnlockReq method correctly +// processes an unlock request by invoking the address manager's Unlock method +// and updating the wallet state. +func TestHandleUnlockReq(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and mock its dependencies. + w, deps := createTestWalletWithMocks(t) + + // Simulate the wallet being in the 'Started' state, which is a + // prerequisite for unlocking. + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + pass := []byte("password") + req := newUnlockReq(UnlockRequest{Passphrase: pass}) + + // Setup the expected call to the address manager's Unlock method. + deps.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() + + // Act: Dispatch the unlock request to the handler. + w.handleUnlockReq(req) + + // Assert: Verify that the response indicates success and the wallet + // state has transitioned to 'Unlocked'. + resp := <-req.resp + require.NoError(t, resp) + require.True(t, w.state.isUnlocked()) +} + +// TestHandleUnlockReq_Errors verifies that handleUnlockReq correctly handles +// error conditions, such as attempting to unlock a stopped wallet or a failure +// from the underlying storage. +func TestHandleUnlockReq_Errors(t *testing.T) { + t.Parallel() + + // 1. ErrStateForbidden (Wallet Locked). + // + // Arrange: Create a test wallet. By default, it is in the 'Stopped' + // state. + w, deps := createTestWalletWithMocks(t) + + pass := []byte("password") + req := newUnlockReq(UnlockRequest{Passphrase: pass}) + + // Act: Attempt to unlock the wallet while it is stopped. + w.handleUnlockReq(req) + + // Assert: Verify that the request fails with ErrStateForbidden. + err := <-req.resp + require.ErrorIs(t, err, ErrStateForbidden) + + // 2. DBUnlock failure. + // + // Arrange: Transition the wallet to 'Started' so the state check + // passes, but setup the address manager to return an error. + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + req = newUnlockReq(UnlockRequest{Passphrase: pass}) + deps.addrStore.On("Unlock", mock.Anything, pass).Return( + errDBMock, + ).Once() + + // Act: Attempt to unlock the wallet again. + w.handleUnlockReq(req) + + // Assert: Verify that the database error is propagated. + err = <-req.resp + require.ErrorContains(t, err, "db error") +} + +// TestHandleLockReq verifies that the handleLockReq method correctly processes +// a lock request by invoking the address manager's Lock method and updating +// the wallet state. +func TestHandleLockReq(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and transition it to 'Started' and + // then 'Unlocked'. + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + w.state.toUnlocked() + + req := newLockReq() + + // Setup the expected call to the address manager's Lock method. + deps.addrStore.On("Lock").Return(nil).Once() + + // Act: Dispatch the lock request to the handler. + w.handleLockReq(req) + + // Assert: Verify that the response indicates success and the wallet + // state is no longer 'Unlocked'. + resp := <-req.resp + require.NoError(t, resp) + require.False(t, w.state.isUnlocked()) +} + +// TestHandleLockReq_Errors verifies that handleLockReq correctly handles error +// conditions, such as attempting to lock a stopped wallet. +func TestHandleLockReq_Errors(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet in the default 'Stopped' state. + w, _ := createTestWalletWithMocks(t) + + req := newLockReq() + + // Act: Attempt to lock the wallet. + w.handleLockReq(req) + + // Assert: Verify that the request fails with ErrStateForbidden. + err := <-req.resp + require.ErrorIs(t, err, ErrStateForbidden) +} + +// TestMainLoop verifies that the wallet's main event loop can start and stop +// correctly in response to context cancellation. +func TestMainLoop(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and setup a cancellable context to + // control the main loop's lifecycle. + w, _ := createTestWalletWithMocks(t) + ctx, cancel := context.WithCancel(t.Context()) + w.lifetimeCtx = ctx + w.cancel = cancel + + var testWg sync.WaitGroup + testWg.Add(1) + w.wg.Add(1) + + // Act: Start the main loop in a background goroutine. + go func() { + defer testWg.Done() + + w.mainLoop() + }() + + // Act: Cancel the context to signal the main loop to exit. + cancel() + + // Assert: Wait for the main loop to exit, ensuring it respects the + // context cancellation. + testWg.Wait() +} + +// TestHandleChangePassphraseReq verifies the change passphrase request handler. +func TestHandleChangePassphraseReq(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and a dummy change passphrase request. + w, deps := createTestWalletWithMocks(t) + + // Transition the wallet to 'Started' so the state check passes. + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + reqStruct := ChangePassphraseRequest{ + ChangePrivate: true, + PrivateOld: []byte("old"), + PrivateNew: []byte("new"), + } + req := newChangePassphraseReq(reqStruct) + + // Setup the expected call to the address manager's ChangePassphrase + // method. + deps.addrStore.On( + "ChangePassphrase", mock.Anything, []byte("old"), + []byte("new"), true, mock.Anything, + ).Return(nil).Once() + + // Act: Call the handler. + w.handleChangePassphraseReq(req) + + // Assert: Verify that the response indicates success. + resp := <-req.resp + require.NoError(t, resp) +} diff --git a/wallet/wallet.go b/wallet/wallet.go index 7c9ed4b1c2..50b2a43a29 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -13,6 +13,7 @@ package wallet import ( + "context" "errors" "fmt" "sync" @@ -313,6 +314,35 @@ type Wallet struct { // Lifecycle (System), Synchronization (Chain), and Authentication // (Security). state walletState + + // lifetimeCtx defines the runtime scope of the wallet. It is created + // when the wallet starts and canceled when it stops, providing a + // standard way to signal shutdown to all context-aware background + // routines. + // + // Storing a context in a struct is generally considered an + // anti-pattern because contexts are usually request-scoped. However, + // for long-lived service objects that manage their own background + // goroutines, maintaining a parent context for those routines is a + // valid exception. + // + //nolint:containedctx + lifetimeCtx context.Context + + // cancel is the cancellation function for lifetimeCtx. + cancel context.CancelFunc + + // requestChan is the central communication channel for incoming + // lifecycle and authentication requests. + requestChan chan any + + // lockTimer is the timer used to automatically lock the wallet after a + // timeout. + lockTimer *time.Timer + + // birthdayBlock is the block from which the wallet started scanning. + // It is loaded on startup and cached to avoid database lookups. + birthdayBlock waddrmgr.BlockStamp } // AccountAddresses returns the addresses for every created address for an From aa76ef62151eefd3d30383be95b02b495cd61bd2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:44:18 +0800 Subject: [PATCH 255/691] wallet: implement Controller `Start` method Implements the Start method, initializing background processes and verifying the wallet's birthday block. --- wallet/controller.go | 158 ++++++++++++++++++++++++++++++++++++++ wallet/controller_test.go | 51 ++++++++++++ 2 files changed, 209 insertions(+) diff --git a/wallet/controller.go b/wallet/controller.go index 9cbd2aceaa..1f103cbcc3 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -123,6 +123,100 @@ type Controller interface { targets []waddrmgr.AccountScope) error } +// Start starts the background processes necessary to manage the wallet. +// +// This is part of the Controller interface. +func (w *Wallet) Start(startCtx context.Context) error { + // 1. Attempt to transition from Stopped to Starting. + err := w.state.toStarting() + if err != nil { + return err + } + + // 2. Setup background resources. + // + // w.lifetimeCtx governs the lifecycle of all background goroutines. + // It is canceled when stop() is called. + w.lifetimeCtx, w.cancel = context.WithCancel(context.Background()) + + // 3. Perform runtime setup. + // + // We use startCtx here because these operations must complete + // synchronously before the wallet is considered "started". If + // startCtx is canceled, the startup sequence aborts. + err = w.performRuntimeSetup(startCtx) + if err != nil { + // Cleanup resources. + w.cancel() + + // Revert state if setup fails. + stopErr := w.state.toStopped() + if stopErr != nil { + log.Warnf("Failed to revert state to stopped: %v", + stopErr) + } + + return err + } + + // 4. Start background goroutines. + w.wg.Add(1) + + go w.mainLoop() + + w.wg.Add(1) + + go func() { + defer w.wg.Done() + + // TODO(yy): build a retry loop. + err := w.sync.run(w.lifetimeCtx) + if err != nil { + log.Errorf("Chain sync loop exited with error: %v", err) + } + }() + + // 5. Mark the wallet as fully started. + err = w.state.toStarted() + if err != nil { + return err + } + + return nil +} + +// performRuntimeSetup executes the synchronous initialization tasks required +// before the wallet's main loops can start. This includes sanity checking the +// birthday block, loading accounts into memory, and cleaning up expired locks. +func (w *Wallet) performRuntimeSetup(startCtx context.Context) error { + // Perform the birthday sanity check synchronously to ensure we are + // connected and our status is valid before starting the main loop. + // + // This also initializes the birthday block cache used by the Info + // method. + err := w.verifyBirthday(startCtx) + if err != nil { + return err + } + + // Ensure all accounts are loaded into memory so we can efficiently + // access them during the scan loop without database lookups. + err = w.DBGetAllAccounts(startCtx) + if err != nil { + return err + } + + // Cleanup any expired output locks. + return w.DBDeleteExpiredLockedOutputs(startCtx) +} + +// deleteExpiredLockedOutputs removes any expired output locks from the +// transaction store. This is typically called on startup to clean up any stale +// state. +func (w *Wallet) deleteExpiredLockedOutputs(ctx context.Context) error { + return w.DBDeleteExpiredLockedOutputs(ctx) +} + // mainLoop is the central event loop for the wallet, responsible for // coordinating and serializing all lifecycle and authentication requests. It // manages the transition between locked and unlocked states and handles the @@ -167,6 +261,70 @@ func (w *Wallet) mainLoop() { } } +// verifyBirthday performs a sanity check on the wallet's birthday block to +// ensure it is set and valid. +// +// Logical Steps: +// 1. Fetch the current birthday block from the database. +// 2. If the block is already verified, initialize the memory cache and +// return. +// 3. If the block is missing or unverified, fetch the wallet's birthday +// timestamp. +// 4. Use the chain backend to locate a suitable block matching the +// birthday timestamp. +// 5. Persist the new birthday block, mark it as verified, and update the +// wallet's sync tip to this point to ensure a clean rescan range. +// 6. Update the memory cache. +func (w *Wallet) verifyBirthday(ctx context.Context) error { + // We'll start by fetching our wallet's birthday block. + birthdayBlock, verified, err := w.DBGetBirthdayBlock(ctx) + if err != nil { + var mgrErr waddrmgr.ManagerError + if !errors.As(err, &mgrErr) || + mgrErr.ErrorCode != waddrmgr.ErrBirthdayBlockNotSet { + + log.Errorf("Unable to sanity check wallet birthday "+ + "block: %v", err) + + return err + } + // If not set, we proceed to locate it. + } + + // If the birthday block has already been verified, we initialize the + // cache and exit our sanity check to avoid redundant lookups. + if verified { + log.Infof("Birthday block verified: height=%d, hash=%v", + birthdayBlock.Height, birthdayBlock.Hash) + w.birthdayBlock = birthdayBlock + + return nil + } + // Otherwise, we'll attempt to locate a better one now that we have + // access to the chain. + timestamp := w.addrStore.Birthday() + + newBirthdayBlock, err := locateBirthdayBlock(w.cfg.Chain, timestamp) + if err != nil { + log.Errorf("Unable to sanity check wallet birthday "+ + "block: %v", err) + + return err + } + + err = w.DBPutBirthdayBlock(ctx, *newBirthdayBlock) + if err != nil { + log.Errorf("Unable to sanity check wallet birthday "+ + "block: %v", err) + + return err + } + + w.birthdayBlock = *newBirthdayBlock + + return nil +} + // resultChan is a generic channel for returning errors to callers. type resultChan chan error diff --git a/wallet/controller_test.go b/wallet/controller_test.go index eb91a61f8a..bcaef2d866 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -5,6 +5,7 @@ import ( "sync" "testing" + "github.com/btcsuite/btcwallet/waddrmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -190,3 +191,53 @@ func TestHandleChangePassphraseReq(t *testing.T) { resp := <-req.resp require.NoError(t, resp) } + +// TestControllerStart verifies that the Start method correctly initializes the +// wallet, verifying the birthday block, loading accounts, cleaning up locks, +// and starting the syncer. +func TestControllerStart(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and mock all dependencies required for + // startup. + w, deps := createTestWalletWithMocks(t) + + // 1. Mock verifyBirthday: Expect a call to retrieve the birthday + // block. + bs := waddrmgr.BlockStamp{Height: 100} + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(bs, true, nil).Once() + + // 2. Mock DBGetAllAccounts: Expect a call to load active account + // managers. + deps.addrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore(nil)).Once() + + // 3. Mock deleteExpiredLockedOutputs: Expect a call to cleanup expired + // locks in the transaction store. + deps.txStore.On( + "DeleteExpiredLockedOutputs", mock.Anything, + ).Return(nil).Once() + + // 4. Mock syncer.run: Expect the syncer to be started. + deps.syncer.On( + "run", mock.Anything, + ).Return(nil).Once() + + // Act: Start the wallet. + err := w.Start(t.Context()) + + // Assert: Verify that Start returned no error and the wallet state is + // 'Started'. + require.NoError(t, err) + require.True(t, w.state.isStarted()) + + // Clean up + if w.cancel != nil { + w.cancel() + } + + w.wg.Wait() +} From c3c2f66d006d11174e6e607174dabcbcc2ec1cfa Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:47:11 +0800 Subject: [PATCH 256/691] wallet: implement Controller `Stop` method Implements the Stop method, ensuring a graceful shutdown of all background routines and context cancellation. --- wallet/controller.go | 52 ++++++++++++++++++++++++++++++++---- wallet/controller_test.go | 56 +++++++++++++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/wallet/controller.go b/wallet/controller.go index 1f103cbcc3..96fb2069f2 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -3,6 +3,7 @@ package wallet import ( "context" "errors" + "fmt" "time" "github.com/btcsuite/btcd/chaincfg" @@ -210,11 +211,52 @@ func (w *Wallet) performRuntimeSetup(startCtx context.Context) error { return w.DBDeleteExpiredLockedOutputs(startCtx) } -// deleteExpiredLockedOutputs removes any expired output locks from the -// transaction store. This is typically called on startup to clean up any stale -// state. -func (w *Wallet) deleteExpiredLockedOutputs(ctx context.Context) error { - return w.DBDeleteExpiredLockedOutputs(ctx) +// Stop signals all wallet background processes to shutdown and blocks until +// they have all exited. It returns an error if the context is canceled before +// the shutdown is complete. +// +// This is part of the Controller interface. +func (w *Wallet) Stop(stopCtx context.Context) error { + // Attempt to transition from Started to Stopping. + err := w.state.toStopping() + if err != nil { + // If the wallet is not started, we can consider it stopped. + log.Warnf("Wallet already stopped: %v", err) + return nil + } + + // Signal all background processes to stop. + // + // It is safe to call w.cancel() here because the successful transition + // to Stopping guarantees that we were previously in the Started state, + // which in turn guarantees that start() has completed initialization + // of w.lifetimeCtx and w.cancel. + // + // Additionally, w.cancel() is idempotent, so it is safe to call even + // if it has effectively already been called (though the state machine + // guarantees we only reach this point once). + w.cancel() + + // Wait for all goroutines to finish. + done := make(chan struct{}) + go func() { + w.wg.Wait() + close(done) + }() + + select { + case <-done: + case <-stopCtx.Done(): + return fmt.Errorf("stop request cancelled: %w", stopCtx.Err()) + } + + // Mark the wallet as stopped. + err = w.state.toStopped() + if err != nil { + return err + } + + return nil } // mainLoop is the central event loop for the wallet, responsible for diff --git a/wallet/controller_test.go b/wallet/controller_test.go index bcaef2d866..b2977bfb66 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -234,10 +234,56 @@ func TestControllerStart(t *testing.T) { require.NoError(t, err) require.True(t, w.state.isStarted()) - // Clean up - if w.cancel != nil { - w.cancel() - } - + // Cleanup: Stop the wallet to release resources. + err = w.Stop(t.Context()) + require.NoError(t, err) w.wg.Wait() } + +// TestControllerStop verifies that the Stop method correctly shuts down the +// wallet, waiting for the syncer and other background processes to exit. +func TestControllerStop(t *testing.T) { + t.Parallel() + + // Arrange: Create and start a test wallet. + w, deps := createTestWalletWithMocks(t) + + // Setup mocks for the startup sequence. + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(waddrmgr.BlockStamp{}, true, nil).Once() + deps.addrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore(nil)).Once() + deps.txStore.On( + "DeleteExpiredLockedOutputs", mock.Anything, + ).Return(nil).Once() + + // Mock syncer.run to simulate a long-running process that exits when + // the context is cancelled. + deps.syncer.On("run", mock.Anything).Run(func(args mock.Arguments) { + ctx, ok := args.Get(0).(context.Context) + if !ok { + return + } + <-ctx.Done() + }).Return(nil).Once() + + require.NoError(t, w.Start(t.Context())) + require.True(t, w.state.isStarted()) + + // Act: Stop the wallet. + err := w.Stop(t.Context()) + + // Assert: Verify that Stop returned no error and the wallet state is + // no longer 'Started'. + require.NoError(t, err) + require.False(t, w.state.isStarted()) + + // Act: Call Stop again to verify idempotency. + err = w.Stop(t.Context()) + + // Assert: Verify that subsequent Stop calls are safe and return no + // error. + require.NoError(t, err) +} From 886f6efa31f11da1d831703ae650bf3a81d541e6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:47:34 +0800 Subject: [PATCH 257/691] wallet: implement Controller `Lock` method Implements the Lock method to secure the wallet's private data and transition the state to 'Locked'. --- wallet/controller.go | 50 +++++++++++++++++++++++++++++++++++++++ wallet/controller_test.go | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/wallet/controller.go b/wallet/controller.go index 96fb2069f2..9476a1724a 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -259,6 +259,27 @@ func (w *Wallet) Stop(stopCtx context.Context) error { return nil } +// Lock locks the wallet. +// +// This is part of the Controller interface. +func (w *Wallet) Lock(ctx context.Context) error { + // Ensure the wallet is in a state that allows locking. + err := w.state.canLock() + if err != nil { + return err + } + + r := newLockReq() + + err = w.sendReq(ctx, r) + if err != nil { + return err + } + + // Wait for the result. + return w.waitForResp(ctx, r.resp) +} + // mainLoop is the central event loop for the wallet, responsible for // coordinating and serializing all lifecycle and authentication requests. It // manages the transition between locked and unlocked states and handles the @@ -511,3 +532,32 @@ func (w *Wallet) handleChangePassphraseReq(req changePassphraseReq) { // Report the result back to the caller. req.resp <- err } + +// sendReq sends an operation request to the main loop or handles cancellation. +func (w *Wallet) sendReq(ctx context.Context, req any) error { + select { + case w.requestChan <- req: + return nil + + case <-w.lifetimeCtx.Done(): + return ErrWalletShuttingDown + + case <-ctx.Done(): + return ctx.Err() + } +} + +// waitForResp waits for the response from an operation request or handles +// cancellation. +func (w *Wallet) waitForResp(ctx context.Context, resp <-chan error) error { + select { + case err := <-resp: + return err + + case <-w.lifetimeCtx.Done(): + return ErrWalletShuttingDown + + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/wallet/controller_test.go b/wallet/controller_test.go index b2977bfb66..8854bb2a5a 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -287,3 +287,45 @@ func TestControllerStop(t *testing.T) { // error. require.NoError(t, err) } + +// TestControllerLock verifies the Lock method. It ensures that the wallet +// can only be locked when it is started and currently unlocked. +func TestControllerLock(t *testing.T) { + t.Parallel() + + // Arrange: Create and start a test wallet. + w, deps := createTestWalletWithMocks(t) + + // Setup mocks for startup. + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(waddrmgr.BlockStamp{}, true, nil).Once() + deps.addrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore(nil)).Once() + deps.txStore.On( + "DeleteExpiredLockedOutputs", mock.Anything, + ).Return(nil).Once() + deps.syncer.On("run", mock.Anything).Return(nil).Once() + + require.NoError(t, w.Start(t.Context())) + + // Transition the wallet to the 'Unlocked' state for testing. + w.state.toUnlocked() + require.True(t, w.state.isUnlocked()) + + // Expect a call to the address manager's Lock method. + deps.addrStore.On("Lock").Return(nil).Once() + + // Act: Call the Lock method. + err := w.Lock(t.Context()) + + // Assert: Verify success and that the wallet state is locked. + require.NoError(t, err) + require.False(t, w.state.isUnlocked()) + + // Cleanup: Stop the wallet to release resources. + err = w.Stop(t.Context()) + require.NoError(t, err) + w.wg.Wait() +} From 1b9d6def19912537085752823653452df53a1efe Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:48:23 +0800 Subject: [PATCH 258/691] wallet: implement Controller `Unlock` method Implements the Unlock method, handling passphrase authentication and auto-lock timer initialization. --- wallet/controller.go | 28 ++++++++++++++++++++++++++ wallet/controller_test.go | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/wallet/controller.go b/wallet/controller.go index 9476a1724a..f99a2a54ad 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -259,6 +259,34 @@ func (w *Wallet) Stop(stopCtx context.Context) error { return nil } +// Unlock unlocks the wallet with a passphrase. +// +// This is part of the Controller interface. +func (w *Wallet) Unlock(ctx context.Context, req UnlockRequest) error { + // Ensure the wallet is in a state that allows unlocking. + err := w.state.canUnlock() + if err != nil { + return err + } + + // Apply default timeout if none specified. + if req.Timeout == 0 { + req.Timeout = w.cfg.AutoLockDuration + log.Infof("Using default auto-lock timeout of %v", req.Timeout) + } + + r := newUnlockReq(req) + + // Submit the request. + err = w.sendReq(ctx, r) + if err != nil { + return err + } + + // Wait for the result from the mainLoop. + return w.waitForResp(ctx, r.resp) +} + // Lock locks the wallet. // // This is part of the Controller interface. diff --git a/wallet/controller_test.go b/wallet/controller_test.go index 8854bb2a5a..7e6c74a3a9 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -329,3 +329,45 @@ func TestControllerLock(t *testing.T) { require.NoError(t, err) w.wg.Wait() } + +// TestControllerUnlock verifies the Unlock method. It ensures that the wallet +// can be unlocked by providing the correct passphrase. +func TestControllerUnlock(t *testing.T) { + t.Parallel() + + // Arrange: Create and start a test wallet. + w, deps := createTestWalletWithMocks(t) + + // Setup mocks for startup. + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(waddrmgr.BlockStamp{}, true, nil).Once() + deps.addrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore(nil)).Once() + deps.txStore.On( + "DeleteExpiredLockedOutputs", mock.Anything, + ).Return(nil).Once() + deps.syncer.On("run", mock.Anything).Return(nil).Once() + + require.NoError(t, w.Start(t.Context())) + require.False(t, w.state.isUnlocked()) + + pass := []byte("password") + + // Expect a call to the address manager's Unlock method. + deps.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() + + // Act: Call the Unlock method. + err := w.Unlock(t.Context(), UnlockRequest{Passphrase: pass}) + + // Assert: Verify success and that the wallet state is unlocked. + require.NoError(t, err) + require.True(t, w.state.isUnlocked()) + + // Cleanup: Stop the wallet to release resources. + err = w.Stop(t.Context()) + require.NoError(t, err) + w.wg.Wait() +} + From 53a0f4207b80f4d7b9a17335cfce140f5780c448 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:48:43 +0800 Subject: [PATCH 259/691] wallet: implement Controller `ChangePassphrase` method Implements the ChangePassphrase method to allow secure rotation of wallet credentials. --- wallet/controller.go | 23 ++++++++++++++ wallet/controller_test.go | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/wallet/controller.go b/wallet/controller.go index f99a2a54ad..38265c2c23 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -308,6 +308,29 @@ func (w *Wallet) Lock(ctx context.Context) error { return w.waitForResp(ctx, r.resp) } +// ChangePassphrase changes the wallet's passphrases according to the request. +// +// This is part of the Controller interface. +func (w *Wallet) ChangePassphrase(ctx context.Context, + req ChangePassphraseRequest) error { + + // Ensure the wallet is in a state that allows changing the passphrase. + err := w.state.canChangePassphrase() + if err != nil { + return err + } + + r := newChangePassphraseReq(req) + + err = w.sendReq(ctx, r) + if err != nil { + return err + } + + // Wait for the result. + return w.waitForResp(ctx, r.resp) +} + // mainLoop is the central event loop for the wallet, responsible for // coordinating and serializing all lifecycle and authentication requests. It // manages the transition between locked and unlocked states and handles the diff --git a/wallet/controller_test.go b/wallet/controller_test.go index 7e6c74a3a9..b4c735f096 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -371,3 +371,70 @@ func TestControllerUnlock(t *testing.T) { w.wg.Wait() } +// TestControllerChangePassphrase verifies the ChangePassphrase method. It +// ensures that the wallet forwards the request to the address manager to +// update the passphrases. +func TestControllerChangePassphrase(t *testing.T) { + t.Parallel() + + // Arrange: Create and start a test wallet. + w, deps := createTestWalletWithMocks(t) + + // Setup mocks for startup. + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(waddrmgr.BlockStamp{}, true, nil).Once() + deps.addrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore(nil)).Once() + deps.txStore.On( + "DeleteExpiredLockedOutputs", mock.Anything, + ).Return(nil).Once() + deps.syncer.On("run", mock.Anything).Return(nil).Once() + + require.NoError(t, w.Start(t.Context())) + + req := ChangePassphraseRequest{ + ChangePrivate: true, + PrivateOld: []byte("old"), + PrivateNew: []byte("new"), + } + + // Expect a call to ChangePassphrase in the address store. + deps.addrStore.On( + "ChangePassphrase", mock.Anything, []byte("old"), []byte("new"), + true, mock.Anything, + ).Return(nil).Once() + + // Act: Call ChangePassphrase. + err := w.ChangePassphrase(t.Context(), req) + + // Assert: Verify that the operation completed without error. + require.NoError(t, err) + + // Cleanup: Stop the wallet to release resources. + err = w.Stop(t.Context()) + require.NoError(t, err) + w.wg.Wait() +} + +// TestHandleChangePassphraseReq_Errors verifies error handling for the +// internal change passphrase request handler. +func TestHandleChangePassphraseReq_Errors(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet in the default 'Stopped' state. + w, _ := createTestWalletWithMocks(t) + + req := changePassphraseReq{ + req: ChangePassphraseRequest{}, + resp: make(chan error, 1), + } + + // Act: Call the internal handler while the wallet is stopped. + w.handleChangePassphraseReq(req) + + // Assert: Verify that the request fails with ErrStateForbidden. + err := <-req.resp + require.ErrorIs(t, err, ErrStateForbidden) +} From d1f47ef1aba7a5241e81841d7dfc3d51014fbdf5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:49:16 +0800 Subject: [PATCH 260/691] wallet: implement Controller `Info` method Implements the Info method to expose the wallet's current operational status to clients. --- wallet/controller.go | 19 +++++++++++++++ wallet/controller_test.go | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/wallet/controller.go b/wallet/controller.go index 38265c2c23..68d6ad986a 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -331,6 +331,25 @@ func (w *Wallet) ChangePassphrase(ctx context.Context, return w.waitForResp(ctx, r.resp) } +// Info returns a comprehensive snapshot of the wallet's static configuration +// and dynamic synchronization state. +// +// This is part of the Controller interface. +func (w *Wallet) Info(_ context.Context) (*Info, error) { + info := &Info{ + BirthdayBlock: w.birthdayBlock, + Backend: w.cfg.Chain.BackEnd(), + ChainParams: w.cfg.ChainParams, + Locked: !w.state.isUnlocked(), + Synced: w.state.isSynced(), + SyncedTo: w.SyncedTo(), + IsRecoveryMode: w.state.isRecoveryMode(), + RecoveryProgress: 0, + } + + return info, nil +} + // mainLoop is the central event loop for the wallet, responsible for // coordinating and serializing all lifecycle and authentication requests. It // manages the transition between locked and unlocked states and handles the diff --git a/wallet/controller_test.go b/wallet/controller_test.go index b4c735f096..57a50678de 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -438,3 +438,52 @@ func TestHandleChangePassphraseReq_Errors(t *testing.T) { err := <-req.resp require.ErrorIs(t, err, ErrStateForbidden) } + +// TestControllerInfo verifies the Info method. It checks that the wallet +// correctly aggregates information from its subsystems (chain backend, +// address manager, and syncer). +func TestControllerInfo(t *testing.T) { + t.Parallel() + + // Arrange: Create and start a test wallet with mocked subsystems. + w, deps := createTestWalletWithMocks(t) + + bs := waddrmgr.BlockStamp{Height: 100} + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(bs, true, nil).Once() + deps.addrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore(nil)).Once() + deps.txStore.On( + "DeleteExpiredLockedOutputs", mock.Anything, + ).Return(nil).Once() + deps.syncer.On("run", mock.Anything).Return(nil).Once() + + // Mock the chain backend to return a specific name. + deps.chain.On("BackEnd").Return("mock") + + // Mock SyncedTo to return a known block stamp. + deps.addrStore.On("SyncedTo").Return(bs) + + // Mock syncState to indicate the wallet is fully synced. + deps.syncer.On("syncState").Return(syncStateSynced) + + require.NoError(t, w.Start(t.Context())) + + // Act: Call the Info method. + info, err := w.Info(t.Context()) + + // Assert: Verify that the returned information matches the mocked + // values and current wallet state. + require.NoError(t, err) + require.Equal(t, "mock", info.Backend) + require.Equal(t, int32(100), info.BirthdayBlock.Height) + require.True(t, info.Synced) + require.True(t, info.Locked) + + // Cleanup: Stop the wallet to release resources. + err = w.Stop(t.Context()) + require.NoError(t, err) + w.wg.Wait() +} From bcd4cb87ab6452bf5c652c543a48af4e1aa527eb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 15 Jan 2026 19:11:42 +0800 Subject: [PATCH 261/691] wallet: make sure locking is idempotent --- wallet/controller.go | 16 +++++++++++----- wallet/controller_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/wallet/controller.go b/wallet/controller.go index 68d6ad986a..1cf8ee999f 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -570,18 +570,24 @@ func (w *Wallet) handleLockReq(req lockReq) { // Signal the address manager to lock, clearing sensitive data. err = w.addrStore.Lock() - if err != nil && !waddrmgr.IsError(err, waddrmgr.ErrLocked) { + if err != nil { log.Errorf("Could not lock wallet: %v", err) + + // If the wallet is already locked, we consider this a success + // (idempotency) and proceed to ensure our state is consistent. + if !waddrmgr.IsError(err, waddrmgr.ErrLocked) { + req.resp <- err + + return + } } // Even if an error occurred (e.g. already locked), we ensure the // wallet's high-level state is synchronized to 'locked'. - if err == nil { - w.state.toLocked() - } + w.state.toLocked() // Report the result back to the caller. - req.resp <- err + req.resp <- nil } // handleChangePassphraseReq processes a request to rotate the wallet's diff --git a/wallet/controller_test.go b/wallet/controller_test.go index 57a50678de..69bf68f75b 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -110,6 +110,40 @@ func TestHandleLockReq(t *testing.T) { require.False(t, w.state.isUnlocked()) } +// TestHandleLockReq_Idempotency verifies that if the wallet is already locked +// (indicated by waddrmgr.ErrLocked), the lock request treats it as a success +// and ensures the state is consistent. +func TestHandleLockReq_Idempotency(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and transition it to 'Started'. + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + // Transition the wallet to the 'Unlocked' state for testing. + w.state.toUnlocked() + + req := newLockReq() + + // Setup the expected call to the address manager's Lock method + // returning ErrLocked. + errLocked := waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrLocked, + Description: "address manager is locked", + } + deps.addrStore.On("Lock").Return(errLocked).Once() + + // Act: Dispatch the lock request to the handler. + w.handleLockReq(req) + + // Assert: Verify that the response indicates success and the wallet + // state is 'Locked'. + resp := <-req.resp + require.NoError(t, resp) + require.False(t, w.state.isUnlocked()) +} + // TestHandleLockReq_Errors verifies that handleLockReq correctly handles error // conditions, such as attempting to lock a stopped wallet. func TestHandleLockReq_Errors(t *testing.T) { From 01ae19b01d65cbdb6438a7e418a95122e2b40cea Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:51:06 +0800 Subject: [PATCH 262/691] syncer: implement initial chain sync and reorg handling Implements the initChainSync phase, handling backend synchronization waits and rollback checks. --- wallet/syncer.go | 182 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/wallet/syncer.go b/wallet/syncer.go index 8fe332d06f..a3bb259a7f 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -5,7 +5,11 @@ import ( "context" "fmt" "sync/atomic" + "time" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -155,6 +159,184 @@ func (s *syncer) isRecoveryMode() bool { return status == syncStateSyncing || status == syncStateRescanning } +// initChainSync performs the initial setup for the chain synchronization loop. +// This includes waiting for the backend to sync, checking for rollbacks, and +// enabling block notifications. It returns an error if any of these setup +// steps fail. +func (s *syncer) initChainSync(ctx context.Context) error { + var err error + + // Inform the backend about our birthday for optimization. For backends + // like Neutrino (SPV), this provides a starting point for the internal + // synchronization of block headers and compact filters. Without this + // hint, the backend might attempt to sync from genesis or its latest + // checkpoint, leading to unnecessary network I/O and delayed wallet + // readiness. + if cc, ok := s.cfg.Chain.(*chain.NeutrinoClient); ok { + cc.SetStartTime(s.addrStore.Birthday()) + } + + // Wait for the backend to be synced to the network. We require the + // backend to be synced before we start scanning to ensure we have a + // consistent view of the chain and can perform recovery correctly. + s.state.Store(uint32(syncStateBackendSyncing)) + + err = s.waitUntilBackendSynced(ctx) + if err != nil { + return fmt.Errorf("unable to wait for backend sync: %w", err) + } + + // Check for any reorgs that happened while we were down. + err = s.checkRollback(ctx) + if err != nil { + return fmt.Errorf("unable to check for rollback: %w", err) + } + + // Enable block notifications from the chain backend. + err = s.cfg.Chain.NotifyBlocks() + if err != nil { + return fmt.Errorf("unable to start block notifications: %w", + err) + } + + return nil +} + +// waitUntilBackendSynced blocks until the chain backend considers itself +// "current". +func (s *syncer) waitUntilBackendSynced(ctx context.Context) error { + // We'll poll every second to determine if our chain considers itself + // "current". + t := time.NewTicker(time.Second) + defer t.Stop() + + for { + select { + case <-t.C: + if s.cfg.Chain.IsCurrent() { + return nil + } + + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// checkRollback ensures the wallet is synchronized with the current chain tip. +// It checks if the wallet's synced tip is still on the main chain, and if not, +// rewinds the wallet state to the common ancestor. +func (s *syncer) checkRollback(ctx context.Context) error { + var err error + + // batchSize is the number of blocks to fetch from the chain backend in + // a single batch when checking for a rollback. A value of 10 is chosen + // as a conservative default that covers the vast majority of reorg + // scenarios (typically 1-3 blocks) while keeping individual batch + // requests lightweight. + const batchSize = 10 + + syncedTo := s.addrStore.SyncedTo() + syncedHeight := syncedTo.Height + + var ( + localHashes []*chainhash.Hash + remoteHashes []chainhash.Hash + header *wire.BlockHeader + ) + + for syncedHeight > 0 { + // Calculate the range for this batch. We scan backwards: + // [startHeight, endHeight] where endHeight is syncedHeight. + endHeight := syncedHeight + startHeight := max(0, endHeight-batchSize+1) + + // Fetch Local Batch (from wallet's database). + localHashes, err = s.DBGetSyncedBlocks( + ctx, startHeight, endHeight, + ) + if err != nil { + return err + } + + // Fetch Remote Batch - Fetch corresponding hashes from the + // chain backend. + remoteHashes, err = s.cfg.Chain.GetBlockHashes( + int64(startHeight), int64(endHeight), + ) + if err != nil { + return fmt.Errorf("remote get block hashes: %w", err) + } + + // Compare Batches. Iterate backwards to find the last matching + // block (the fork point). + matchIndex := s.findForkPoint(localHashes, remoteHashes) + + // Case A: Tip matches. No rollback needed (if we are at the + // tip). If syncedHeight == syncedTo.Height and matchIndex is + // the last element, then we are fully synced on the main + // chain. + if syncedHeight == syncedTo.Height && + matchIndex == len(localHashes)-1 { + + return nil + } + + // Case B: Mismatch found within this batch. A fork point has + // been detected. This indicates a blockchain reorganization + // where the wallet's local chain history diverges from the + // chain backend's view within the current batch of blocks. + if matchIndex != -1 { + //nolint:gosec // matchIndex < batchSize (10). + forkHeight := startHeight + int32(matchIndex) + forkHash := localHashes[matchIndex] + + log.Infof("Rollback detected! Rewinding to height %d "+ + "(%v)", forkHeight, forkHash) + + // Fetch the block header outside the DB transaction to + // avoid holding the lock during an RPC call. + header, err = s.cfg.Chain.GetBlockHeader(forkHash) + if err != nil { + return fmt.Errorf("get fork header: %w", err) + } + + // Perform the rollback. + return s.DBPutRewind(ctx, waddrmgr.BlockStamp{ + Height: forkHeight, + Hash: *forkHash, + Timestamp: header.Timestamp, + }) + } + + // Case C: No match in this batch. The fork point is deeper. + // Move syncedHeight back and continue loop. + syncedHeight = startHeight - 1 + } + + return nil +} + +// findForkPoint compares local and remote block hashes to find the last +// matching block (fork point). It returns the index of the last match in the +// slices, or -1 if no match is found. +func (s *syncer) findForkPoint(localHashes []*chainhash.Hash, + remoteHashes []chainhash.Hash) int { + + // Compare up to the length of the shortest slice to avoid + // out-of-bounds panics if the chain backend returns fewer hashes than + // expected. + minLen := min(len(localHashes), len(remoteHashes)) + + for i := minLen - 1; i >= 0; i-- { + if localHashes[i].IsEqual(&remoteHashes[i]) { + return i + } + } + + return -1 +} + // run executes the main synchronization loop. func (s *syncer) run(ctx context.Context) error { return nil From 5a480d6acc8210629188b826863f550804375f0b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 20:41:52 +0800 Subject: [PATCH 263/691] syncer: implement full scan state loading and header-only scanning Implements loadFullScanState and scanBatchHeadersOnly to support efficient sync resumption and fast-forwarding. --- wallet/syncer.go | 94 +++++++++++++++ wallet/syncer_test.go | 269 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+) diff --git a/wallet/syncer.go b/wallet/syncer.go index a3bb259a7f..e21647d02a 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "time" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" @@ -352,3 +353,96 @@ func (s *syncer) requestScan(ctx context.Context, req *scanReq) error { return fmt.Errorf("context done: %w", ctx.Err()) } } + +// scanBatchHeadersOnly performs a lightweight scan by only fetching block +// headers. This is used when the wallet has no addresses or outpoints to +// watch, allowing it to fast-forward its sync state. +func (s *syncer) scanBatchHeadersOnly(_ context.Context, + startHeight, endHeight int32) ([]scanResult, error) { + + // Batch 1: Fetch Block Hashes. + hashes, err := s.cfg.Chain.GetBlockHashes( + int64(startHeight), int64(endHeight), + ) + if err != nil { + return nil, fmt.Errorf("batch get block hashes: %w", err) + } + + // Batch 2: Fetch Block Headers (for timestamps). + headers, err := s.cfg.Chain.GetBlockHeaders(hashes) + if err != nil { + return nil, fmt.Errorf("batch get block headers: %w", err) + } + + results := make([]scanResult, 0, len(hashes)) + for i := range hashes { + hash := hashes[i] + header := headers[i] + + //nolint:gosec // i is bounded by batch size (2000), so + // addition to startHeight won't overflow int32. + height := startHeight + int32(i) + + meta := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Hash: hash, Height: height}, + Time: header.Timestamp, + } + + results = append(results, scanResult{ + meta: meta, + // We provide an empty BlockProcessResult to avoid nil + // pointer dereferences when accessing embedded fields + // (like RelevantTxs) in commitSyncBatch. This + // effectively acts as a "no-op" result. + BlockProcessResult: &BlockProcessResult{}, + }) + } + + return results, nil +} + +// loadFullScanState initializes a fresh recovery state for a new batch scan. +// It loads active data, syncs horizons from DB, and prepares the initial +// lookahead window. +func (s *syncer) loadFullScanState( + ctx context.Context) (*RecoveryState, error) { + + horizonData, initialAddrs, initialUnspent, err := s.loadWalletScanData( + ctx, + ) + if err != nil { + return nil, err + } + + // Initialize a fresh recovery state for this batch to ensure no stale + // state leaks between batches. + scanState := NewRecoveryState( + s.cfg.RecoveryWindow, s.cfg.ChainParams, s.addrStore, + ) + + // Initialize Batch State (History + Lookahead) + err = scanState.Initialize(horizonData, initialAddrs, initialUnspent) + if err != nil { + return nil, fmt.Errorf("init scan state: %w", err) + } + + return scanState, nil +} + +// loadWalletScanData retrieves all necessary data from the database. +func (s *syncer) loadWalletScanData(ctx context.Context) ( + []*waddrmgr.AccountProperties, []btcutil.Address, + []wtxmgr.Credit, error) { + + var targets []waddrmgr.AccountScope + for _, scopedMgr := range s.addrStore.ActiveScopedKeyManagers() { + for _, accNum := range scopedMgr.ActiveAccounts() { + targets = append(targets, waddrmgr.AccountScope{ + Scope: scopedMgr.Scope(), + Account: accNum, + }) + } + } + + return s.DBGetScanData(ctx, targets) +} diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 1757a627bb..521a16c3b5 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -5,7 +5,12 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -121,3 +126,267 @@ func TestSyncerRun(t *testing.T) { err := s.run(ctx) require.NoError(t, err) } + +// TestWaitUntilBackendSynced verifies polling logic. +func TestWaitUntilBackendSynced(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock its chain to simulate a + // delayed synchronization. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + // Simulate the backend not being current on the first check, but + // becoming current on the second check. + mockChain.On("IsCurrent").Return(false).Once() + mockChain.On("IsCurrent").Return(true).Once() + + // Act & Assert: Call waitUntilBackendSynced and verify it waits for + // the backend to sync before returning successfully. + err := s.waitUntilBackendSynced(t.Context()) + require.NoError(t, err) + mockChain.AssertExpectations(t) +} + +// TestCheckRollbackNoReorg verifies checkRollback when tips match. +func TestCheckRollbackNoReorg(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer with a test database and mock chain. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockChain := &mockChain{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, mockAddrStore, nil, nil, + ) + + tip := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} + mockAddrStore.On("SyncedTo").Return(tip) + + // Mock retrieval of synced block hashes from the database for the + // last 10 blocks. + for i := int32(91); i <= 100; i++ { + hash := chainhash.Hash{byte(i)} + mockAddrStore.On( + "BlockHash", mock.Anything, i, + ).Return(&hash, nil) + } + + // Mock retrieval of matching block hashes from the remote chain. + remoteHashes := make([]chainhash.Hash, 10) + for i := range 10 { + remoteHashes[i] = chainhash.Hash{byte(91 + i)} + } + + mockChain.On( + "GetBlockHashes", int64(91), int64(100), + ).Return(remoteHashes, nil).Once() + + // Act & Assert: Verify that checkRollback completes without error + // and no rollback is triggered when hashes match. + err := s.checkRollback(t.Context()) + require.NoError(t, err) +} + +// TestCheckRollbackDetected verifies checkRollback when reorg is detected. +func TestCheckRollbackDetected(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer with a test database and mocks to + // simulate a chain reorganization. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockChain := &mockChain{} + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, + mockPublisher, + ) + + tip := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} + mockAddrStore.On("SyncedTo").Return(tip) + + // Mock retrieval of synced block hashes from the database for blocks + // 91 to 100. + for i := int32(91); i <= 100; i++ { + hash := chainhash.Hash{byte(i)} + mockAddrStore.On( + "BlockHash", mock.Anything, i, + ).Return(&hash, nil) + } + + // Mock retrieval of remote block hashes where a fork occurs at + // height 95. + remoteHashes := make([]chainhash.Hash, 10) + for i := range 10 { + h := 91 + i + if h > 95 { + remoteHashes[i] = chainhash.Hash{0xff} // Mismatch + } else { + remoteHashes[i] = chainhash.Hash{byte(h)} // Match + } + } + + mockChain.On( + "GetBlockHashes", int64(91), int64(100), + ).Return(remoteHashes, nil).Once() + + // Mock header retrieval for the detected fork point at height 95. + forkHash := chainhash.Hash{byte(95)} + header := &wire.BlockHeader{Timestamp: time.Now()} + mockChain.On("GetBlockHeader", &forkHash).Return(header, nil).Once() + + // Expect a rollback to the common ancestor at height 95 and a + // corresponding transaction store rollback. + mockAddrStore.On( + "SetSyncedTo", mock.Anything, mock.Anything, + ).Return(nil).Once() + mockTxStore.On("Rollback", mock.Anything, int32(96)).Return(nil).Once() + + // Act & Assert: Verify that checkRollback correctly identifies the + // fork and performs the rollback. + err := s.checkRollback(t.Context()) + require.NoError(t, err) +} + +// TestInitChainSync verifies the initial synchronization sequence. +func TestInitChainSync(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock its dependencies for the + // initial synchronization sequence. + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + ) + + // Mock backend synchronization check. + mockChain.On("IsCurrent").Return(true).Once() + + // Mock block notification registration. + mockChain.On("NotifyBlocks").Return(nil).Once() + + // Mock rollback check at the start of synchronization. + tip := waddrmgr.BlockStamp{Height: 0} + mockAddrStore.On("SyncedTo").Return(tip) + + // Act & Assert: Verify that the initial chain synchronization + // sequence completes successfully. + err := s.initChainSync(t.Context()) + require.NoError(t, err) +} + +// TestScanBatchHeadersOnly verifies header-only scan logic. +func TestScanBatchHeadersOnly(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock block and header retrieval. + mockChain := &mockChain{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) + + hashes := []chainhash.Hash{{0x01}, {0x02}} + mockChain.On( + "GetBlockHashes", int64(10), int64(11), + ).Return(hashes, nil).Once() + + headers := []*wire.BlockHeader{ + {Timestamp: time.Unix(100, 0)}, + {Timestamp: time.Unix(200, 0)}, + } + mockChain.On("GetBlockHeaders", hashes).Return(headers, nil).Once() + + // Act: Perform a header-only scan for blocks 10 and 11. + results, err := s.scanBatchHeadersOnly(t.Context(), 10, 11) + + // Assert: Verify that the correct block results are returned with + // expected heights. + require.NoError(t, err) + require.Len(t, results, 2) + require.Equal(t, int32(10), results[0].meta.Height) + require.Equal(t, int32(11), results[1].meta.Height) +} + +// TestSyncerLoadScanState verifies full scan state loading. +func TestSyncerLoadScanState(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer with a test database and set up complex + // mock expectations for loading wallet scan data. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{ + DB: db, + RecoveryWindow: 10, + ChainParams: &chaincfg.MainNetParams, + }, + mockAddrStore, mockTxStore, mockPublisher, + ) + + // Mock active scoped key managers. + scopedMgr := &mockAccountStore{} + mockAddrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore{scopedMgr}).Once() + + // Mock active accounts for the key manager scope. + scopedMgr.On("ActiveAccounts").Return([]uint32{0}).Once() + scopedMgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + + // Mock database operations to fetch scan data, including key managers, + // account properties, active addresses, and outputs to watch. + mockAddrStore.On( + "FetchScopedKeyManager", mock.Anything, + ).Return(scopedMgr, nil).Times(3) + + props := &waddrmgr.AccountProperties{ + AccountNumber: 0, + KeyScope: waddrmgr.KeyScopeBIP0084, + } + scopedMgr.On( + "AccountProperties", mock.Anything, uint32(0), + ).Return(props, nil).Twice() + + mockAddrStore.On( + "ForEachRelevantActiveAddress", mock.Anything, mock.Anything, + ).Return(nil).Once() + + mockTxStore.On( + "OutputsToWatch", mock.Anything, + ).Return([]wtxmgr.Credit(nil), nil).Once() + + // Mock address derivation for the lookahead window (10 addresses for + // each branch). + mockAddr := &mockAddress{} + mockAddr.On("EncodeAddress").Return("addr") + mockAddr.On("ScriptAddress").Return([]byte{0x00}) + scopedMgr.On( + "DeriveAddr", mock.Anything, mock.Anything, mock.Anything, + ).Return( + mockAddr, []byte{0x00}, nil, + ).Maybe() + + // Act: Load the full scan state from the database. + state, err := s.loadFullScanState(t.Context()) + + // Assert: Verify that the scan state is correctly loaded and not nil. + require.NoError(t, err) + require.NotNil(t, state) +} From b76ca9f4c773dfd64e99fe013b3e0da710cba19d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:53:32 +0800 Subject: [PATCH 264/691] syncer: implement full block scanning fallback Implements scanBatchWithFullBlocks as a robust fallback mechanism for when filters are unavailable. --- wallet/syncer.go | 51 +++++++++++++++++++++++++++++++++++++++++++ wallet/syncer_test.go | 37 +++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/wallet/syncer.go b/wallet/syncer.go index e21647d02a..2da100c9fb 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -429,6 +429,57 @@ func (s *syncer) loadFullScanState( return scanState, nil } +// scanBatchWithFullBlocks implements the fallback scanning by downloading and +// checking every block in the batch. +func (s *syncer) scanBatchWithFullBlocks(_ context.Context, + scanState *RecoveryState, startHeight int32, + hashes []chainhash.Hash) ([]scanResult, error) { + + results := make([]scanResult, 0, len(hashes)) + + // 1. Fetch ALL Blocks. + blocks, err := s.cfg.Chain.GetBlocks(hashes) + if err != nil { + return nil, fmt.Errorf("batch get blocks (fallback): %w", err) + } + + // Iterate and Process Blocks. Now that all blocks in the batch have + // been fetched, process each block individually. This involves + // creating the necessary block metadata and then feeding the full + // block into the recovery state for filtering and horizon expansion. + for i := range hashes { + hash := hashes[i] + block := blocks[i] + + //nolint:gosec // i is bounded by batch size (2000), so + // addition to startHeight won't overflow int32. + height := startHeight + int32(i) + + meta := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Hash: hash, Height: height}, + } + + // Process the block using the recovery state. This involves: + // 1. Filtering the block for relevant transactions. + // 2. Expanding the address lookahead horizons if new addresses + // are found. + // 3. Re-filtering if horizons were expanded to ensure we catch + // all transactions relevant to the newly derived addresses. + res, err := scanState.ProcessBlock(block) + if err != nil { + return nil, fmt.Errorf("process block %d (%s): %w", + height, hash, err) + } + + results = append(results, scanResult{ + meta: meta, + BlockProcessResult: res, + }) + } + + return results, nil +} + // loadWalletScanData retrieves all necessary data from the database. func (s *syncer) loadWalletScanData(ctx context.Context) ( []*waddrmgr.AccountProperties, []btcutil.Address, diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 521a16c3b5..456a37df1d 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -390,3 +390,40 @@ func TestSyncerLoadScanState(t *testing.T) { require.NoError(t, err) require.NotNil(t, state) } + +// TestScanBatchWithFullBlocks verifies fallback scan logic. +func TestScanBatchWithFullBlocks(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and a recovery state for scanning. + mockChain := &mockChain{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) + + mockAddrStore := &mockAddrStore{} + scanState := NewRecoveryState( + 10, &chaincfg.MainNetParams, mockAddrStore, + ) + + hashes := []chainhash.Hash{{0x01}} + + // Create a mock block message for testing. + msgBlock := wire.NewMsgBlock(wire.NewBlockHeader( + 1, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + blocks := []*wire.MsgBlock{msgBlock} + mockChain.On( + "GetBlocks", hashes, + ).Return(blocks, nil).Once() + + // Act: Perform a batch scan using full blocks. + results, err := s.scanBatchWithFullBlocks( + t.Context(), scanState, 10, hashes, + ) + + // Assert: Verify that the scan returned the expected block result. + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, int32(10), results[0].meta.Height) +} From ea5cdb3a501b480634aa633da2d96a97f7d98cb4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:55:49 +0800 Subject: [PATCH 265/691] syncer: implement CFilter-based scanning Implements scanBatchWithCFilters, enabling bandwidth-efficient scanning via BIP 157/158 Compact Filters. --- wallet/syncer.go | 292 ++++++++++++++++++++++++++++++++++++++++++ wallet/syncer_test.go | 66 ++++++++++ 2 files changed, 358 insertions(+) diff --git a/wallet/syncer.go b/wallet/syncer.go index 2da100c9fb..0a2750e025 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -3,11 +3,14 @@ package wallet import ( "context" + "errors" "fmt" "sync/atomic" "time" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" + "github.com/btcsuite/btcd/btcutil/gcs/builder" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" @@ -15,6 +18,70 @@ import ( "github.com/btcsuite/btcwallet/wtxmgr" ) +var ( + // ErrCFiltersUnavailable is returned when the chain backend cannot + // serve compact filters. + ErrCFiltersUnavailable = errors.New("cfilters unavailable") + + // ErrUnknownSyncMethod is returned when an unknown synchronization + // method is specified. + ErrUnknownSyncMethod = errors.New("unknown sync method") + + // ErrScanBatchEmpty is returned when a scan batch contains no blocks. + ErrScanBatchEmpty = errors.New("scan batch empty") + + // ErrUnknownRescanJobType is returned when an unknown rescan job type + // is encountered. + ErrUnknownRescanJobType = errors.New("unknown rescan job type") + + // ErrInvalidStartHeight is returned when a resync or rescan is + // requested with an invalid start height (e.g., zero if not allowed). + ErrInvalidStartHeight = errors.New("invalid start height") + + // ErrStartHeightTooHigh is returned when a resync or rescan is + // requested with a start height that is greater than the current + // chain tip. + ErrStartHeightTooHigh = errors.New("start height is greater than " + + "current chain tip") + + // ErrStartHeightTooLarge is returned when a resync or rescan is + // requested with a start height that exceeds the maximum value of + // an int32, which is the underlying type for block heights. + ErrStartHeightTooLarge = errors.New("start height too large, exceeds " + + "maximum int32 value") + + // ErrNoScanTargets is returned when a targeted rescan is requested with + // no targets. + ErrNoScanTargets = errors.New("at least one target must be specified") +) + +const ( + // syncStateSwitchThreshold is the number of blocks behind the chain + // tip at which the wallet switches to the "Syncing" state. Gaps + // smaller than this are handled silently (blocking DB lock) to avoid + // disrupting UX with "Wallet Busy" errors for minor lags. + // + // Value 6 (approx 1 hour) is chosen based on a balance of two factors: + // + // 1. Database Contention: Synchronization requires a database write + // lock. Processing 6 blocks typically takes less than 1 second, + // which is an acceptable duration for other operations (like + // CreateTx) to block (wait) on the database lock. Gaps larger than + // this would result in noticeable UI hangs, so we switch to the + // explicit "Syncing" state which allows the wallet to return an + // immediate error instead of blocking. + // + // 2. Data Integrity vs. UX: While in a silent sync, the wallet might + // allow the user to initiate actions based on slightly outdated + // data (e.g., spending an output that was actually spent in one of + // the missing blocks). For a 6-block gap, the risk is minimal, and + // such transactions would be rejected by the network mempool or + // miners. However, for large gaps, the risk of false "Insufficient + // Funds" errors or extremely inaccurate fee estimates increases, + // making the explicit "Syncing" state a necessary safeguard. + syncStateSwitchThreshold = 6 +) + // syncState represents the synchronization status of the wallet with the // blockchain. type syncState uint32 @@ -480,6 +547,231 @@ func (s *syncer) scanBatchWithFullBlocks(_ context.Context, return results, nil } +// initResultsForCFilterScan fetches block headers for the given hashes and +// initializes a slice of scanResult with basic metadata (hash, height, time). +// This is a preparatory step specifically for CFilter-based scans. +func (s *syncer) initResultsForCFilterScan(_ context.Context, + startHeight int32, hashes []chainhash.Hash) ([]scanResult, error) { + + headers, err := s.cfg.Chain.GetBlockHeaders(hashes) + if err != nil { + return nil, fmt.Errorf("batch get block headers: %w", err) + } + + results := make([]scanResult, len(hashes)) + for i := range hashes { + results[i] = scanResult{ + meta: &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: hashes[i], + + //nolint:gosec // i is bounded by batch + // size (2000), so addition to + // startHeight won't overflow int32. + Height: startHeight + int32(i), + }, + Time: headers[i].Timestamp, + }, + // Initialize with empty result to avoid nil + // dereference if block is not processed. + BlockProcessResult: &BlockProcessResult{}, + } + } + + return results, nil +} + +// filterBatch iterates over the scan results and matches them against the +// provided filters using the watchlist. It returns a list of block hashes that +// matched the filter. +func (s *syncer) filterBatch(ctx context.Context, results []scanResult, + filters []*gcs.Filter, + blockMap map[chainhash.Hash]*wire.MsgBlock, + watchList [][]byte) ([]chainhash.Hash, error) { + + var matchedHashes []chainhash.Hash + for i := range results { + // Check context cancellation. + select { + case <-ctx.Done(): + return nil, fmt.Errorf("context done: %w", ctx.Err()) + default: + } + + // Skip if we already fetched this block. + if _, ok := blockMap[results[i].meta.Hash]; ok { + continue + } + + filter := filters[i] + + // If the filter is nil or has no elements (N=0), it indicates + // a potential issue with the chain backend (e.g., filter not + // available, corrupted, or for an invalid block). While N=0 is + // theoretically impossible for valid Bitcoin blocks with + // regular filters (due to coinbase transactions), we + // conservatively treat both cases as a match to ensure no + // relevant transactions are missed. This prioritizes safety + // over strict filter efficiency, forcing the download of the + // full block for later processing. + if filter == nil || filter.N() == 0 { + var n uint32 + if filter != nil { + n = filter.N() + } + + log.Errorf("Filter missing or empty for block %v "+ + "(nil=%v, N=%d), forcing download", + results[i].meta.Hash, filter == nil, n) + + matchedHashes = append( + matchedHashes, results[i].meta.Hash, + ) + + continue + } + + key := builder.DeriveKey(&results[i].meta.Hash) + + matched, err := filter.MatchAny(key, watchList) + if err != nil { + return nil, fmt.Errorf("filter match failed: %w", err) + } + + if matched { + matchedHashes = append( + matchedHashes, results[i].meta.Hash, + ) + } + } + + return matchedHashes, nil +} + +// matchAndFetchBatch performs the core logic of matching CFilters against the +// wallet's watchlist and fetching the corresponding blocks. It iterates over +// the provided `results`, checking filters for each. Blocks that match (and +// haven't been fetched yet) are downloaded and added to the `blockMap`. +// +// NOTE: This method mutates the provided `blockMap` parameter by adding new +// blocks to it. +func (s *syncer) matchAndFetchBatch(ctx context.Context, state *RecoveryState, + results []scanResult, + filters []*gcs.Filter, + blockMap map[chainhash.Hash]*wire.MsgBlock) error { + + // Generate the watchlist for CFilter matching. + watchList, err := state.BuildCFilterData() + if err != nil { + return fmt.Errorf("build cfilter data: %w", err) + } + + matchedHashes, err := s.filterBatch( + ctx, results, filters, blockMap, watchList, + ) + if err != nil { + return err + } + + // Fetch Matched Blocks. + if len(matchedHashes) > 0 { + blocks, err := s.cfg.Chain.GetBlocks(matchedHashes) + if err != nil { + return fmt.Errorf("batch get blocks: %w", err) + } + + for i, block := range blocks { + blockMap[matchedHashes[i]] = block + } + } + + return nil +} + +// scanBatchWithCFilters implements the fast-path scanning using Compact +// Filters. It fetches filters, matches them locally, fetches only matched +// blocks, and handles horizon expansion with an in-place resume logic. +func (s *syncer) scanBatchWithCFilters(ctx context.Context, + scanState *RecoveryState, startHeight int32, + hashes []chainhash.Hash) ([]scanResult, error) { + + // Fetch CFilters for the batch. + filters, err := s.cfg.Chain.GetCFilters( + hashes, wire.GCSFilterRegular, + ) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCFiltersUnavailable, err) + } + + // Fetch headers and initialize results with metadata. + results, err := s.initResultsForCFilterScan(ctx, startHeight, hashes) + if err != nil { + return nil, err + } + + // blockMap serves as a cache for full block data that has been + // fetched. It is populated by `matchAndFetchBatch` during both the + // initial matching phase and any subsequent re-matching due to horizon + // expansion. This map ensures that once a block is identified as + // relevant and downloaded, it's available for processing without + // redundant network requests, maintaining I/O efficiency across the + // processing loops. + blockMap := make(map[chainhash.Hash]*wire.MsgBlock, len(hashes)) + + // Initial Match: Optimistically match the entire batch of filters + // against the current watchlist. This allows us to fetch all likely + // relevant blocks in a single batch operation, maximizing I/O + // parallelism. + err = s.matchAndFetchBatch(ctx, scanState, results, filters, blockMap) + if err != nil { + return nil, err + } + + // Process Blocks: Iterate through the results and process any blocks + // that were matched and fetched. + for i := range results { + res := &results[i] + block := blockMap[res.meta.Hash] + + // If block was not matched/fetched, skip processing. + if block == nil { + continue + } + + processRes, err := scanState.ProcessBlock(block) + if err != nil { + return nil, fmt.Errorf("process block %d (%s): %w", + res.meta.Height, res.meta.Hash, err) + } + + // Attach the real result to the pre-allocated scanResult. + res.BlockProcessResult = processRes + + // Move to the next if the horizon is not expanded. + if !processRes.Expanded { + continue + } + + log.Debugf("Horizon expanded at height %d, updating filters", + res.meta.Height) + + // If the horizon expanded, our watchlist has changed. We must + // re-evaluate the remaining filters in the batch (i+1 onwards) + // against the new addresses or outpoints to ensure we don't + // miss any relevant transactions that were previously skipped. + // This "in-place resume" logic ensures correctness despite the + // batch pre-fetching optimization. + err = s.matchAndFetchBatch( + ctx, scanState, results[i+1:], filters[i+1:], blockMap, + ) + if err != nil { + return nil, err + } + } + + return results, nil +} + // loadWalletScanData retrieves all necessary data from the database. func (s *syncer) loadWalletScanData(ctx context.Context) ( []*waddrmgr.AccountProperties, []btcutil.Address, diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 456a37df1d..92569704ee 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcutil/gcs" + "github.com/btcsuite/btcd/btcutil/gcs/builder" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -427,3 +429,67 @@ func TestScanBatchWithFullBlocks(t *testing.T) { require.Len(t, results, 1) require.Equal(t, int32(10), results[0].meta.Height) } + +// TestScanBatchWithCFilters verifies CFilter-based scan logic. +func TestScanBatchWithCFilters(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and set up a recovery state. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, nil, nil, mockPublisher, + ) + + mockAddrStore := &mockAddrStore{} + scanState := NewRecoveryState( + 10, &chaincfg.MainNetParams, mockAddrStore, + ) + + hashes := []chainhash.Hash{{0x01}} + + // Mock retrieval of compact filters for the block batch. + filter, err := gcs.BuildGCSFilter( + builder.DefaultP, builder.DefaultM, [16]byte{}, nil, + ) + require.NoError(t, err) + mockChain.On( + "GetCFilters", hashes, wire.GCSFilterRegular, + ).Return([]*gcs.Filter{filter}, nil).Once() + + // Mock retrieval of block headers for the batch. + headers := []*wire.BlockHeader{{Timestamp: time.Unix(100, 0)}} + mockChain.On("GetBlockHeaders", hashes).Return(headers, nil).Once() + + // Mock retrieval of full blocks for the batch (simulating a filter + // match). + msgBlock := wire.NewMsgBlock(wire.NewBlockHeader( + 1, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + mockChain.On("GetBlocks", hashes).Return( + []*wire.MsgBlock{msgBlock}, nil, + ).Once() + + // Mock address store failures to simplify the test path and avoid + // deep derivation logic. + mockAddrStore.On( + "Address", mock.Anything, mock.Anything, + ).Return(nil, waddrmgr.ErrAddressNotFound).Maybe() + mockAddrStore.On( + "FetchScopedKeyManager", mock.Anything, + ).Return(nil, waddrmgr.ErrAddressNotFound).Maybe() + + // Act: Perform a batch scan using CFilters. + results, err := s.scanBatchWithCFilters( + t.Context(), scanState, 10, hashes, + ) + + // Assert: Verify that the scan results are correct. + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, int32(10), results[0].meta.Height) +} From 89df949e86fe96c333ac9539846701d1cd6d90d2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:56:16 +0800 Subject: [PATCH 266/691] syncer: implement scan strategy dispatching and main scan batching Implements dispatchScanStrategy to intelligently select between CFilters, Full Blocks, or Headers-only scanning based on heuristics. --- wallet/syncer.go | 162 ++++++++++++++++++++++++++++++++++++++++++ wallet/syncer_test.go | 154 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+) diff --git a/wallet/syncer.go b/wallet/syncer.go index 0a2750e025..1ea5d53e94 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -772,6 +772,168 @@ func (s *syncer) scanBatchWithCFilters(ctx context.Context, return results, nil } +// fetchAndFilterBlocks retrieves and processes a batch of blocks from the +// chain backend. It handles CFilter matching, block fetching, local filtering, +// and dynamic address discovery (expanding horizons in memory/read-only DB). +func (s *syncer) fetchAndFilterBlocks(ctx context.Context, + scanState *RecoveryState, startHeight, chainTip int32) ( + []scanResult, error) { + + // Cap the batch size to recoveryBatchSize to manage memory usage. + endHeight := min(startHeight+int32(recoveryBatchSize)-1, chainTip) + + // Optimization: If we have nothing to watch, performing a + // "header-only" scan to advance the wallet's sync state without + // downloading full blocks or filters. + // + // NOTE: For targeted rescans, the state will never be empty as it is + // initialized with specific targets. + if scanState.Empty() { + log.Debugf("Performing header-only scan for %d blocks", + endHeight-startHeight+1) + + return s.scanBatchHeadersOnly(ctx, startHeight, endHeight) + } + + log.Debugf("Scanning %d blocks (height %d to %d) with %s", + endHeight-startHeight+1, startHeight, endHeight, scanState) + + // Batch 1: Fetch all Block Hashes. + // TODO: Pass ctx when chainClient supports it. + hashes, err := s.cfg.Chain.GetBlockHashes( + int64(startHeight), int64(endHeight), + ) + if err != nil { + return nil, fmt.Errorf("batch get block hashes: %w", err) + } + + return s.dispatchScanStrategy(ctx, scanState, startHeight, hashes) +} + +// defaultMaxCFilterItems is the heuristic threshold for the number of items +// (addresses + outpoints) in the watchlist at which the cost of client-side +// GCS filter matching exceeds the cost of downloading and parsing full blocks. +// +// Calculation: +// - CFilter Match: ~50ns per item (SIP hash). 100k items = 5ms per block. +// - Full Block: ~10ms transfer (local) + ~10ms parse. Total ~20ms per block. +// +// While 100k items suggests ~5ms matching time, this is for a single filter. +// In a batch of 200 blocks, total matching time is 1 second. However, if the +// match rate is non-zero, we incur additional block download costs. +// +// At >100k items, the CPU load of matching becomes significant enough that +// bypassing filters and streaming full blocks (especially from a local node) +// is often more performant and uses less CPU time overall. This threshold is +// conservative to favor CFilters for typical wallet sizes (<10k items). +const defaultMaxCFilterItems = 100000 + +// dispatchScanStrategy chooses and executes the appropriate scanning strategy +// based on the wallet's configuration and heuristics. +func (s *syncer) dispatchScanStrategy(ctx context.Context, + scanState *RecoveryState, startHeight int32, + hashes []chainhash.Hash) ([]scanResult, error) { + + switch s.cfg.SyncMethod { + case SyncMethodFullBlocks: + return s.scanBatchWithFullBlocks( + ctx, scanState, startHeight, hashes, + ) + + // Attempt to use CFilters. If this fails (e.g. not supported by + // backend), we return the error directly as the user explicitly + // requested this method. + case SyncMethodCFilters: + return s.scanBatchWithCFilters( + ctx, scanState, startHeight, hashes, + ) + + case SyncMethodAuto: + // Check address/UTXO count heuristic. If we have > 100k items + // to watch, full block scanning is likely faster due to + // client-side filter matching CPU bottleneck. + threshold := s.cfg.MaxCFilterItems + if threshold == 0 { + threshold = defaultMaxCFilterItems + } + + if scanState.WatchListSize() > threshold { + log.Infof("Auto sync: Watchlist size %d > %d, "+ + "switching to full blocks for performance", + scanState.WatchListSize(), threshold) + + return s.scanBatchWithFullBlocks( + ctx, scanState, startHeight, hashes, + ) + } + + // Try CFilters (Fast Path). + results, err := s.scanBatchWithCFilters( + ctx, scanState, startHeight, hashes, + ) + if err == nil { + return results, nil + } + + // If CFilters are unavailable (e.g. backend doesn't support + // them), fall back to full block scanning. + if errors.Is(err, ErrCFiltersUnavailable) { + log.Warnf("Batch GetCFilters unavailable: %v. "+ + "Falling back to full block download.", err) + + return s.scanBatchWithFullBlocks( + ctx, scanState, startHeight, hashes, + ) + } + + // If scanBatchWithCFilters failed for another reason, return + // the error. + return nil, err + + default: + return nil, fmt.Errorf("%w: %v", ErrUnknownSyncMethod, + s.cfg.SyncMethod) + } +} + +// scanBatch fetches and processes a batch of blocks from the chain backend. +func (s *syncer) scanBatch(ctx context.Context, syncedTo waddrmgr.BlockStamp, + bestHeight int32) error { + + // Prepare the full recovery state for syncing. + scanState, err := s.loadFullScanState(ctx) + if err != nil { + return err + } + + // Fetch and Filter Blocks. The `fetchAndFilterBlocks` method is + // responsible for fetching a batch of blocks from the chain backend, + // filtering them for relevant transactions, and expanding address + // horizons. This phase primarily involves network I/O and in-memory + // processing. While it internally performs brief read-only database + // accesses (e.g., in `loadFullScanState`), it avoids holding + // long-lived write locks during potentially extensive network + // operations. + results, err := s.fetchAndFilterBlocks( + ctx, scanState, syncedTo.Height+1, bestHeight, + ) + if err != nil { + return err + } + // Batch might be empty if: + // 1. We were interrupted by a quit signal or rescan job (handled + // above). + // 2. We encountered a backend error fetching the first block + // hash or filter (loop broke early). + // In either case, we return an error to let the chain loop sleep and + // retry. + if len(results) == 0 { + return fmt.Errorf("%w: scan batch empty", ErrScanBatchEmpty) + } + // Process Batch (Update). We do this in a single DB transaction. + return s.DBPutSyncBatch(ctx, results) +} + // loadWalletScanData retrieves all necessary data from the database. func (s *syncer) loadWalletScanData(ctx context.Context) ( []*waddrmgr.AccountProperties, []btcutil.Address, diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 92569704ee..0a0a5ae08b 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -493,3 +493,157 @@ func TestScanBatchWithCFilters(t *testing.T) { require.Len(t, results, 1) require.Equal(t, int32(10), results[0].meta.Height) } + +// TestDispatchScanStrategy verifies strategy selection. +func TestDispatchScanStrategy(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock dependencies. + mockChain := &mockChain{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) + + scanState := NewRecoveryState(10, &chaincfg.MainNetParams, nil) + hashes := []chainhash.Hash{{0x01}} + + // 1. Test the SyncMethodFullBlocks strategy. + s.cfg.SyncMethod = SyncMethodFullBlocks + msgBlock := wire.NewMsgBlock(wire.NewBlockHeader( + 1, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + mockChain.On( + "GetBlocks", hashes, + ).Return([]*wire.MsgBlock{msgBlock}, nil).Once() + + // Act: Dispatch the scan strategy for full blocks. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 10, hashes, + ) + + // Assert: Verify that full blocks strategy was used. + require.NoError(t, err) + require.Len(t, results, 1) + + // 2. Test the SyncMethodCFilters strategy. + s.cfg.SyncMethod = SyncMethodCFilters + filter, err := gcs.BuildGCSFilter( + builder.DefaultP, builder.DefaultM, [16]byte{}, nil, + ) + require.NoError(t, err) + + mockChain.On( + "GetCFilters", hashes, wire.GCSFilterRegular, + ).Return([]*gcs.Filter{filter}, nil).Once() + mockChain.On( + "GetBlockHeaders", hashes, + ).Return([]*wire.BlockHeader{{}}, nil).Once() + + // Simulate a filter match (N=0) to force a full block download. + mockChain.On( + "GetBlocks", hashes, + ).Return([]*wire.MsgBlock{msgBlock}, nil).Once() + + // Act: Dispatch the scan strategy for CFilters. + results, err = s.dispatchScanStrategy( + t.Context(), scanState, 10, hashes, + ) + + // Assert: Verify that CFilters strategy was used. + require.NoError(t, err) + require.Len(t, results, 1) +} + +// TestScanBatch verifies the batch scanning entry point. +func TestScanBatch(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer with a test database and set up mocks + // for the batch scan. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockChain := &mockChain{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, mockAddrStore, nil, + mockPublisher, + ) + + // Mock loading of the full scan state required by the batch scan. + scopedMgr := &mockAccountStore{} + scopedMgr.On("ActiveAccounts").Return([]uint32{0}).Once() + scopedMgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + scopedMgr.On( + "AccountProperties", mock.Anything, uint32(0), + ).Return(&waddrmgr.AccountProperties{}, nil).Twice() + mockAddrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore{scopedMgr}).Once() + mockAddrStore.On( + "FetchScopedKeyManager", mock.Anything, + ).Return(scopedMgr, nil).Times(3) + mockAddrStore.On( + "ForEachRelevantActiveAddress", mock.Anything, mock.Anything, + ).Return(nil).Once() + + mockTxStore := &mockTxStore{} + s.txStore = mockTxStore + mockTxStore.On( + "OutputsToWatch", mock.Anything, + ).Return([]wtxmgr.Credit(nil), nil).Once() + + // Mock expectations for header-only scanning when no targets are + // present. + hashes := []chainhash.Hash{{0x01}} + mockChain.On( + "GetBlockHashes", int64(11), int64(11), + ).Return(hashes, nil).Once() + mockChain.On( + "GetBlockHeaders", hashes, + ).Return([]*wire.BlockHeader{{}}, nil).Once() + + // Expect the sync progress to be updated in the database. + mockAddrStore.On( + "SetSyncedTo", mock.Anything, mock.Anything, + ).Return(nil).Once() + + // Act: Perform a batch scan from height 10 to 11. + err := s.scanBatch(t.Context(), waddrmgr.BlockStamp{Height: 10}, 11) + + // Assert: Verify that the batch scan completed successfully. + require.NoError(t, err) +} + +// TestFetchAndFilterBlocks verifies the block fetching and filtering helper. +func TestFetchAndFilterBlocks(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock chain for block fetching. + mockChain := &mockChain{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) + + // Create an empty recovery state for testing. + scanState := NewRecoveryState(10, &chaincfg.MainNetParams, nil) + hashes := []chainhash.Hash{{0x01}} + + // Mock expectations for header-only scanning when the recovery state + // is empty. + mockChain.On( + "GetBlockHashes", int64(10), int64(11), + ).Return(hashes, nil).Once() + mockChain.On( + "GetBlockHeaders", hashes, + ).Return([]*wire.BlockHeader{{}}, nil).Once() + + // Act: Fetch and filter blocks for heights 10 to 11. + results, err := s.fetchAndFilterBlocks(t.Context(), scanState, 10, 11) + + // Assert: Verify that the block results are correct. + require.NoError(t, err) + require.Len(t, results, 1) +} From 58b97d059f8875ca4fd7d6715eaa2806df941dd5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:57:05 +0800 Subject: [PATCH 267/691] syncer: implement chain synchronization advancement Implements advanceChainSync to drive the sync process forward, detecting gaps and triggering batch scans. --- wallet/syncer.go | 61 +++++++++++++++++++- wallet/syncer_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/wallet/syncer.go b/wallet/syncer.go index 1ea5d53e94..2fcaecf538 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -896,7 +896,66 @@ func (s *syncer) dispatchScanStrategy(ctx context.Context, } } -// scanBatch fetches and processes a batch of blocks from the chain backend. +// advanceChainSync checks if the wallet is behind the chain tip and processes +// a batch of blocks if necessary. It returns (syncFinished, error) where +// syncFinished is true if the wallet is caught up to the best known tip, and +// false if a sync operation was performed (or attempted) indicating that the +// caller should continue polling. +func (s *syncer) advanceChainSync(ctx context.Context) (bool, error) { + // Check the chain tip. + _, bestHeight, err := s.cfg.Chain.GetBestBlock() + if err != nil { + // An error getting best block height means we couldn't + // determine sync status. We are NOT finished, and an error + // occurred. Caller should retry. + return false, fmt.Errorf("unable to get best block height: %w", + err) + } + + // Determine our current sync state. + syncedTo := s.addrStore.SyncedTo() + + // If the wallet is caught up to the best known tip, log this and + // return. + if syncedTo.Height >= bestHeight { + s.state.Store(uint32(syncStateSynced)) + log.Infof("Wallet is synced to chain tip: height=%d", + syncedTo.Height) + + return true, nil + } + + // Calculate the gap. + gap := bestHeight - syncedTo.Height + + // If the gap is large (> 6 blocks), we treat it as a major event + // requiring Syncing state protection. Smaller gaps are handled + // silently to avoid disrupting user operations like CreateTx. + isLargeGap := gap > syncStateSwitchThreshold + + if isLargeGap { + s.state.Store(uint32(syncStateSyncing)) + } + + // Wallet is behind, log the sync range and attempt to scan a batch. + log.Infof("Wallet is in syncing mode: from height %d to %d (gap=%d)", + syncedTo.Height+1, bestHeight, gap) + + err = s.scanBatch(ctx, syncedTo, bestHeight) + if err != nil { + // Scan failed. Sync operation was attempted but not finished + // due to error. + return false, fmt.Errorf("failed to process batch: %w", err) + } + + // Scan successful, but wallet might still be behind. Synchronization + // is NOT finished. Caller should continue looping to process the next + // batch. + return false, nil +} + +// scanBatch fetches and processes a batch of blocks from the chain backend. It +// handles fetching, CFilter matching, and DB updates. func (s *syncer) scanBatch(ctx context.Context, syncedTo waddrmgr.BlockStamp, bestHeight int32) error { diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 0a0a5ae08b..1e127fc87d 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -647,3 +647,133 @@ func TestFetchAndFilterBlocks(t *testing.T) { require.NoError(t, err) require.Len(t, results, 1) } + +// TestAdvanceChainSync verifies advancement logic. +func TestAdvanceChainSync(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer with a test database and mocks to + // test the chain synchronization advancement logic. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, + mockPublisher, + ) + + // Case 1: Test advancement when the wallet is already synced to the + // best block. + mockChain.On( + "GetBestBlock", + ).Return(&chainhash.Hash{}, int32(100), nil).Once() + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}, + ).Once() + + // Act & Assert: Advance the chain sync and verify that it correctly + // identifies the synced state. + finished, err := s.advanceChainSync(t.Context()) + require.NoError(t, err) + require.True(t, finished) + require.Equal(t, syncStateSynced, s.syncState()) + + // Case 2: Test advancement when the wallet is behind and needs to + // trigger a scan. + mockChain.On("GetBestBlock").Return( + &chainhash.Hash{}, int32(105), nil, + ).Once() + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}, + ).Once() + + // Set up mocks for the batch scan triggered by advancement. + // Mock loading of the full scan state. + scopedMgr := &mockAccountStore{} + mockAddrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore{scopedMgr}).Once() + scopedMgr.On("ActiveAccounts").Return([]uint32{0}).Once() + scopedMgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + mockAddrStore.On( + "FetchScopedKeyManager", mock.Anything, + ).Return(scopedMgr, nil).Times(3) + + props := &waddrmgr.AccountProperties{ + AccountNumber: 0, + KeyScope: waddrmgr.KeyScopeBIP0084, + } + scopedMgr.On( + "AccountProperties", mock.Anything, uint32(0), + ).Return(props, nil).Twice() + mockAddrStore.On( + "ForEachRelevantActiveAddress", mock.Anything, mock.Anything, + ).Return(nil).Once() + + mockTxStore.On( + "OutputsToWatch", mock.Anything, + ).Return([]wtxmgr.Credit(nil), nil).Once() + + scopedMgr.On( + "DeriveAddr", mock.Anything, mock.Anything, mock.Anything, + ).Return( + &mockAddress{}, []byte{}, nil, + ).Maybe() + + // Mock fetching and filtering of blocks for the missing height range. + // Mock retrieval of block hashes when scan targets are present. + hashes := []chainhash.Hash{{0x01}, {0x02}, {0x03}, {0x04}, {0x05}} + mockChain.On( + "GetBlockHashes", int64(101), int64(105), + ).Return(hashes, nil).Once() + + // Mock the scan strategy dispatch for the block batch. + filter, err := gcs.BuildGCSFilter( + builder.DefaultP, builder.DefaultM, [16]byte{}, nil, + ) + require.NoError(t, err) + + filters := make([]*gcs.Filter, 5) + for i := range 5 { + filters[i] = filter + } + + mockChain.On( + "GetCFilters", hashes, wire.GCSFilterRegular, + ).Return(filters, nil).Once() + + headers := make([]*wire.BlockHeader, 5) + for i := range 5 { + headers[i] = &wire.BlockHeader{} + } + + mockChain.On("GetBlockHeaders", hashes).Return(headers, nil).Once() + + // Simulate filter matches for all blocks to force full block downloads. + msgBlock := wire.NewMsgBlock(wire.NewBlockHeader( + 1, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + + blocks := make([]*wire.MsgBlock, 5) + for i := range 5 { + blocks[i] = msgBlock + } + + mockChain.On("GetBlocks", hashes).Return(blocks, nil).Once() + + // Expect the sync progress to be updated for each block in the batch. + mockAddrStore.On( + "SetSyncedTo", mock.Anything, mock.Anything, + ).Return(nil).Times(5) + + // Act & Assert: Advance the chain sync and verify that it triggers + // the expected batch scan. + finished, err = s.advanceChainSync(t.Context()) + require.NoError(t, err) + require.False(t, finished) +} From 35464ea4db324bcd18adca4b21545b2991bc637d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:57:32 +0800 Subject: [PATCH 268/691] syncer: implement chain update and address extraction logic Implements processChainUpdate and address extraction logic to handle block connections and transaction discovery. --- wallet/syncer.go | 98 +++++++++++++++++++++++++++++++++++++++++++ wallet/syncer_test.go | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/wallet/syncer.go b/wallet/syncer.go index 2fcaecf538..aec99c76f8 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/btcutil/gcs" "github.com/btcsuite/btcd/btcutil/gcs/builder" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" @@ -993,6 +994,103 @@ func (s *syncer) scanBatch(ctx context.Context, syncedTo waddrmgr.BlockStamp, return s.DBPutSyncBatch(ctx, results) } +// handleChainUpdate processes a notification immediately. +// It returns an error if processing fails or if the wallet is shutting down. +func (s *syncer) handleChainUpdate(ctx context.Context, n any) error { + // For a single update, we process it and commit immediately. + err := s.processChainUpdate(ctx, n) + if err != nil { + return fmt.Errorf("failed to process chain update: %w", err) + } + + switch msg := n.(type) { + case *chain.RescanProgress: + log.Debugf("Rescanned through block %v (height %d)", + msg.Hash, msg.Height) + + // Consume and log the legacy RescanFinished notification. We no longer + // perform state updates here as the new controller- driven sync loop + // manages wallet synchronization. + case *chain.RescanFinished: + log.Debugf("Received legacy RescanFinished notification for "+ + "block %v (height %d). No wallet state updates "+ + "performed.", msg.Hash, msg.Height) + } + + return nil +} + +// processChainUpdate writes a single chain update to the database. +func (s *syncer) processChainUpdate(ctx context.Context, update any) error { + switch n := update.(type) { + case chain.BlockConnected: + return s.DBPutSyncTip(ctx, wtxmgr.BlockMeta(n)) + + // A block was disconnected. We use checkRollback to safely verify our + // chain state against the backend and rewind if necessary. This + // handles both single block disconnects and deeper reorgs robustly. + case chain.BlockDisconnected: + return s.checkRollback(ctx) + + // We only expect individual transaction notifications for unconfirmed + // transactions as they enter the mempool. Confirmed transactions are + // handled atomically via FilteredBlockConnected. + case chain.RelevantTx: + matches := s.prepareTxMatches([]*wtxmgr.TxRecord{n.TxRecord}) + return s.DBPutTxns(ctx, matches, n.Block) + + case chain.FilteredBlockConnected: + matches := s.prepareTxMatches(n.RelevantTxs) + return s.DBPutBlocks(ctx, matches, n.Block) + } + + return nil +} + +// prepareTxMatches extracts address entries from a batch of transactions and +// groups them by transaction hash. +func (s *syncer) prepareTxMatches(recs []*wtxmgr.TxRecord) TxEntries { + matches := make(TxEntries, 0, len(recs)) + for _, rec := range recs { + entries := s.extractAddrEntries(rec.MsgTx.TxOut) + matches = append(matches, TxEntry{ + Rec: rec, + Entries: entries, + }) + } + + return matches +} + +// extractAddrEntries collects all addresses from transaction outputs and +// creates initial AddrEntry objects with output indices. +func (s *syncer) extractAddrEntries(txOuts []*wire.TxOut) []AddrEntry { + var entries []AddrEntry + for i, output := range txOuts { + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + output.PkScript, s.cfg.ChainParams, + ) + if err != nil { + log.Warnf("Cannot extract non-std pkScript=%x", + output.PkScript) + + continue + } + + for _, addr := range addrs { + entries = append(entries, AddrEntry{ + Address: addr, + Credit: wtxmgr.CreditEntry{ + //nolint:gosec // bounded. + Index: uint32(i), + }, + }) + } + } + + return entries +} + // loadWalletScanData retrieves all necessary data from the database. func (s *syncer) loadWalletScanData(ctx context.Context) ( []*waddrmgr.AccountProperties, []btcutil.Address, diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 1e127fc87d..7d4e86e93e 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -5,11 +5,14 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/gcs" "github.com/btcsuite/btcd/btcutil/gcs/builder" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/mock" @@ -777,3 +780,79 @@ func TestAdvanceChainSync(t *testing.T) { require.NoError(t, err) require.False(t, finished) } + +// TestHandleChainUpdate verifies notification handling. +func TestHandleChainUpdate(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock its dependencies for + // handling chain updates. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, + mockPublisher, + ) + + // Case 1: Test handling of a BlockConnected notification. + meta := wtxmgr.BlockMeta{Block: wtxmgr.Block{Height: 100}} + + mockAddrStore.On( + "SetSyncedTo", mock.Anything, mock.Anything, + ).Return(nil).Once() + + // Act & Assert: Verify that a BlockConnected notification is + // correctly processed. + err := s.handleChainUpdate(t.Context(), chain.BlockConnected(meta)) + require.NoError(t, err) + + // Case 2: Test handling of a RelevantTx notification. + tx := wire.NewMsgTx(1) + rec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) + require.NoError(t, err) + mockTxStore.On( + "InsertUnconfirmedTx", mock.Anything, mock.Anything, + mock.Anything, + ).Return(nil).Once() + + // Act & Assert: Verify that a RelevantTx notification is correctly + // processed. + err = s.handleChainUpdate(t.Context(), chain.RelevantTx{TxRecord: rec}) + require.NoError(t, err) +} + +// TestExtractAddrEntries verifies address extraction from outputs. +func TestExtractAddrEntries(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and create a P2PKH output for address + // extraction. + mockPublisher := &mockTxPublisher{} + s := newSyncer( + Config{ChainParams: &chaincfg.MainNetParams}, nil, nil, + mockPublisher, + ) + + addr, err := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chaincfg.MainNetParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + txOut := &wire.TxOut{Value: 1000, PkScript: pkScript} + + // Act: Extract address entries from the output. + entries := s.extractAddrEntries([]*wire.TxOut{txOut}) + + // Assert: Verify that the correct address was extracted. + require.Len(t, entries, 1) + require.Equal(t, addr.String(), entries[0].Address.String()) + require.Equal(t, uint32(0), entries[0].Credit.Index) +} From c58fbfaedab3e1a8be9ada0c9733cdbf806abf65 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:58:00 +0800 Subject: [PATCH 269/691] syncer: implement scan request handling and targeted scanning Implements handling for user-initiated scanReq jobs, including targeted rescans and rewinds. --- wallet/syncer.go | 135 +++++++++++++++++++++++++++++++++++++++++- wallet/syncer_test.go | 109 ++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) diff --git a/wallet/syncer.go b/wallet/syncer.go index aec99c76f8..cf7569684f 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -1091,7 +1091,140 @@ func (s *syncer) extractAddrEntries(txOuts []*wire.TxOut) []AddrEntry { return entries } -// loadWalletScanData retrieves all necessary data from the database. +// handleScanReq processes a user-initiated rescan request. +func (s *syncer) handleScanReq(ctx context.Context, + req *scanReq) error { + + // If the wallet is already syncing or rescanning, we can't accept a + // full resync request. This prevents conflicting rescan operations. + if s.isRecoveryMode() { + return fmt.Errorf("%w: wallet is currently %s", + ErrStateForbidden, s.syncState()) + } + + if req.typ == scanTypeTargeted { + return s.scanWithTargets(ctx, req) + } + + return s.scanWithRewind(ctx, req) +} + +// scanWithRewind rewinds the wallet's sync status to the requested start block. +func (s *syncer) scanWithRewind(ctx context.Context, req *scanReq) error { + current := s.addrStore.SyncedTo() + + if req.startBlock.Height >= current.Height { + // Requested start is ahead of or equal to current sync. + // Nothing to do (we are already synced past it). + return nil + } + + log.Infof("Rewinding sync status from %d to %d for rescan", + current.Height, req.startBlock.Height) + + // Rewind the database status. + err := s.DBPutRewind(ctx, req.startBlock) + if err != nil { + log.Errorf("Failed to rewind sync status: %v", err) + + return err + } + + return nil +} + +// scanWithTargets performs a targeted rescan for specific accounts without +// rewinding the global sync state. +func (s *syncer) scanWithTargets(ctx context.Context, req *scanReq) error { + scanState, err := s.loadTargetedScanState(ctx, req.targets) + if err != nil { + return err + } + + s.state.Store(uint32(syncStateRescanning)) + defer s.state.Store(uint32(syncStateSynced)) + + startHeight := req.startBlock.Height + + _, bestHeight, err := s.cfg.Chain.GetBestBlock() + if err != nil { + return fmt.Errorf("get best block: %w", err) + } + + log.Infof("Starting targeted rescan from height %d to %d for %d "+ + "accounts", startHeight, bestHeight, len(req.targets)) + + // Loop until caught up. + for startHeight < bestHeight { + // Cap end height. + endHeight := min( + startHeight+int32(recoveryBatchSize)-1, bestHeight, + ) + + // Use fetchAndFilterBlocks directly. + results, err := s.fetchAndFilterBlocks( + ctx, scanState, startHeight, endHeight, + ) + if err != nil { + return err + } + + if len(results) == 0 { + return fmt.Errorf("%w: fetchAndFilterBlocks returned "+ + "0 results", ErrScanBatchEmpty) + } + + // Process results (update DB). + err = s.DBPutTargetedBatch(ctx, results) + if err != nil { + return err + } + + // Advance startHeight. + //nolint:gosec // batch size is bounded. + startHeight += int32(len(results)) + } + + log.Infof("Targeted rescan complete") + + return nil +} + +// loadTargetedScanState initializes a recovery state for a targeted rescan of +// specific accounts. +func (s *syncer) loadTargetedScanState(ctx context.Context, + targets []waddrmgr.AccountScope) (*RecoveryState, error) { + + horizonData, initialAddrs, initialUnspent, err := + s.loadTargetedScanData(ctx, targets) + if err != nil { + return nil, err + } + + state := NewRecoveryState( + s.cfg.RecoveryWindow, s.cfg.ChainParams, s.addrStore, + ) + + err = state.Initialize(horizonData, initialAddrs, initialUnspent) + if err != nil { + return nil, fmt.Errorf("init scan state: %w", err) + } + + return state, nil +} + +// loadTargetedScanData retrieves all necessary data from the database to +// initialize the recovery state for a targeted rescan. +func (s *syncer) loadTargetedScanData(ctx context.Context, + targets []waddrmgr.AccountScope) ([]*waddrmgr.AccountProperties, + []btcutil.Address, []wtxmgr.Credit, error) { + + return s.DBGetScanData(ctx, targets) +} + +// loadWalletScanData retrieves all necessary data from the database to +// initialize the recovery state. This includes account horizons, active +// addresses, and unspent outputs to watch. func (s *syncer) loadWalletScanData(ctx context.Context) ( []*waddrmgr.AccountProperties, []btcutil.Address, []wtxmgr.Credit, error) { diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 7d4e86e93e..b1e3cd2552 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -856,3 +856,112 @@ func TestExtractAddrEntries(t *testing.T) { require.Equal(t, addr.String(), entries[0].Address.String()) require.Equal(t, uint32(0), entries[0].Credit.Index) } + +// TestHandleScanReq verifies scan request handling. +func TestHandleScanReq(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer with a test database and mocks to + // test handling of different scan request types. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, mockPublisher, + ) + + // Case 1: Test handling of a rewind scan request. + req := &scanReq{ + typ: scanTypeRewind, + startBlock: waddrmgr.BlockStamp{Height: 50}, + } + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}, + ).Once() + + // Expect sync state update and transaction rollback for the rewind. + mockAddrStore.On( + "SetSyncedTo", mock.Anything, mock.Anything, + ).Return(nil).Once() + + mockTxStore := &mockTxStore{} + s.txStore = mockTxStore + mockTxStore.On("Rollback", mock.Anything, int32(51)).Return(nil).Once() + + // Act & Assert: Verify that a rewind scan request is correctly handled. + err := s.handleScanReq(t.Context(), req) + require.NoError(t, err) + + // Case 2: Test handling of a targeted scan request. + req = &scanReq{ + typ: scanTypeTargeted, + startBlock: waddrmgr.BlockStamp{Height: 100}, + targets: []waddrmgr.AccountScope{{Account: 1}}, + } + mockChain := &mockChain{} + s.cfg.Chain = mockChain + mockChain.On("GetBestBlock").Return( + &chainhash.Hash{}, int32(101), nil, + ).Once() + + // Mock loading of targeted scan data. + scopedMgr := &mockAccountStore{} + mockAddrStore.On( + "FetchScopedKeyManager", mock.Anything, + ).Return(scopedMgr, nil).Times(3) + + // Set up mocks for initializing targeted scan state. + props := &waddrmgr.AccountProperties{ + AccountNumber: 1, + KeyScope: waddrmgr.KeyScopeBIP0084, + } + scopedMgr.On( + "AccountProperties", mock.Anything, uint32(1), + ).Return(props, nil).Twice() + // ActiveAccounts might not be called in targeted scan flow. + scopedMgr.On("ActiveAccounts").Return([]uint32{1}).Maybe() + mockAddrStore.On( + "ForEachRelevantActiveAddress", mock.Anything, mock.Anything, + ).Return(nil).Once() + mockTxStore.On( + "OutputsToWatch", mock.Anything, + ).Return([]wtxmgr.Credit(nil), nil).Once() + + // DeriveAddr is called multiple times during state initialization. + // Use Maybe() to avoid assertions on specific iteration counts. + scopedMgr.On( + "DeriveAddr", mock.Anything, mock.Anything, mock.Anything, + ).Return(&mockAddress{}, []byte{}, nil).Maybe() + + // Mock block hash retrieval for the targeted scan range. + mockChain.On( + "GetBlockHashes", int64(100), int64(101), + ).Return([]chainhash.Hash{{0x01}, {0x02}}, nil).Once() + + // Mock CFilter-based scanning for the targeted scan. + mockChain.On( + "GetCFilters", mock.Anything, mock.Anything, + ).Return([]*gcs.Filter{nil, nil}, nil).Once() + mockChain.On( + "GetBlockHeaders", mock.Anything, + ).Return([]*wire.BlockHeader{{}, {}}, nil).Once() + + msgBlock := wire.NewMsgBlock(wire.NewBlockHeader( + 1, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + + blocks := make([]*wire.MsgBlock, 2) + for i := range 2 { + blocks[i] = msgBlock + } + + mockChain.On("GetBlocks", mock.Anything).Return(blocks, nil).Once() + + // Act & Assert: Verify that a targeted scan request is correctly + // handled. + err = s.handleScanReq(t.Context(), req) + require.NoError(t, err) +} \ No newline at end of file From d3b4ba171f22710cdb3b595782ceb307745ee2d0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 23 Dec 2025 17:58:21 +0800 Subject: [PATCH 270/691] syncer: implement main synchronization loop and broadcasting Implements the waitForEvent loop to efficiently idle and respond to chain notifications or jobs. Finalizes the syncer.run loop, stitching together initialization, advancement, and event handling. --- wallet/syncer.go | 102 ++++++++++++++++- wallet/syncer_test.go | 257 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 357 insertions(+), 2 deletions(-) diff --git a/wallet/syncer.go b/wallet/syncer.go index cf7569684f..ae160b6048 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -408,6 +408,58 @@ func (s *syncer) findForkPoint(localHashes []*chainhash.Hash, // run executes the main synchronization loop. func (s *syncer) run(ctx context.Context) error { + // Initialize the chain sync state. + err := s.initChainSync(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || + errors.Is(err, ErrWalletShuttingDown) { + + return nil + } + + return fmt.Errorf("initialize chain sync: %w", err) + } + + for { + err := s.runSyncStep(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || + errors.Is(err, ErrWalletShuttingDown) { + + return nil + } + + return err + } + } +} + +// runSyncStep performs a single iteration of the synchronization loop. It +// advances the chain sync state, broadcasts unmined transactions, and then +// waits for the next event (notification or job). +func (s *syncer) runSyncStep(ctx context.Context) error { + // Attempt to advance the wallet's sync state. + syncFinished, err := s.advanceChainSync(ctx) + if err != nil { + return fmt.Errorf("advance chain sync: %w", err) + } + + if !syncFinished { + return nil + } + + // Rebroadcast unmined transactions. + err = s.broadcastUnminedTxns(ctx) + if err != nil { + return fmt.Errorf("broadcast unmined txns: %w", err) + } + + // Proceed to idle mode, waiting for notifications or jobs. + err = s.waitForEvent(ctx) + if err != nil { + return err + } + return nil } @@ -422,6 +474,28 @@ func (s *syncer) requestScan(ctx context.Context, req *scanReq) error { } } +// broadcastUnminedTxns retrieves all unmined transactions from the wallet and +// attempts to re-broadcast them to the network. +func (s *syncer) broadcastUnminedTxns(ctx context.Context) error { + txs, err := s.DBGetUnminedTxns(ctx) + if err != nil { + log.Errorf("Unable to retrieve unconfirmed transactions to "+ + "resend: %v", err) + + return fmt.Errorf("failed to retrieve unconfirmed txs: %w", err) + } + + for _, tx := range txs { + err := s.publisher.Broadcast(ctx, tx, "") + if err != nil { + log.Warnf("Unable to rebroadcast tx %v: %v", + tx.TxHash(), err) + } + } + + return nil +} + // scanBatchHeadersOnly performs a lightweight scan by only fetching block // headers. This is used when the wallet has no addresses or outpoints to // watch, allowing it to fast-forward its sync state. @@ -1109,7 +1183,33 @@ func (s *syncer) handleScanReq(ctx context.Context, return s.scanWithRewind(ctx, req) } -// scanWithRewind rewinds the wallet's sync status to the requested start block. +// waitForEvent blocks until a notification, rescan job, or context +// cancellation occurs, processing the event accordingly. +func (s *syncer) waitForEvent(ctx context.Context) error { + select { + // Process asynchronous notifications from the chain backend, such as + // new blocks or transactions. + case n, ok := <-s.cfg.Chain.Notifications(): + if !ok { + return ErrWalletShuttingDown + } + + return s.handleChainUpdate(ctx, n) + + // Handle synchronous rescan or resync requests submitted via the + // controller. + case job := <-s.scanReqChan: + return s.handleScanReq(ctx, job) + + // Exit gracefully if the context is canceled or the wallet is shutting + // down. + case <-ctx.Done(): + return ctx.Err() + } +} + +// scanWithRewind rewinds the wallet's sync status to the requested start +// block. func (s *syncer) scanWithRewind(ctx context.Context, req *scanReq) error { current := s.addrStore.SyncedTo() diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index b1e3cd2552..d3aef2031e 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -2,6 +2,7 @@ package wallet import ( "context" + "errors" "testing" "time" @@ -964,4 +965,258 @@ func TestHandleScanReq(t *testing.T) { // handled. err = s.handleScanReq(t.Context(), req) require.NoError(t, err) -} \ No newline at end of file +} + +// TestWaitForEvent verifies event loop idling and dispatch. +func TestWaitForEvent(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock its dependencies for testing + // the event loop. + mockChain := &mockChain{} + mockPublisher := &mockTxPublisher{} + mockAddrStore := &mockAddrStore{} + + db, cleanup := setupTestDB(t) + defer cleanup() + + s := newSyncer( + Config{ + Chain: mockChain, + DB: db, + }, + mockAddrStore, nil, mockPublisher, + ) + + // Mock chain notifications channel. + notificationChan := make(chan any, 1) + mockChain.On("Notifications").Return((<-chan any)(notificationChan)) + + // Case 1: Test event handling when a chain notification arrives. + notificationChan <- chain.BlockConnected{} + + // Mock sync progress update resulting from the chain notification. + mockAddrStore.On( + "SetSyncedTo", mock.Anything, mock.Anything, + ).Return(nil).Once() + + // Act & Assert: Call waitForEvent and verify it correctly processes + // the arriving notification. + err := s.waitForEvent(t.Context()) + require.NoError(t, err) + + // Case 2: Test event handling when a scan request arrives. + s.scanReqChan <- &scanReq{typ: scanTypeRewind} + + mockAddrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{}).Once() + + // Act & Assert: Call waitForEvent and verify it correctly processes + // the arriving scan request. + err = s.waitForEvent(t.Context()) + require.NoError(t, err) +} + +// TestSyncerFullRun verifies the full run loop coordination. +func TestSyncerFullRun(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer with a test database and set up + // extensive mocks to simulate a full run loop execution. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, mockAddrStore, nil, + mockPublisher, + ) + + // Mock initial chain sync sequence. + mockAddrStore.On("Birthday").Return(time.Now()).Once() + mockChain.On("IsCurrent").Return(true).Once() + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}, + ).Once() + + // Mock rollback check dependencies. + mockAddrStore.On( + "BlockHash", mock.Anything, mock.Anything, + ).Return(&chainhash.Hash{}, nil).Maybe() + + // Mock remote hashes for rollback check (batch size 10). + remoteHashes := make([]chainhash.Hash, 10) + mockChain.On( + "GetBlockHashes", mock.Anything, mock.Anything, + ).Return(remoteHashes, nil).Maybe() + mockChain.On("NotifyBlocks").Return(nil).Once() + + // Mock advancement to the current best block. + mockChain.On( + "GetBestBlock", + ).Return(&chainhash.Hash{}, int32(100), nil).Once() + + // Mock synced state retrieval. + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}, + ).Once() + + // Mock retrieval of unmined transactions from the store. + mockTxStore := &mockTxStore{} + s.txStore = mockTxStore + mockTxStore.On("UnminedTxs", mock.Anything).Return( + []*wire.MsgTx(nil), nil, + ).Once() + + // Set up for the event waiting phase of the run loop. + ctx, cancel := context.WithCancel(t.Context()) + + // Use a goroutine to cancel the context after a delay to allow the + // syncer to enter its event loop. + go func() { + time.Sleep(1500 * time.Millisecond) + cancel() + }() + + notificationChan := make(chan any) + mockChain.On("Notifications").Return((<-chan any)(notificationChan)) + + // Act & Assert: Execute the syncer's run loop and verify that it + // completes all initial sync steps and enters the idle loop. + err := s.run(ctx) + require.NoError(t, err) +} + +var ( + errDBMockSync = errors.New("db error") + errCFilter = errors.New("not supported") +) + +// TestProcessChainUpdate_Disconnect verifies rollback on block disconnect. +func TestProcessChainUpdate_Disconnect(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock its dependencies for handling + // a block disconnect. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, + mockPublisher, + ) + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}, + ).Once() + + mockAddrStore.On( + "BlockHash", mock.Anything, mock.Anything, + ).Return(&chainhash.Hash{}, nil).Maybe() + + remoteHashes := make([]chainhash.Hash, 10) + mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( + remoteHashes, nil, + ).Once() + + // Act & Assert: Process a BlockDisconnected notification and verify + // that it triggers a rollback check. + err := s.processChainUpdate(t.Context(), chain.BlockDisconnected{}) + require.NoError(t, err) +} + +// TestBroadcastUnminedTxns_Error verifies error handling. +func TestBroadcastUnminedTxns_Error(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock an error during unmined + // transactions retrieval. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher) + + mockTxStore.On("UnminedTxs", mock.Anything).Return( + ([]*wire.MsgTx)(nil), errDBMockSync, + ).Once() + + // Act & Assert: Verify that broadcasting unmined transactions + // returns the expected database error. + err := s.broadcastUnminedTxns(t.Context()) + require.Error(t, err) +} + +// TestInitChainSync_BackendNotSynced verifies it waits/errors. +func TestInitChainSync_BackendNotSynced(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock the backend as not being + // current to test initialization timeout. + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer( + Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + ) + + mockAddrStore.On("Birthday").Return(time.Now()).Once() + mockChain.On("IsCurrent").Return(false) + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + // Act & Assert: Verify that initialization fails due to timeout + // when the backend never becomes current. + err := s.initChainSync(ctx) + require.Error(t, err) +} + +// TestDispatchScanStrategy_CFilterFail verifies fallback. +func TestDispatchScanStrategy_CFilterFail(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and mock a CFilter retrieval failure + // to test fallback to full block scanning. + mockChain := &mockChain{} + mockPublisher := &mockTxPublisher{} + s := newSyncer( + Config{Chain: mockChain, SyncMethod: SyncMethodAuto}, nil, nil, + mockPublisher, + ) + mockAddrStore := &mockAddrStore{} + scanState := NewRecoveryState( + 10, &chaincfg.MainNetParams, mockAddrStore, + ) + hashes := []chainhash.Hash{{0x01}} + + mockChain.On( + "GetCFilters", hashes, wire.GCSFilterRegular, + ).Return(([]*gcs.Filter)(nil), errCFilter).Once() + + msgBlock := wire.NewMsgBlock(wire.NewBlockHeader( + 1, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + mockChain.On( + "GetBlocks", hashes, + ).Return([]*wire.MsgBlock{msgBlock}, nil).Once() + + // Act: Dispatch the scan strategy when CFilters are not supported. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 10, hashes, + ) + + // Assert: Verify that the scan fell back to full blocks successfully. + require.NoError(t, err) + require.Len(t, results, 1) +} From 964b1eb9d335fc0ebca5d81b6ecca51ae050fec7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Dec 2025 20:07:15 +0800 Subject: [PATCH 271/691] wallet: implement Controller 'Rescan' and 'Resync' methods --- wallet/controller.go | 70 +++++++++++++++++++++++++++++++++++++++ wallet/controller_test.go | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/wallet/controller.go b/wallet/controller.go index 1cf8ee999f..95d1c188f7 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "time" "github.com/btcsuite/btcd/chaincfg" @@ -350,6 +351,75 @@ func (w *Wallet) Info(_ context.Context) (*Info, error) { return info, nil } +// Resync rewinds the wallet's synchronization state to a specific block +// height. +// +// This is part of the Controller interface. +func (w *Wallet) Resync(ctx context.Context, startHeight uint32) error { + return w.submitRescanRequest( + ctx, scanTypeRewind, startHeight, nil, + ) +} + +// Rescan initiates a targeted rescan for specific accounts or addresses +// starting from the given block height. This operation scans for +// relevant transactions without rewinding the wallet's global +// synchronization state. +func (w *Wallet) Rescan(ctx context.Context, startHeight uint32, + targets []waddrmgr.AccountScope) error { + + if len(targets) == 0 { + return ErrNoScanTargets + } + + return w.submitRescanRequest( + ctx, scanTypeTargeted, startHeight, targets, + ) +} + +// submitRescanRequest validates the rescan request and submits it to the +// syncer. +func (w *Wallet) submitRescanRequest(ctx context.Context, typ scanType, + startHeight uint32, targets []waddrmgr.AccountScope) error { + + // Ensure the wallet is running and synced. + err := w.state.validateSynced() + if err != nil { + return err + } + + // BlockStamp.Height is int32, so we need to ensure the requested + // startHeight does not exceed math.MaxInt32. + if startHeight > math.MaxInt32 { + return fmt.Errorf("%w: %d", ErrStartHeightTooLarge, startHeight) + } + + startHeightInt32 := int32(startHeight) + + // Fetch the current best block to ensure we don't resync past the tip. + _, bestHeightInt32, err := w.cfg.Chain.GetBestBlock() + if err != nil { + return fmt.Errorf("unable to get chain tip: %w", err) + } + + if startHeightInt32 > bestHeightInt32 { + return fmt.Errorf("%w: start height %d is greater than "+ + "current chain tip %d", ErrStartHeightTooHigh, + startHeight, bestHeightInt32) + } + + // Submit the rescan request to the syncer. + req := &scanReq{ + typ: typ, + startBlock: waddrmgr.BlockStamp{ + Height: startHeightInt32, + }, + targets: targets, + } + + return w.sync.requestScan(ctx, req) +} + // mainLoop is the central event loop for the wallet, responsible for // coordinating and serializing all lifecycle and authentication requests. It // manages the transition between locked and unlocked states and handles the diff --git a/wallet/controller_test.go b/wallet/controller_test.go index 69bf68f75b..2c2642de33 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -5,6 +5,7 @@ import ( "sync" "testing" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -521,3 +522,66 @@ func TestControllerInfo(t *testing.T) { require.NoError(t, err) w.wg.Wait() } + +// TestControllerResync verifies the Resync method. +func TestControllerResync(t *testing.T) { + t.Parallel() + + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + deps.syncer.On("syncState").Return(syncStateSynced) + + // 1. Start height too high. + deps.chain.On("GetBestBlock").Return( + &chainhash.Hash{}, int32(100), nil, + ).Once() + + err := w.Resync(t.Context(), 101) + require.ErrorIs(t, err, ErrStartHeightTooHigh) + + // 2. Success. + deps.chain.On( + "GetBestBlock", + ).Return(&chainhash.Hash{}, int32(100), nil).Once() + deps.syncer.On("requestScan", mock.Anything, mock.MatchedBy( + func(req *scanReq) bool { + return req.typ == scanTypeRewind && + req.startBlock.Height == 50 + }, + )).Return(nil).Once() + + err = w.Resync(t.Context(), 50) + require.NoError(t, err) +} + +// TestControllerRescan verifies the Rescan method. +func TestControllerRescan(t *testing.T) { + t.Parallel() + + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + deps.syncer.On("syncState").Return(syncStateSynced) + + targets := []waddrmgr.AccountScope{{Account: 1}} + + // 1. No targets. + err := w.Rescan(t.Context(), 50, nil) + require.ErrorIs(t, err, ErrNoScanTargets) + + // 2. Success. + deps.chain.On( + "GetBestBlock", + ).Return(&chainhash.Hash{}, int32(100), nil).Once() + deps.syncer.On("requestScan", mock.Anything, mock.MatchedBy( + func(req *scanReq) bool { + return req.typ == scanTypeTargeted && + req.startBlock.Height == 50 && + len(req.targets) == 1 + }, + )).Return(nil).Once() + + err = w.Rescan(t.Context(), 50, targets) + require.NoError(t, err) +} From 67184c5efa798d077c4dddbbabc180cf8020cc90 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 14 Jan 2026 22:01:43 +0800 Subject: [PATCH 272/691] wallet: fix linter errors --- wallet/syncer.go | 3 +-- wallet/syncer_test.go | 16 +++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/wallet/syncer.go b/wallet/syncer.go index ae160b6048..625f531963 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -1,4 +1,3 @@ -//nolint:unused,revive // TODO(yy): remove it once implemented package wallet import ( @@ -470,7 +469,7 @@ func (s *syncer) requestScan(ctx context.Context, req *scanReq) error { return nil case <-ctx.Done(): - return fmt.Errorf("context done: %w", ctx.Err()) + return ctx.Err() } } diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index d3aef2031e..40732249d8 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -62,8 +62,8 @@ func TestSyncerRequestScan(t *testing.T) { }, } - // Act: Submit request. - err := s.requestScan(context.Background(), req) + // Act: Submit the rewind request to the syncer. + err := s.requestScan(t.Context(), req) // Assert: Ensure the request is accepted without error and is // correctly placed in the scan request channel. @@ -91,9 +91,10 @@ func TestSyncerRequestScanBlocked(t *testing.T) { // Fill the buffer (size 1). s.scanReqChan <- &scanReq{} - // Act: Submit another request with a canceled context. - ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel immediately. + // Act: Attempt to submit another request with a context that is + // already canceled. + ctx, cancel := context.WithCancel(t.Context()) + cancel() err := s.requestScan(ctx, &scanReq{}) @@ -124,8 +125,9 @@ func TestSyncerRun(t *testing.T) { mockAddrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{}).Maybe() mockChain.On("NotifyBlocks").Return(nil).Maybe() - // Act: Run with canceled context to stop loop immediately. - ctx, cancel := context.WithCancel(context.Background()) + // Act: Execute the syncer's run loop with a context that is canceled + // immediately to stop the loop. + ctx, cancel := context.WithCancel(t.Context()) cancel() // Assert: The run loop should exit without error. From 955c617fe3aa149b3ed2066165fc90b939f4fe7b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 30 Dec 2025 18:26:55 +0800 Subject: [PATCH 273/691] gitignore: add coverage report file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8dce5946b3..172541d00c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage.txt .vscode .DS_Store .aider* +coverage.out From 71415a41d34d525d4232f93f343a5f2c09282819 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 16 Jan 2026 02:31:05 +0800 Subject: [PATCH 274/691] wallet: add test errors and improve wallet mock creation Add a comprehensive set of error variables to common_test.go for use across unit tests. Update createTestWalletWithMocks to initialize the wallet with a proper cancellable context, ensuring better control over test lifecycles. --- wallet/common_test.go | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/wallet/common_test.go b/wallet/common_test.go index 8a066d9ef5..cb7542c2f4 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -15,8 +15,38 @@ import ( ) var ( - errDBMock = errors.New("db error") - errMock = errors.New("mock error") + errDBMock = errors.New("db error") + errMock = errors.New("mock error") + errChainMock = errors.New("chain error") + errPutMock = errors.New("put error") + errLockMock = errors.New("lock fail") + errDBFail = errors.New("db fail") + errDeriveFail = errors.New("derive fail") + errLoadStateFail = errors.New("load state fail") + errRollbackFail = errors.New("rollback fail") + errFetchFail = errors.New("fetch fail") + errCFilterFail = errors.New("cfilter fail") + errActiveMgrsFail = errors.New("active managers fail") + + errSetFail = errors.New("set fail") + errOther = errors.New("other error") + errBroadcast = errors.New("broadcast fail") + errScan = errors.New("scan fail") + errBlocks = errors.New("blocks fail") + errDBInsert = errors.New("db insert fail") + errBestBlock = errors.New("best block fail") + errAddr = errors.New("addr fail") + errInsert = errors.New("insert fail") + errManager = errors.New("manager fail") + errUtxo = errors.New("utxo fail") + errGetBlocks = errors.New("get blocks fail") + errBlockHash = errors.New("block hash fail") + errSetSync = errors.New("set sync fail") + errRemote = errors.New("remote fail") + errNotify = errors.New("notify fail") + errHashes = errors.New("hashes fail") + errHeaders = errors.New("headers fail") + errHeader = errors.New("header fail") ) // setupTestDB creates a temporary database for testing. @@ -76,13 +106,15 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { mockSyncer := &mockChainSyncer{} mockChain := &mockChain{} + ctx, cancel := context.WithCancel(t.Context()) + w := &Wallet{ addrStore: mockAddrStore, txStore: mockTxStore, sync: mockSyncer, state: newWalletState(mockSyncer), - lifetimeCtx: context.Background(), - cancel: func() {}, + lifetimeCtx: ctx, + cancel: cancel, requestChan: make(chan any, 1), lockTimer: time.NewTimer(time.Hour), birthdayBlock: waddrmgr.BlockStamp{ From 97ab602b83826236b914434bdb9eb421e4ddab64 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 16 Jan 2026 02:31:29 +0800 Subject: [PATCH 275/691] wallet: add mockNeutrinoChain for interface compliance Introduce mockNeutrinoChain struct in mock_test.go, which embeds mockChain and explicitly implements the chain.NeutrinoChainService interface. This resolves method signature conflicts between chain.Interface and NeutrinoChainService (specifically for GetBlock and GetCFilter) and allows for robust testing of Neutrino-specific logic without polluting the general mockChain. --- wallet/mock_test.go | 170 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 4d0ebdb2a0..6d3cba8478 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -24,6 +24,9 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/lightninglabs/neutrino" + "github.com/lightninglabs/neutrino/banman" + "github.com/lightninglabs/neutrino/headerfs" "github.com/stretchr/testify/mock" ) @@ -1024,6 +1027,173 @@ func (m *mockChain) MapRPCErr(err error) error { return args.Error(0) } +// mockNeutrinoChain is a mock implementation of the chain.NeutrinoChainService +// interface. +type mockNeutrinoChain struct { + mockChain +} + +// A compile-time assertion to ensure that mockNeutrinoChain implements the +// chain.NeutrinoChainService. +var _ chain.NeutrinoChainService = (*mockNeutrinoChain)(nil) + +// Stop implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) Stop() error { + args := m.Called() + return args.Error(0) +} + +// GetBlock implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) GetBlock(hash chainhash.Hash, + opts ...neutrino.QueryOption) (*btcutil.Block, error) { + + args := m.Called(hash, opts) + if args.Get(0) != nil { + if val, ok := args.Get(0).(*btcutil.Block); ok { + return val, args.Error(1) + } + } + + return nil, args.Error(1) +} + +// GetCFilter implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) GetCFilter(hash chainhash.Hash, + filterType wire.FilterType, + opts ...neutrino.QueryOption) (*gcs.Filter, error) { + + args := m.Called(hash, filterType, opts) + if args.Get(0) != nil { + if val, ok := args.Get(0).(*gcs.Filter); ok { + return val, args.Error(1) + } + } + + return nil, args.Error(1) +} + +// GetBlockHeight implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) GetBlockHeight( + hash *chainhash.Hash) (int32, error) { + + args := m.Called(hash) + return args.Get(0).(int32), args.Error(1) +} + +// BestBlock implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) BestBlock() (*headerfs.BlockStamp, error) { + args := m.Called() + if args.Get(0) != nil { + if val, ok := args.Get(0).(*headerfs.BlockStamp); ok { + return val, args.Error(1) + } + } + + return nil, args.Error(1) +} + +// SendTransaction implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) SendTransaction(tx *wire.MsgTx) error { + args := m.Called(tx) + return args.Error(0) +} + +// GetUtxo implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) GetUtxo( + opts ...neutrino.RescanOption) (*neutrino.SpendReport, error) { + + args := m.Called(opts) + if args.Get(0) != nil { + if val, ok := args.Get(0).(*neutrino.SpendReport); ok { + return val, args.Error(1) + } + } + + return nil, args.Error(1) +} + +// BanPeer implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) BanPeer(addr string, + reason banman.Reason) error { + + args := m.Called(addr, reason) + return args.Error(0) +} + +// IsBanned implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) IsBanned(addr string) bool { + args := m.Called(addr) + return args.Bool(0) +} + +// AddPeer implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) AddPeer(peer *neutrino.ServerPeer) { + m.Called(peer) +} + +// AddBytesSent implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) AddBytesSent(bytes uint64) { + m.Called(bytes) +} + +// AddBytesReceived implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) AddBytesReceived(bytes uint64) { + m.Called(bytes) +} + +// NetTotals implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) NetTotals() (uint64, uint64) { + args := m.Called() + + var a, b uint64 + if args.Get(0) != nil { + if val, ok := args.Get(0).(uint64); ok { + a = val + } + } + + if args.Get(1) != nil { + if val, ok := args.Get(1).(uint64); ok { + b = val + } + } + + return a, b +} + +// UpdatePeerHeights implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) UpdatePeerHeights(hash *chainhash.Hash, + height int32, peer *neutrino.ServerPeer) { + + m.Called(hash, height, peer) +} + +// ChainParams implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) ChainParams() chaincfg.Params { + args := m.Called() + if args.Get(0) != nil { + if val, ok := args.Get(0).(chaincfg.Params); ok { + return val + } + } + + return chaincfg.Params{} +} + +// PeerByAddr implements the chain.NeutrinoChainService interface. +func (m *mockNeutrinoChain) PeerByAddr( + addr string) *neutrino.ServerPeer { + + args := m.Called(addr) + if args.Get(0) != nil { + if val, ok := args.Get(0).(*neutrino.ServerPeer); ok { + return val + } + } + + return nil +} + // mockManagedPubKeyAddr is a mock implementation of the // waddrmgr.ManagedPubKeyAddress interface, used for testing. type mockManagedPubKeyAddr struct { From 889101998fbcc70010ed54e27ae9e0453fa22034 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 16 Jan 2026 02:31:40 +0800 Subject: [PATCH 276/691] wallet: refactor controller interruption tests to reduce complexity Refactor TestControllerUnlock_Interrupted, TestControllerLock_Interrupted, and TestControllerChangePassphrase_Interrupted into smaller, focused top-level test functions. This eliminates cyclomatic complexity issues and improves test readability. Also ensures returned dependencies are correctly captured. --- wallet/controller_test.go | 1132 +++++++++++++++++++++++++++++++++++-- 1 file changed, 1094 insertions(+), 38 deletions(-) diff --git a/wallet/controller_test.go b/wallet/controller_test.go index 2c2642de33..cad13d6ceb 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -2,10 +2,13 @@ package wallet import ( "context" + "math" "sync" "testing" + "time" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -275,6 +278,249 @@ func TestControllerStart(t *testing.T) { w.wg.Wait() } +// TestControllerUnlock_Interrupted_SendCancelled verifies Unlock when the +// request send is interrupted by context cancellation. +func TestControllerUnlock_Interrupted_SendCancelled(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet and a cancelled context during Unlock. + w1, _ := createTestWalletWithMocks(t) + require.NoError(t, w1.state.toStarting()) + require.NoError(t, w1.state.toStarted()) + + w1.requestChan = make(chan any) // Unbuffered to block send. + ctx1, cancel1 := context.WithCancel(t.Context()) + + errChan1 := make(chan error, 1) + go func() { + errChan1 <- w1.Unlock(ctx1, + UnlockRequest{Passphrase: []byte("pw")}) + }() + + // Act: Cancel context to interrupt send. + cancel1() + + // Assert: Verify cancellation error. + select { + case err := <-errChan1: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + +// TestControllerUnlock_Interrupted_SendShutdown verifies Unlock when the +// request send is interrupted by wallet shutdown. +func TestControllerUnlock_Interrupted_SendShutdown(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet and trigger shutdown during Unlock. + w2, _ := createTestWalletWithMocks(t) + require.NoError(t, w2.state.toStarting()) + require.NoError(t, w2.state.toStarted()) + + w2.requestChan = make(chan any) + + errChan2 := make(chan error, 1) + go func() { + errChan2 <- w2.Unlock(t.Context(), + UnlockRequest{Passphrase: []byte("pw")}) + }() + + // Act: Stop wallet. + w2.cancel() + + // Assert: Verify shutdown error. + select { + case err := <-errChan2: + require.ErrorIs(t, err, ErrWalletShuttingDown) + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + +// TestControllerUnlock_Interrupted_WaitCancelled verifies Unlock when the +// response wait is interrupted by context cancellation. +func TestControllerUnlock_Interrupted_WaitCancelled(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet with a buffered channel to allow send but + // block on response. + w3, _ := createTestWalletWithMocks(t) + require.NoError(t, w3.state.toStarting()) + require.NoError(t, w3.state.toStarted()) + + ctx3, cancel3 := context.WithCancel(t.Context()) + + errChan3 := make(chan error, 1) + go func() { + errChan3 <- w3.Unlock(ctx3, + UnlockRequest{Passphrase: []byte("pw")}) + }() + + // Wait for request to be sent. + select { + case <-w3.requestChan: + case <-time.After(time.Second): + t.Fatal("timeout waiting for request") + } + + // Act: Cancel context during response wait. + cancel3() + + // Assert: Verify cancellation error. + select { + case err := <-errChan3: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + +// TestControllerUnlock_Interrupted_WaitShutdown verifies Unlock when the +// response wait is interrupted by wallet shutdown. +func TestControllerUnlock_Interrupted_WaitShutdown(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet and trigger shutdown during response wait. + w4, _ := createTestWalletWithMocks(t) + require.NoError(t, w4.state.toStarting()) + require.NoError(t, w4.state.toStarted()) + + errChan4 := make(chan error, 1) + go func() { + errChan4 <- w4.Unlock(t.Context(), + UnlockRequest{Passphrase: []byte("pw")}) + }() + + select { + case <-w4.requestChan: + case <-time.After(time.Second): + t.Fatal("timeout waiting for request") + } + + // Act: Stop wallet. + w4.cancel() + + // Assert: Verify shutdown error. + select { + case err := <-errChan4: + require.ErrorIs(t, err, ErrWalletShuttingDown) + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + +// TestControllerVerifyBirthday_Verified verifies that verifyBirthday +// returns early if the birthday block is already verified. +func TestControllerVerifyBirthday_Verified(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet where the birthday block is already verified. + w, deps := createTestWalletWithMocks(t) + bs := waddrmgr.BlockStamp{Height: 123, Hash: chainhash.Hash{0x01}} + deps.addrStore.On("BirthdayBlock", mock.Anything).Return( + bs, true, nil).Once() + + // Act: Verify birthday. + err := w.verifyBirthday(t.Context()) + + // Assert: Verify success. + require.NoError(t, err) + require.Equal(t, bs, w.birthdayBlock) +} + +// TestControllerVerifyBirthday_LocateFail verifies verifyBirthday failure +// when locateBirthdayBlock fails. +func TestControllerVerifyBirthday_LocateFail(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where the birthday block is not set + // and chain lookup fails. + w, deps := createTestWalletWithMocks(t) + + deps.addrStore.On("BirthdayBlock", mock.Anything).Return( + waddrmgr.BlockStamp{}, false, waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrBirthdayBlockNotSet, + }).Once() + deps.addrStore.On("Birthday").Return(time.Now()).Once() + deps.chain.On("GetBestBlock").Return(nil, int32(0), errChainMock).Once() + + // Act: Attempt to verify birthday. + err := w.verifyBirthday(t.Context()) + + // Assert: Verify failure. + require.ErrorContains(t, err, "chain error") +} + +// TestControllerVerifyBirthday_PutFail verifies verifyBirthday failure +// when DBPutBirthdayBlock fails. +func TestControllerVerifyBirthday_PutFail(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where block location succeeds but + // persisting the birthday block fails. + w, deps := createTestWalletWithMocks(t) + + deps.addrStore.On("BirthdayBlock", mock.Anything).Return( + waddrmgr.BlockStamp{}, false, waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrBirthdayBlockNotSet, + }).Once() + deps.addrStore.On("Birthday").Return(time.Now()).Once() + deps.chain.On("GetBestBlock").Return( + &chainhash.Hash{}, int32(100), nil).Once() + deps.chain.On("GetBlockHash", mock.Anything).Return( + &chainhash.Hash{}, nil).Maybe() + deps.chain.On("GetBlockHeader", mock.Anything).Return( + &wire.BlockHeader{}, nil).Maybe() + deps.addrStore.On("SetBirthdayBlock", mock.Anything, mock.Anything, + true).Return(errPutMock).Once() + + // Act: Attempt to verify birthday. + err := w.verifyBirthday(t.Context()) + + // Assert: Verify failure. + require.ErrorContains(t, err, "put error") +} + +// TestSubmitRescanRequest_Errors verifies submitRescanRequest error paths. +func TestSubmitRescanRequest_Errors(t *testing.T) { + t.Parallel() + + t.Run("ErrStateForbidden", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup a stopped wallet. + w, _ := createTestWalletWithMocks(t) + + // Act: Attempt to submit rescan. + err := w.submitRescanRequest(t.Context(), scanTypeRewind, 0, nil) + + // Assert: Verify failure. + require.ErrorIs(t, err, ErrStateForbidden) + }) + + t.Run("GetBestBlock_Failure", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup a started wallet where best block lookup fails. + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + deps.syncer.On("syncState").Return(syncStateSynced) + deps.chain.On("GetBestBlock").Return( + nil, int32(0), errBestBlock).Once() + + // Act: Attempt to submit rescan. + err := w.submitRescanRequest(t.Context(), scanTypeRewind, + 0, nil) + + // Assert: Verify failure. + require.ErrorContains(t, err, "best block fail") + }) +} + // TestControllerStop verifies that the Stop method correctly shuts down the // wallet, waiting for the syncer and other background processes to exit. func TestControllerStop(t *testing.T) { @@ -453,6 +699,457 @@ func TestControllerChangePassphrase(t *testing.T) { w.wg.Wait() } +// TestControllerLock_Errors verifies Lock failures. +func TestControllerLock_Errors(t *testing.T) { + t.Parallel() + + t.Run("ContextCanceled", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup a started wallet with an unbuffered request + // channel and a cancelled context. + w, _ := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + w.requestChan = make(chan any) // Unbuffered to block send. + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + // Act: Attempt to lock. + err := w.Lock(ctx) + + // Assert: Verify cancellation error. + require.ErrorIs(t, err, context.Canceled) + }) + + t.Run("WalletStopped", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup a stopped wallet. + w, _ := createTestWalletWithMocks(t) + ctx, cancel := context.WithCancel(t.Context()) + w.lifetimeCtx = ctx + w.cancel = cancel + w.cancel() // Stop wallet. + + // Act: Attempt to lock. + err := w.Lock(t.Context()) + + // Assert: Verify forbidden error. + require.ErrorIs(t, err, ErrStateForbidden) + }) +} + +// TestControllerChangePassphrase_Errors verifies ChangePassphrase failures. +func TestControllerChangePassphrase_Errors(t *testing.T) { + t.Parallel() + + t.Run("ContextCanceled", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup a started wallet and a cancelled context. + w, _ := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + w.requestChan = make(chan any) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + // Act: Attempt to change passphrase. + err := w.ChangePassphrase(ctx, ChangePassphraseRequest{}) + + // Assert: Verify cancellation error. + require.ErrorIs(t, err, context.Canceled) + }) + + t.Run("WalletStopped", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup a stopped wallet. + w, _ := createTestWalletWithMocks(t) + ctx, cancel := context.WithCancel(t.Context()) + w.lifetimeCtx = ctx + w.cancel = cancel + w.cancel() + + // Act: Attempt to change passphrase. + err := w.ChangePassphrase( + t.Context(), ChangePassphraseRequest{}, + ) + + // Assert: Verify forbidden error. + require.ErrorIs(t, err, ErrStateForbidden) + }) +} + +// TestControllerStart_WithAccounts verifies Start with existing accounts. +func TestControllerStart_WithAccounts(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet with existing accounts in the address store. + w, deps := createTestWalletWithMocks(t) + + bs := waddrmgr.BlockStamp{Height: 100} + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(bs, true, nil).Once() + + scopedMgr := &mockAccountStore{} + deps.addrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore{scopedMgr}).Once() + + scopedMgr.On("LastAccount", mock.Anything).Return(uint32(1), nil).Once() + scopedMgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Maybe() + scopedMgr.On( + "AccountProperties", mock.Anything, uint32(0), + ).Return(&waddrmgr.AccountProperties{AccountNumber: 0}, nil).Once() + scopedMgr.On( + "AccountProperties", mock.Anything, uint32(1), + ).Return(&waddrmgr.AccountProperties{AccountNumber: 1}, nil).Once() + + deps.txStore.On( + "DeleteExpiredLockedOutputs", mock.Anything, + ).Return(nil).Once() + deps.syncer.On("run", mock.Anything).Return(nil).Once() + + // Act: Start the wallet. + err := w.Start(t.Context()) + + // Assert: Verify success. + require.NoError(t, err) + require.True(t, w.state.isStarted()) + + // Cleanup. + require.NoError(t, w.Stop(t.Context())) + w.wg.Wait() +} + +// TestMainLoop_AutoLock verifies that the main loop handles auto-lock +// timeouts. +func TestMainLoop_AutoLock(t *testing.T) { + t.Parallel() + + // Arrange: Setup an unlocked wallet with a short lock timer. + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + w.state.toUnlocked() + + ctx, cancel := context.WithCancel(t.Context()) + w.lifetimeCtx = ctx + w.cancel = cancel + w.lockTimer = time.NewTimer(time.Millisecond * 10) + + lockCalled := make(chan struct{}) + deps.addrStore.On("Lock").Run(func(args mock.Arguments) { + close(lockCalled) + }).Return(nil).Once() + + // Act: Start main loop. + w.wg.Add(1) + + go w.mainLoop() + + // Assert: Verify that the auto-lock was triggered. + select { + case <-lockCalled: + case <-time.After(time.Second): + t.Fatal("Auto-lock not triggered") + } + + // Clean up. + cancel() + w.wg.Wait() +} + +// TestMainLoop_UnknownRequest verifies main loop handles unknown requests +// gracefully. +func TestMainLoop_UnknownRequest(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet and start the main loop. + w, _ := createTestWalletWithMocks(t) + + ctx, cancel := context.WithCancel(t.Context()) + w.lifetimeCtx = ctx + w.cancel = cancel + + w.wg.Add(1) + + go w.mainLoop() + + // Act: Send an unknown request type. + w.requestChan <- "unknown" + + // Assert: Ensure it doesn't crash and can be stopped cleanly. + cancel() + w.wg.Wait() +} + +// TestControllerLock_Interrupted_SendShutdown verifies Lock when request +// send is interrupted by wallet shutdown. +func TestControllerLock_Interrupted_SendShutdown(t *testing.T) { + t.Parallel() + + // Arrange: Trigger shutdown during Lock. + w, _ := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + w.requestChan = make(chan any) + w.cancel() // Stop wallet. + + // Act: Attempt to lock. + err := w.Lock(t.Context()) + + // Assert: Verify error. + require.ErrorIs(t, err, ErrWalletShuttingDown) +} + +// TestControllerLock_Interrupted_WaitCancelled verifies Lock when response +// wait is interrupted by context cancellation. +func TestControllerLock_Interrupted_WaitCancelled(t *testing.T) { + t.Parallel() + + // Arrange: Block during response wait and cancel context. + w, _ := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + ctx, cancel := context.WithCancel(t.Context()) + + errChan := make(chan error, 1) + go func() { + errChan <- w.Lock(ctx) + }() + + // Wait for request. + select { + case <-w.requestChan: + case <-time.After(time.Second): + t.Fatal("timeout waiting for request") + } + + // Act: Cancel context. + cancel() + + // Assert: Verify error. + select { + case err := <-errChan: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + +// TestControllerLock_Interrupted_WaitShutdown verifies Lock when response +// wait is interrupted by wallet shutdown. +func TestControllerLock_Interrupted_WaitShutdown(t *testing.T) { + t.Parallel() + + // Arrange: Block during response wait and trigger shutdown. + w, _ := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + errChan := make(chan error, 1) + go func() { + errChan <- w.Lock(t.Context()) + }() + + select { + case <-w.requestChan: + case <-time.After(time.Second): + t.Fatal("timeout waiting for request") + } + + // Act: Stop wallet. + w.cancel() + + // Assert: Verify error. + select { + case err := <-errChan: + require.ErrorIs(t, err, ErrWalletShuttingDown) + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + +// TestControllerLock_Interrupted_WaitTimeout verifies Lock when response +// wait times out. +func TestControllerLock_Interrupted_WaitTimeout(t *testing.T) { + t.Parallel() + + // Arrange: Block during response wait and allow timeout to occur. + w, _ := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + ctx, cancel := context.WithTimeout( + t.Context(), 10*time.Millisecond, + ) + defer cancel() + + errChan := make(chan error, 1) + go func() { + errChan <- w.Lock(ctx) + }() + + select { + case <-w.requestChan: + case <-time.After(time.Second): + t.Fatal("timeout") + } + + // Assert: Verify timeout error. + select { + case err := <-errChan: + require.ErrorContains(t, err, + "context deadline exceeded") + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + +// TestControllerChangePassphrase_Interrupted_SendShutdown verifies +// ChangePassphrase when request send is interrupted by wallet shutdown. +func TestControllerChangePassphrase_Interrupted_SendShutdown(t *testing.T) { + t.Parallel() + + // Arrange: Trigger shutdown during send. + w, _ := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + w.requestChan = make(chan any) + w.cancel() // Stop wallet. + + // Act: Attempt change. + err := w.ChangePassphrase(t.Context(), + ChangePassphraseRequest{}) + + // Assert: Verify error. + require.ErrorIs(t, err, ErrWalletShuttingDown) +} + +// TestControllerChangePassphrase_Interrupted_WaitCancelled verifies +// ChangePassphrase when response wait is interrupted by context cancellation. +func TestControllerChangePassphrase_Interrupted_WaitCancelled(t *testing.T) { + t.Parallel() + + // Arrange: Block during response wait and cancel context. + w, _ := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + ctx, cancel := context.WithCancel(t.Context()) + + errChan := make(chan error, 1) + go func() { + errChan <- w.ChangePassphrase(ctx, + ChangePassphraseRequest{}) + }() + + select { + case <-w.requestChan: + case <-time.After(time.Second): + t.Fatal("timeout waiting for request") + } + + // Act: Cancel context. + cancel() + + // Assert: Verify error. + select { + case err := <-errChan: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + +// TestControllerChangePassphrase_Interrupted_WaitShutdown verifies +// ChangePassphrase when response wait is interrupted by wallet shutdown. +func TestControllerChangePassphrase_Interrupted_WaitShutdown(t *testing.T) { + t.Parallel() + + // Arrange: Block during response wait and trigger shutdown. + w, _ := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + errChan := make(chan error, 1) + go func() { + errChan <- w.ChangePassphrase(t.Context(), + ChangePassphraseRequest{}) + }() + + select { + case <-w.requestChan: + case <-time.After(time.Second): + t.Fatal("timeout waiting for request") + } + + // Act: Stop wallet. + w.cancel() + + // Assert: Verify error. + select { + case err := <-errChan: + require.ErrorIs(t, err, ErrWalletShuttingDown) + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + +// TestControllerChangePassphrase_Interrupted_WaitTimeout verifies +// ChangePassphrase when response wait times out. +func TestControllerChangePassphrase_Interrupted_WaitTimeout(t *testing.T) { + t.Parallel() + + // Arrange: Block during response wait and allow timeout. + w, _ := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + ctx, cancel := context.WithTimeout(t.Context(), + 10*time.Millisecond) + defer cancel() + + errChan := make(chan error, 1) + go func() { + errChan <- w.ChangePassphrase(ctx, + ChangePassphraseRequest{}) + }() + + select { + case <-w.requestChan: + case <-time.After(time.Second): + t.Fatal("timeout") + } + + // Assert: Verify timeout. + select { + case err := <-errChan: + require.ErrorContains(t, err, + "context deadline exceeded") + case <-time.After(time.Second): + t.Fatal("timeout waiting for response") + } +} + // TestHandleChangePassphraseReq_Errors verifies error handling for the // internal change passphrase request handler. func TestHandleChangePassphraseReq_Errors(t *testing.T) { @@ -527,61 +1224,420 @@ func TestControllerInfo(t *testing.T) { func TestControllerResync(t *testing.T) { t.Parallel() + t.Run("StartHeightTooHigh", func(t *testing.T) { + t.Parallel() + + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + deps.syncer.On("syncState").Return(syncStateSynced) + deps.chain.On("GetBestBlock").Return( + &chainhash.Hash{}, int32(100), nil, + ).Once() + + err := w.Resync(t.Context(), 101) + require.ErrorIs(t, err, ErrStartHeightTooHigh) + }) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + deps.syncer.On("syncState").Return(syncStateSynced) + deps.chain.On("GetBestBlock").Return( + &chainhash.Hash{}, int32(100), nil, + ).Once() + deps.syncer.On("requestScan", mock.Anything, mock.MatchedBy( + func(req *scanReq) bool { + return req.typ == scanTypeRewind && + req.startBlock.Height == 50 + }, + )).Return(nil).Once() + + err := w.Resync(t.Context(), 50) + require.NoError(t, err) + }) +} + +// TestControllerRescan verifies the Rescan method. +func TestControllerRescan(t *testing.T) { + t.Parallel() + + t.Run("NoTargets", func(t *testing.T) { + t.Parallel() + + w, _ := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + err := w.Rescan(t.Context(), 50, nil) + require.ErrorIs(t, err, ErrNoScanTargets) + }) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + deps.syncer.On("syncState").Return(syncStateSynced) + + targets := []waddrmgr.AccountScope{{Account: 1}} + + deps.chain.On("GetBestBlock").Return( + &chainhash.Hash{}, int32(100), nil, + ).Once() + deps.syncer.On("requestScan", mock.Anything, mock.MatchedBy( + func(req *scanReq) bool { + return req.typ == scanTypeTargeted && + req.startBlock.Height == 50 && + len(req.targets) == 1 + }, + )).Return(nil).Once() + + err := w.Rescan(t.Context(), 50, targets) + require.NoError(t, err) + }) +} + +// TestControllerStart_VerifyBirthdayFail verifies Start fails when +// verifyBirthday fails. +func TestControllerStart_VerifyBirthdayFail(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where birthday block lookup fails. w, deps := createTestWalletWithMocks(t) - require.NoError(t, w.state.toStarting()) - require.NoError(t, w.state.toStarted()) - deps.syncer.On("syncState").Return(syncStateSynced) - // 1. Start height too high. - deps.chain.On("GetBestBlock").Return( - &chainhash.Hash{}, int32(100), nil, - ).Once() + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(waddrmgr.BlockStamp{}, false, errDBMock).Once() + + // Act: Attempt to start the wallet. + err := w.Start(t.Context()) + + // Assert: Verify failure. + require.ErrorIs(t, err, errDBMock) + require.False(t, w.state.isStarted()) +} + +// TestControllerStart_DBGetAllAccountsFail verifies Start fails when +// DBGetAllAccounts fails. +func TestControllerStart_DBGetAllAccountsFail(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where account lookup fails during + // startup. + w, deps := createTestWalletWithMocks(t) + + bs := waddrmgr.BlockStamp{Height: 100} + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(bs, true, nil).Once() + + mockScopedMgr := &mockAccountStore{} + deps.addrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore{mockScopedMgr}).Once() + + mockScopedMgr.On( + "LastAccount", mock.Anything, + ).Return(uint32(0), errDBMock).Once() + + // Act: Attempt to start the wallet. + err := w.Start(t.Context()) + + // Assert: Verify failure. + require.ErrorIs(t, err, errDBMock) + require.False(t, w.state.isStarted()) +} + +// TestControllerStart_BirthdayNotSet verifies the flow when birthday block is +// not set in DB. +func TestControllerStart_BirthdayNotSet(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where the birthday block is not set + // and must be located from the chain. + w, deps := createTestWalletWithMocks(t) + + deps.addrStore.On( + "BirthdayBlock", mock.Anything, + ).Return(waddrmgr.BlockStamp{}, false, waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrBirthdayBlockNotSet, + }).Once() - err := w.Resync(t.Context(), 101) - require.ErrorIs(t, err, ErrStartHeightTooHigh) + birthday := time.Now() + deps.addrStore.On("Birthday").Return(birthday).Once() - // 2. Success. deps.chain.On( "GetBestBlock", ).Return(&chainhash.Hash{}, int32(100), nil).Once() - deps.syncer.On("requestScan", mock.Anything, mock.MatchedBy( - func(req *scanReq) bool { - return req.typ == scanTypeRewind && - req.startBlock.Height == 50 - }, - )).Return(nil).Once() - - err = w.Resync(t.Context(), 50) + deps.chain.On( + "GetBlockHash", int64(50), + ).Return(&chainhash.Hash{}, nil).Once() + + header := &wire.BlockHeader{Timestamp: birthday} + deps.chain.On( + "GetBlockHeader", mock.Anything, + ).Return(header, nil).Once() + + deps.addrStore.On( + "SetBirthdayBlock", mock.Anything, + mock.MatchedBy(func(bs waddrmgr.BlockStamp) bool { + return bs.Height == 50 + }), true, + ).Return(nil).Once() + deps.addrStore.On( + "SetSyncedTo", mock.Anything, mock.Anything, + ).Return(nil).Once() + + deps.addrStore.On( + "ActiveScopedKeyManagers", + ).Return([]waddrmgr.AccountStore(nil)).Once() + deps.txStore.On( + "DeleteExpiredLockedOutputs", mock.Anything, + ).Return(nil).Once() + deps.syncer.On("run", mock.Anything).Return(nil).Once() + + // Act: Start the wallet. + err := w.Start(t.Context()) + + // Assert: Verify success. + require.NoError(t, err) + require.True(t, w.state.isStarted()) + + // Clean up. + require.NoError(t, w.Stop(t.Context())) + w.wg.Wait() +} + +// TestControllerUnlock_DefaultTimeout verifies default timeout usage. +func TestControllerUnlock_DefaultTimeout(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet with an auto-lock duration and start the + // main loop. + w, deps := createTestWalletWithMocks(t) + + w.cfg.AutoLockDuration = time.Minute + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + w.wg.Add(1) + + go w.mainLoop() + + pass := []byte("pass") + req := UnlockRequest{Passphrase: pass} + deps.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() + // Auto-lock might trigger if the test runs slowly, but it's not + // guaranteed. + deps.addrStore.On("Lock").Return(nil).Maybe() + + // Act: Perform Unlock with default timeout. + err := w.Unlock(t.Context(), req) + + // Assert: Verify success. require.NoError(t, err) + + // Clean up. + w.cancel() + w.wg.Wait() } -// TestControllerRescan verifies the Rescan method. -func TestControllerRescan(t *testing.T) { +// TestControllerStart_DeleteExpiredFail verifies Start fails when +// deleteExpiredLockedOutputs fails. +func TestControllerStart_DeleteExpiredFail(t *testing.T) { t.Parallel() + // Arrange: Setup mock expectations where cleanup of expired locks + // fails. w, deps := createTestWalletWithMocks(t) + + bs := waddrmgr.BlockStamp{Height: 100} + deps.addrStore.On("BirthdayBlock", mock.Anything).Return( + bs, true, nil).Once() + deps.addrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore(nil)).Once() + + deps.txStore.On("DeleteExpiredLockedOutputs", mock.Anything).Return( + errDBMock).Once() + + // Act: Attempt to start. + err := w.Start(t.Context()) + + // Assert: Verify failure. + require.ErrorIs(t, err, errDBMock) + require.False(t, w.state.isStarted()) +} + +// TestControllerUnlock_NegativeTimeout verifies Unlock with negative +// timeout. +func TestControllerUnlock_NegativeTimeout(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet and start the main loop. + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) require.NoError(t, w.state.toStarted()) - deps.syncer.On("syncState").Return(syncStateSynced) - targets := []waddrmgr.AccountScope{{Account: 1}} + w.wg.Add(1) - // 1. No targets. - err := w.Rescan(t.Context(), 50, nil) - require.ErrorIs(t, err, ErrNoScanTargets) + go w.mainLoop() - // 2. Success. - deps.chain.On( - "GetBestBlock", - ).Return(&chainhash.Hash{}, int32(100), nil).Once() - deps.syncer.On("requestScan", mock.Anything, mock.MatchedBy( - func(req *scanReq) bool { - return req.typ == scanTypeTargeted && - req.startBlock.Height == 50 && - len(req.targets) == 1 - }, - )).Return(nil).Once() - - err = w.Rescan(t.Context(), 50, targets) + pass := []byte("pass") + req := UnlockRequest{Passphrase: pass, Timeout: -1} + deps.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() + + // Act: Perform Unlock with negative timeout (no auto-lock). + err := w.Unlock(t.Context(), req) + + // Assert: Verify success. require.NoError(t, err) + + // Clean up. + w.cancel() + w.wg.Wait() +} + +// TestControllerUnlock_DBUnlockFail verifies Unlock failure when +// DBUnlock fails. +func TestControllerUnlock_DBUnlockFail(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet and mock an unlock failure. + w, deps := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + w.wg.Add(1) + + go w.mainLoop() + + pass := []byte("pass") + deps.addrStore.On("Unlock", mock.Anything, pass).Return( + errDBMock).Once() + + // Act: Attempt Unlock. + err := w.Unlock(t.Context(), UnlockRequest{Passphrase: pass}) + + // Assert: Verify failure. + require.ErrorIs(t, err, errDBMock) + + // Clean up. + w.cancel() + w.wg.Wait() +} + +// TestHandleLockReq_LockError verifies error handling when Lock fails. +func TestHandleLockReq_LockError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where internal lock fails. + w, deps := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + req := lockReq{resp: make(chan error, 1)} + + deps.addrStore.On("Lock").Return(errLockMock).Once() + + // Act: Handle lock request. + w.handleLockReq(req) + err := <-req.resp + + // Assert: Verify error. + require.ErrorContains(t, err, "lock fail") +} + +// TestSubmitRescanRequest_HeightOverflow verifies large start height rejection. +func TestSubmitRescanRequest_HeightOverflow(t *testing.T) { + t.Parallel() + + // Arrange: Setup a wallet and attempt a rescan with an invalid height. + w, deps := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + deps.syncer.On("syncState").Return(syncStateSynced).Maybe() + + height := uint32(math.MaxInt32 + 1) + + // Act: Attempt to submit rescan request. + err := w.submitRescanRequest(t.Context(), scanTypeTargeted, + height, nil) + + // Assert: Verify error. + require.ErrorIs(t, err, ErrStartHeightTooLarge) +} + +// TestChangePassphrase_StateError verifies early failure when state forbids +// change. +func TestChangePassphrase_StateError(t *testing.T) { + t.Parallel() + + // Arrange: Setup a stopped wallet. + w, _ := createTestWalletWithMocks(t) + + // Act: Attempt change. + err := w.ChangePassphrase(t.Context(), + ChangePassphraseRequest{}) + + // Assert: Verify forbidden error. + require.ErrorIs(t, err, ErrStateForbidden) +} + +// TestControllerStart_AlreadyStarted verifies Start fails if already started. +func TestControllerStart_AlreadyStarted(t *testing.T) { + t.Parallel() + + // Arrange: Setup a started wallet. + w, _ := createTestWalletWithMocks(t) + + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + // Act: Attempt to start again. + err := w.Start(t.Context()) + + // Assert: Verify error. + require.ErrorIs(t, err, ErrWalletAlreadyStarted) +} + +// TestControllerUnlock_StateError verifies Unlock fails if not started. +func TestControllerUnlock_StateError(t *testing.T) { + t.Parallel() + + // Arrange: Setup a stopped wallet. + w, _ := createTestWalletWithMocks(t) + + // Act: Attempt Unlock. + err := w.Unlock(t.Context(), UnlockRequest{}) + + // Assert: Verify error. + require.ErrorIs(t, err, ErrStateForbidden) +} + +// TestControllerLock_StateError verifies Lock fails if not started. +func TestControllerLock_StateError(t *testing.T) { + t.Parallel() + + // Arrange: Setup a stopped wallet. + w, _ := createTestWalletWithMocks(t) + + // Act: Attempt Lock. + err := w.Lock(t.Context()) + + // Assert: Verify error. + require.ErrorIs(t, err, ErrStateForbidden) } From 39e11048bb538ea2b392b0004a7ac9a836ddd497 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 16 Jan 2026 02:32:21 +0800 Subject: [PATCH 277/691] wallet: add comprehensive DB operation tests Add a suite of new tests to db_ops_test.go covering targeted batch updates, sync tip handling, horizon expansion, and error propagation during scan data retrieval. These tests verify robustness against database failures and ensure correct state transitions during wallet operations. Refactor existing tests to strictly follow the AAA pattern and enforce error checking. --- wallet/db_ops_test.go | 437 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 437 insertions(+) diff --git a/wallet/db_ops_test.go b/wallet/db_ops_test.go index 9e0bba9da9..7b7e7e2bd1 100644 --- a/wallet/db_ops_test.go +++ b/wallet/db_ops_test.go @@ -610,3 +610,440 @@ func TestDBGetScanData_Error(t *testing.T) { require.Nil(t, addrs) require.Nil(t, unspent) } + +// TestDBPutTargetedBatch_WithTxns verifies that DBPutTargetedBatch processes +// relevant outputs. +func TestDBPutTargetedBatch_WithTxns(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and mock dependencies for processing a + // targeted batch. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + + s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + + rec, err := wtxmgr.NewTxRecordFromMsgTx(wire.NewMsgTx(1), time.Now()) + require.NoError(t, err) + + results := []scanResult{ + { + meta: &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 100}, + Time: time.Now(), + }, + BlockProcessResult: &BlockProcessResult{ + RelevantOutputs: TxEntries{ + {Rec: rec, Entries: []AddrEntry{}}, + }, + }, + }, + } + + mockTxStore.On("InsertConfirmedTx", mock.Anything, mock.Anything, + mock.Anything, mock.Anything).Return(nil).Once() + + // Act: Execute the targeted batch update. + err = s.DBPutTargetedBatch(t.Context(), results) + + // Assert: Verify success. + require.NoError(t, err) +} + +// TestDBPutSyncTip_Error verifies error propagation in DBPutSyncTip. +func TestDBPutSyncTip_Error(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where SetSyncedTo fails during + // DBPutSyncTip. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + mockAddrStore.On("SetSyncedTo", mock.Anything, + mock.Anything).Return(errSetFail).Once() + + // Act: Attempt to update the sync tip. + err := s.DBPutSyncTip(t.Context(), wtxmgr.BlockMeta{}) + + // Assert: Verify failure. + require.ErrorIs(t, err, errSetFail) +} + +// TestDBPutTargetedBatch_Errors verifies error paths. +func TestDBPutTargetedBatch_Errors(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where transaction insertion fails + // during a targeted batch update. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + + rec, err := wtxmgr.NewTxRecordFromMsgTx(wire.NewMsgTx(1), time.Now()) + require.NoError(t, err) + + results := []scanResult{ + { + meta: &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 100}, + Time: time.Now(), + }, + BlockProcessResult: &BlockProcessResult{ + RelevantOutputs: TxEntries{ + {Rec: rec, Entries: []AddrEntry{}}, + }, + }, + }, + } + + mockTxStore.On("InsertConfirmedTx", mock.Anything, mock.Anything, + mock.Anything, mock.Anything).Return(errDBInsert).Once() + + // Act: Execute the targeted batch update. + err = s.DBPutTargetedBatch(t.Context(), results) + + // Assert: Verify failure. + require.ErrorIs(t, err, errDBInsert) +} + +// TestDBPutTxns_Error verifies error propagation in DBPutTxns. +func TestDBPutTxns_Error(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where address lookup fails during + // transaction persistence. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + addr, err := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + + matches := TxEntries{ + { + Rec: &wtxmgr.TxRecord{}, + Entries: []AddrEntry{{Address: addr}}, + }, + } + + mockAddrStore.On("Address", + mock.Anything, mock.Anything).Return(nil, errAddr).Once() + + // Act: Attempt to persist transactions. + err = s.DBPutTxns(t.Context(), matches, nil) + + // Assert: Verify failure. + require.ErrorIs(t, err, errAddr) +} + +// TestDBPutTxns_UnconfirmedError verifies error propagation for unconfirmed tx. +func TestDBPutTxns_UnconfirmedError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where unconfirmed transaction + // insertion fails. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + + s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + + addr, err := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + + matches := TxEntries{ + { + Rec: &wtxmgr.TxRecord{}, + Entries: []AddrEntry{{Address: addr}}, + }, + } + + maddr := &mockManagedAddress{} + maddr.On("Internal").Return(false).Maybe() + mockAddrStore.On("Address", mock.Anything, mock.Anything).Return(maddr, + nil).Once() + + mgr := &mockAccountStore{} + mgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + mockAddrStore.On("AddrAccount", mock.Anything, mock.Anything).Return( + mgr, + uint32(0), nil).Once() + mockAddrStore.On("MarkUsed", mock.Anything, + mock.Anything).Return(nil).Once() + mockTxStore.On("InsertUnconfirmedTx", mock.Anything, mock.Anything, + mock.Anything).Return(errInsert).Once() + + // Act: Attempt to persist unconfirmed transactions. + err = s.DBPutTxns(t.Context(), matches, nil) + + // Assert: Verify failure. + require.ErrorIs(t, err, errInsert) +} + +// TestPutSyncTip_Error verifies error propagation. +func TestPutSyncTip_Error(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where sync tip update fails within + // a database transaction. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + // Act: Execute sync tip update within a database transaction. + err := walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + mockAddrStore.On("SetSyncedTo", mock.Anything, + mock.Anything).Return(errSetFail).Once() + + return s.putSyncTip(t.Context(), tx, wtxmgr.BlockMeta{}) + }) + + // Assert: Verify failure. + require.ErrorIs(t, err, errSetFail) +} + +// TestDBGetScanData_ManagerError verifies account not found is handled. +func TestDBGetScanData_ManagerError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where key manager lookup fails + // during scan data retrieval. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + targets := []waddrmgr.AccountScope{ + {Scope: waddrmgr.KeyScopeBIP0084, Account: 0}, + } + + mockAddrStore.On("FetchScopedKeyManager", + mock.Anything).Return(nil, errManager).Once() + + // Act: Attempt to retrieve scan data. + horizons, addrs, unspent, err := s.DBGetScanData(t.Context(), targets) + + // Assert: Verify failure. + require.Nil(t, horizons) + require.Nil(t, addrs) + require.Nil(t, unspent) + require.ErrorIs(t, err, errManager) +} + +// TestDBGetScanData_UTXOError verifies UTXO loading failure. +func TestDBGetScanData_UTXOError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where UTXO lookup fails during scan + // data retrieval. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.AnythingOfType("func(btcutil.Address) error"), + ).Return(nil).Once() + mockTxStore.On("OutputsToWatch", + mock.Anything).Return(([]wtxmgr.Credit)(nil), errUtxo).Once() + + // Act: Attempt to retrieve scan data. + horizons, addrs, unspent, err := s.DBGetScanData(t.Context(), nil) + + // Assert: Verify failure. + require.Nil(t, horizons) + require.Nil(t, addrs) + require.Nil(t, unspent) + require.ErrorIs(t, err, errUtxo) +} + +// TestPutAddrHorizons_Error verifies error propagation. +func TestPutAddrHorizons_Error(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where key manager lookup fails + // during horizon persistence. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + results := []scanResult{ + { + BlockProcessResult: &BlockProcessResult{ + FoundHorizons: map[waddrmgr.BranchScope]uint32{ + {}: 1, + }, + }, + }, + } + + mockAddrStore.On("FetchScopedKeyManager", + mock.Anything).Return(nil, errManager).Once() + + // Act: Attempt to persist address horizons. + err := s.putAddrHorizons(t.Context(), nil, results) + + // Assert: Verify failure. + require.ErrorIs(t, err, errManager) +} + +// TestDBGetScanData_AddressError verifies active address loading failure. +func TestDBGetScanData_AddressError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where address iteration fails + // during scan data retrieval. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything).Return(errAddr).Once() + + // Act: Attempt to retrieve scan data. + horizons, addrs, unspent, err := s.DBGetScanData(t.Context(), nil) + + // Assert: Verify failure. + require.Nil(t, horizons) + require.Nil(t, addrs) + require.Nil(t, unspent) + require.ErrorIs(t, err, errAddr) +} + +// TestDBPutTxns_InternalAddressAsChange verifies internal branch handling. +func TestDBPutTxns_InternalAddressAsChange(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations for a transaction match where the + // address is internal, requiring it to be marked as change. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + + addr, err := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + + matches := TxEntries{ + { + Rec: &wtxmgr.TxRecord{}, + Entries: []AddrEntry{{Address: addr}}, + }, + } + + maddr := &mockManagedAddress{} + maddr.On("Internal").Return(true).Once() + mockAddrStore.On("Address", + mock.Anything, mock.Anything).Return(maddr, nil).Once() + + mgr := &mockAccountStore{} + mgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + mockAddrStore.On("AddrAccount", + mock.Anything, mock.Anything).Return(mgr, uint32(0), nil).Once() + + mockAddrStore.On("MarkUsed", mock.Anything, + mock.Anything).Return(nil).Once() + + mockTxStore.On("InsertUnconfirmedTx", mock.Anything, mock.Anything, + mock.Anything).Return(nil).Once() + + // Act: Persist transactions and filter branch scopes. + err = s.DBPutTxns(t.Context(), matches, nil) + + // Assert: Verify that the output was correctly identified as change. + require.NoError(t, err) + require.True(t, matches[0].Entries[0].Credit.Change) +} + +// TestDBPutTxns_AddressNotFound verifies ignoring not-found addresses. +func TestDBPutTxns_AddressNotFound(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where an address lookup returns a + // "not found" error, which should lead to the entry being filtered out. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + + addr, err := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + + matches := TxEntries{ + { + Rec: &wtxmgr.TxRecord{}, + Entries: []AddrEntry{{Address: addr}}, + }, + } + + mockAddrStore.On("Address", + mock.Anything, mock.Anything, + ).Return(nil, waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAddressNotFound}).Once() + + mockTxStore.On("InsertUnconfirmedTx", mock.Anything, mock.Anything, + mock.Anything).Return(nil).Once() + + // Act: Persist transactions. + err = s.DBPutTxns(t.Context(), matches, nil) + + // Assert: Verify that the unknown address entry was filtered. + require.NoError(t, err) + require.Empty(t, matches[0].Entries) +} + +// TestDBPutRewind_Error verifies error propagation. +func TestDBPutRewind_Error(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where SetSyncedTo fails during + // DBPutRewind. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + mockAddrStore.On("SetSyncedTo", + mock.Anything, mock.Anything).Return(errSetSync).Once() + + // Act: Perform DBPutRewind. + err := s.DBPutRewind(t.Context(), waddrmgr.BlockStamp{}) + + // Assert: Verify failure. + require.ErrorIs(t, err, errSetSync) +} From 9f9437e1f5c4dc70d21cf6cd694c12fdb973ece0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 16 Jan 2026 02:32:29 +0800 Subject: [PATCH 278/691] wallet: expand syncer test coverage Add extensive unit tests to syncer_test.go covering: - CFilter-based scanning logic and fallback mechanisms. - Chain update handling (block connection/disconnection, reorgs). - Error propagation during scan strategy dispatch and batch fetching. - Sync state transitions and event loop handling. - Address extraction and output script validation. Ensure all tests adhere to the AAA pattern and standard error checking conventions. --- wallet/syncer_test.go | 2318 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 2309 insertions(+), 9 deletions(-) diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 40732249d8..d77a03aaac 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -9,7 +9,6 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/gcs" "github.com/btcsuite/btcd/btcutil/gcs/builder" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -343,7 +342,7 @@ func TestSyncerLoadScanState(t *testing.T) { Config{ DB: db, RecoveryWindow: 10, - ChainParams: &chaincfg.MainNetParams, + ChainParams: &chainParams, }, mockAddrStore, mockTxStore, mockPublisher, ) @@ -411,7 +410,7 @@ func TestScanBatchWithFullBlocks(t *testing.T) { mockAddrStore := &mockAddrStore{} scanState := NewRecoveryState( - 10, &chaincfg.MainNetParams, mockAddrStore, + 10, &chainParams, mockAddrStore, ) hashes := []chainhash.Hash{{0x01}} @@ -453,7 +452,7 @@ func TestScanBatchWithCFilters(t *testing.T) { mockAddrStore := &mockAddrStore{} scanState := NewRecoveryState( - 10, &chaincfg.MainNetParams, mockAddrStore, + 10, &chainParams, mockAddrStore, ) hashes := []chainhash.Hash{{0x01}} @@ -510,7 +509,7 @@ func TestDispatchScanStrategy(t *testing.T) { s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) - scanState := NewRecoveryState(10, &chaincfg.MainNetParams, nil) + scanState := NewRecoveryState(10, &chainParams, nil) hashes := []chainhash.Hash{{0x01}} // 1. Test the SyncMethodFullBlocks strategy. @@ -634,7 +633,7 @@ func TestFetchAndFilterBlocks(t *testing.T) { s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) // Create an empty recovery state for testing. - scanState := NewRecoveryState(10, &chaincfg.MainNetParams, nil) + scanState := NewRecoveryState(10, &chainParams, nil) hashes := []chainhash.Hash{{0x01}} // Mock expectations for header-only scanning when the recovery state @@ -838,12 +837,12 @@ func TestExtractAddrEntries(t *testing.T) { // extraction. mockPublisher := &mockTxPublisher{} s := newSyncer( - Config{ChainParams: &chaincfg.MainNetParams}, nil, nil, + Config{ChainParams: &chainParams}, nil, nil, mockPublisher, ) addr, err := btcutil.NewAddressPubKeyHash( - make([]byte, 20), &chaincfg.MainNetParams, + make([]byte, 20), &chainParams, ) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) @@ -1198,7 +1197,7 @@ func TestDispatchScanStrategy_CFilterFail(t *testing.T) { ) mockAddrStore := &mockAddrStore{} scanState := NewRecoveryState( - 10, &chaincfg.MainNetParams, mockAddrStore, + 10, &chainParams, mockAddrStore, ) hashes := []chainhash.Hash{{0x01}} @@ -1222,3 +1221,2304 @@ func TestDispatchScanStrategy_CFilterFail(t *testing.T) { require.NoError(t, err) require.Len(t, results, 1) } + +// TestFilterBatch_MatchFound verifies logic when CFilter matches. +func TestFilterBatch_MatchFound(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer configured for CFilter scanning. + mockChain := &mockChain{} + s := newSyncer( + Config{Chain: mockChain, SyncMethod: SyncMethodCFilters}, + nil, nil, nil, + ) + + // Create a filter that matches "data". + data := []byte("match_me") + filter, err := gcs.BuildGCSFilter( + builder.DefaultP, builder.DefaultM, [16]byte{}, [][]byte{data}, + ) + require.NoError(t, err) + + // Setup scan state watching the data. + scanState := NewRecoveryState(10, &chainParams, nil) + + mockAddr := &mockAddress{} + mockAddr.On("ScriptAddress").Return(data) + mockAddr.On("String").Return("addr") + + scopeState := scanState.StateForScope(waddrmgr.KeyScopeBIP0084) + scopeState.ExternalBranch.AddAddr(0, mockAddr) + + hashes := []chainhash.Hash{{0x01}} + mockChain.On( + "GetCFilters", hashes, wire.GCSFilterRegular, + ).Return([]*gcs.Filter{filter}, nil).Once() + + // Expect full block fetch due to filter match. + msgBlock := wire.NewMsgBlock(wire.NewBlockHeader( + 1, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + mockChain.On("GetBlocks", hashes).Return( + []*wire.MsgBlock{msgBlock}, nil, + ).Once() + + mockChain.On("GetBlockHeaders", hashes).Return( + []*wire.BlockHeader{{}}, nil, + ).Once() + + // Act: Perform the scan. + results, err := s.scanBatchWithCFilters( + t.Context(), scanState, 10, hashes, + ) + + // Assert: Verify results. + require.NoError(t, err) + require.Len(t, results, 1) +} + +// TestScanBatchWithCFilters_GetHeadersFail verifies error handling. +func TestScanBatchWithCFilters_GetHeadersFail(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer and mock CFilter success but header retrieval + // failure. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + scanState := NewRecoveryState(10, &chainParams, nil) + hashes := []chainhash.Hash{{0x01}} + + filter, err := gcs.BuildGCSFilter( + builder.DefaultP, builder.DefaultM, [16]byte{}, nil, + ) + require.NoError(t, err) + + mockChain.On( + "GetCFilters", hashes, wire.GCSFilterRegular, + ).Return([]*gcs.Filter{filter}, nil).Once() + + mockChain.On( + "GetBlockHeaders", hashes, + ).Return(([]*wire.BlockHeader)(nil), errHeaders).Once() + + // Act: Attempt to scan the batch. + results, err := s.scanBatchWithCFilters( + t.Context(), scanState, 10, hashes, + ) + + // Assert: Verify error propagation. + require.Nil(t, results) + require.ErrorContains(t, err, "headers fail") +} + +// TestFetchAndFilterBlocks_NonEmpty verifies block fetching and filtering +// when the scan state is NOT empty. +func TestFetchAndFilterBlocks_NonEmpty(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer with a non-empty scan state. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + scanState := NewRecoveryState(10, &chainParams, nil) + scanState.AddWatchedOutPoint(&wire.OutPoint{Index: 0}, nil) + + hashes := []chainhash.Hash{{0x01}} + mockChain.On( + "GetBlockHashes", int64(10), int64(11), + ).Return(hashes, nil).Once() + + filter, err := gcs.BuildGCSFilter( + builder.DefaultP, builder.DefaultM, [16]byte{}, nil, + ) + require.NoError(t, err) + mockChain.On( + "GetCFilters", hashes, wire.GCSFilterRegular, + ).Return([]*gcs.Filter{filter}, nil).Once() + mockChain.On("GetBlockHeaders", hashes).Return( + []*wire.BlockHeader{{}}, nil).Once() + + // Act: Fetch and filter blocks. + results, err := s.fetchAndFilterBlocks( + t.Context(), scanState, 10, 11, + ) + + // Assert: Verify results. + require.NoError(t, err) + require.Len(t, results, 1) +} + +// TestFetchAndFilterBlocks_Errors verifies error paths. +func TestFetchAndFilterBlocks_Errors(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer with a non-empty scan state and mock a hash + // fetch failure. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + scanState := NewRecoveryState(10, &chainParams, nil) + scanState.AddWatchedOutPoint(&wire.OutPoint{Index: 0}, nil) + + mockChain.On( + "GetBlockHashes", int64(10), int64(11), + ).Return([]chainhash.Hash(nil), errChainMock).Once() + + // Act: Attempt to fetch and filter blocks. + results, err := s.fetchAndFilterBlocks( + t.Context(), scanState, 10, 11, + ) + + // Assert: Verify error propagation. + require.Nil(t, results) + require.ErrorContains(t, err, "chain error") +} + +// TestScanBatch_Empty verifies error when fetchAndFilterBlocks returns 0. +func TestScanBatch_Empty(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + defer cleanup() + + // Arrange: Setup a syncer that returns empty blocks during a batch + // scan. + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, + mockAddrStore, mockTxStore, nil, + ) + + mockAddrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore{}).Once() + + mockTxStore.On("OutputsToWatch", mock.Anything).Return( + []wtxmgr.Credit(nil), nil).Once() + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything).Return(nil).Once() + + mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( + []chainhash.Hash{}, nil).Once() + mockChain.On("GetBlockHeaders", []chainhash.Hash{}).Return( + []*wire.BlockHeader{}, nil).Once() + + // Act: Attempt to scan the batch. + err := s.scanBatch( + t.Context(), waddrmgr.BlockStamp{Height: 10}, 11, + ) + + // Assert: Verify that the empty batch error is returned. + require.ErrorIs(t, err, ErrScanBatchEmpty) +} + +// TestInitChainSync_Errors verifies initChainSync error paths. +func TestInitChainSync_Errors(t *testing.T) { + t.Parallel() + + t.Run("CheckRollback_Failure", func(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + defer cleanup() + + // Arrange: Setup a syncer where DB operations fail during + // rollback check. + mockChain := &mockChain{} + addrStore := &mockAddrStore{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, addrStore, nil, nil, + ) + + mockChain.On("IsCurrent").Return(true).Maybe() + addrStore.On("Birthday").Return(time.Now()).Maybe() + addrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}, + ) + addrStore.On("BlockHash", mock.Anything, mock.Anything).Return( + &chainhash.Hash{}, errDBMock).Once() + + // Act: Attempt initialization. + err := s.initChainSync(t.Context()) + + // Assert: Verify error. + require.ErrorContains(t, err, "db error") + }) + + t.Run("NotifyBlocks_Failure", func(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + defer cleanup() + + // Arrange: Setup a syncer where block notifications fail. + mockChain := &mockChain{} + addrStore := &mockAddrStore{} + s := newSyncer( + Config{Chain: mockChain, DB: db}, addrStore, nil, nil, + ) + + mockChain.On("IsCurrent").Return(true).Maybe() + addrStore.On("Birthday").Return(time.Now()).Maybe() + addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{Height: 0}) + mockChain.On("NotifyBlocks").Return(errNotify).Once() + + // Act: Attempt initialization. + err := s.initChainSync(t.Context()) + + // Assert: Verify error. + require.ErrorContains(t, err, "notify fail") + }) +} + +// TestHandleScanReq_Errors verifies handleScanReq error paths. +func TestHandleScanReq_Errors(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer already in syncing state. + s := newSyncer(Config{}, nil, nil, nil) + s.state.Store(uint32(syncStateSyncing)) + + // Act: Attempt to handle a scan request. + err := s.handleScanReq(t.Context(), &scanReq{}) + + // Assert: Verify state forbidden error. + require.ErrorIs(t, err, ErrStateForbidden) +} + +// TestSyncerRun_InitError verifies run failure when initChainSync fails. +func TestSyncerRun_InitError(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + defer cleanup() + + // Arrange: Setup a syncer where initialization fails. + mockChain := &mockChain{} + addrStore := &mockAddrStore{} + + s := newSyncer(Config{Chain: mockChain, DB: db}, addrStore, nil, nil) + + addrStore.On("Birthday").Return(time.Now()).Once() + mockChain.On("IsCurrent").Return(true).Once() + + addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{Height: 100}) + addrStore.On("BlockHash", mock.Anything, mock.Anything).Return( + &chainhash.Hash{}, errDBMock).Once() + + // Act: Run the syncer. + err := s.run(t.Context()) + + // Assert: Verify error propagation. + require.ErrorContains(t, err, "db error") +} + +// TestHandleChainUpdate_BlockDisconnected verifies handleChainUpdate for +// BlockDisconnected. +func TestHandleChainUpdate_BlockDisconnected(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer and dependencies for handling updates. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockChain := &mockChain{} + s := newSyncer( + Config{ + Chain: mockChain, + ChainParams: &chainParams, + DB: db, + }, + mockAddrStore, mockTxStore, nil, + ) + + // 1. BlockDisconnected. + mockTxStore.On("Rollback", mock.Anything, int32(100)).Return(nil).Once() + mockAddrStore.On("SetSyncedTo", mock.Anything, mock.Anything).Return( + nil).Once() + mockAddrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{Height: 100}) + + for i := int32(91); i <= 100; i++ { + hash := chainhash.Hash{byte(i)} + mockAddrStore.On("BlockHash", mock.Anything, i).Return( + &hash, nil).Maybe() + } + + remoteHashes := make([]chainhash.Hash, 10) + for i := range 10 { + remoteHashes[i] = chainhash.Hash{byte(91 + i)} + } + + mockChain.On("GetBlockHashes", int64(91), int64(100)).Return( + remoteHashes, nil).Once() + + // Act: Handle BlockDisconnected. + err := s.handleChainUpdate( + t.Context(), chain.BlockDisconnected{ + Block: wtxmgr.Block{Height: 100}, + }, + ) + + // Assert: Verify success. + require.NoError(t, err) +} + +// TestDispatchScanStrategy_AutoFallback verifies fallback to full blocks +// when watchlist is too large. +func TestDispatchScanStrategy_AutoFallback(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer with a low filter item threshold to force + // fallback. + mockChain := &mockChain{} + s := newSyncer( + Config{ + Chain: mockChain, + SyncMethod: SyncMethodAuto, + MaxCFilterItems: 1, + }, nil, nil, nil, + ) + scanState := NewRecoveryState(10, &chainParams, nil) + + // Add 2 items (threshold 1). + credits := make([]wtxmgr.Credit, 2) + for i := range credits { + credits[i] = wtxmgr.Credit{ + OutPoint: wire.OutPoint{Index: uint32(i)}, + PkScript: []byte{0x00}, + } + } + + err := scanState.Initialize(nil, nil, credits) + require.NoError(t, err) + + hashes := []chainhash.Hash{{0x01}} + msgBlock := wire.NewMsgBlock(wire.NewBlockHeader( + 1, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + mockChain.On( + "GetBlocks", hashes, + ).Return([]*wire.MsgBlock{msgBlock}, nil).Once() + + // Act: Dispatch the scan strategy. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 10, hashes, + ) + + // Assert: Verify results. + require.NoError(t, err) + require.Len(t, results, 1) +} + +// TestBroadcastUnminedTxns_Success verifies successful broadcast. +func TestBroadcastUnminedTxns_Success(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer and mock successful transaction retrieval + // and broadcast. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockTxStore := &mockTxStore{} + mockPublisher := &mockTxPublisher{} + + s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher) + + tx := wire.NewMsgTx(1) + mockTxStore.On("UnminedTxs", mock.Anything).Return( + []*wire.MsgTx{tx}, nil, + ).Once() + mockPublisher.On("Broadcast", mock.Anything, tx, "").Return(nil).Once() + + // Act: Broadcast unmined transactions. + err := s.broadcastUnminedTxns(t.Context()) + + // Assert: Verify success. + require.NoError(t, err) +} + +// TestFilterBatch_EmptyFilter verifies that empty filters force download. +func TestFilterBatch_EmptyFilter(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer and mock an empty filter response. + mockChain := &mockChain{} + s := newSyncer( + Config{Chain: mockChain, SyncMethod: SyncMethodCFilters}, + nil, nil, nil, + ) + + emptyFilter, err := gcs.BuildGCSFilter( + builder.DefaultP, builder.DefaultM, [16]byte{}, nil, + ) + require.NoError(t, err) + + scanState := NewRecoveryState(10, &chainParams, nil) + hashes := []chainhash.Hash{{0x01}} + mockChain.On( + "GetCFilters", hashes, wire.GCSFilterRegular, + ).Return([]*gcs.Filter{emptyFilter}, nil).Once() + + msgBlock := wire.NewMsgBlock(wire.NewBlockHeader( + 1, &chainhash.Hash{}, &chainhash.Hash{}, 0, 0, + )) + mockChain.On("GetBlocks", hashes).Return( + []*wire.MsgBlock{msgBlock}, nil, + ).Once() + mockChain.On("GetBlockHeaders", hashes).Return( + []*wire.BlockHeader{{}}, nil, + ).Once() + + // Act: Scan the batch with CFilters. + results, err := s.scanBatchWithCFilters( + t.Context(), scanState, 10, hashes, + ) + + // Assert: Verify that the block was fetched. + require.NoError(t, err) + require.Len(t, results, 1) +} + +// TestWaitForEvent_NotificationsClosed verifies that the loop exits when the +// notifications channel is closed. +func TestWaitForEvent_NotificationsClosed(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer with a closed notification channel. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + closedChan := make(chan any) + close(closedChan) + + mockChain.On("Notifications").Return((<-chan any)(closedChan)).Once() + + // Act: Start waiting for events. + err := s.waitForEvent(t.Context()) + + // Assert: Verify that the loop exits with the expected error. + require.ErrorIs(t, err, ErrWalletShuttingDown) +} + +// TestWaitForEvent_ContextCancelled verifies exit on context cancellation. +func TestWaitForEvent_ContextCancelled(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer with a blocking notification channel and a + // cancelled context. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + blockChan := make(chan any) + mockChain.On("Notifications").Return((<-chan any)(blockChan)).Once() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + // Act: Attempt to wait for events. + err := s.waitForEvent(ctx) + + // Assert: Verify cancellation error. + require.ErrorIs(t, err, context.Canceled) +} + +// TestMatchAndFetchBatch_GetBlocksError verifies error propagation. +func TestMatchAndFetchBatch_GetBlocksError(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer and setup a recovery state. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + state := NewRecoveryState(1, nil, nil) + + // Setup results and mock filters such that a match is forced, then + // mock a block fetch failure. + results := []scanResult{ + {meta: &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Hash: chainhash.Hash{0x01}}, + }}, + } + + filters := []*gcs.Filter{nil} + + blockMap := make(map[chainhash.Hash]*wire.MsgBlock) + mockChain.On("GetBlocks", mock.Anything).Return( + ([]*wire.MsgBlock)(nil), errGetBlocks).Once() + + // Act: Attempt to match and fetch the batch. + err := s.matchAndFetchBatch( + t.Context(), state, results, filters, blockMap, + ) + + // Assert: Verify error propagation. + require.ErrorIs(t, err, errGetBlocks) +} + +// TestFilterBatch_ContextCancelled verifies early exit. +func TestFilterBatch_ContextCancelled(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer and a cancelled context. + s := newSyncer(Config{}, nil, nil, nil) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + // Act: Attempt to filter a batch. + results := []scanResult{{}} + matched, err := s.filterBatch(ctx, results, nil, nil, nil) + + // Assert: Verify failure. + require.Nil(t, matched) + require.ErrorIs(t, err, context.Canceled) +} + +// TestFilterBatch_BlockAlreadyFetched verifies skipping. +func TestFilterBatch_BlockAlreadyFetched(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer where the target block has already been + // fetched. + s := newSyncer(Config{}, nil, nil, nil) + + hash := chainhash.Hash{0x01} + results := []scanResult{ + {meta: &wtxmgr.BlockMeta{Block: wtxmgr.Block{Hash: hash}}}, + } + blockMap := map[chainhash.Hash]*wire.MsgBlock{ + hash: {}, + } + + // Act: Filter the batch. + matched, err := s.filterBatch(t.Context(), results, nil, blockMap, nil) + + // Assert: Verify that the block was skipped. + require.NoError(t, err) + require.Empty(t, matched) +} + +// TestInitChainSync_WaitUntilSyncedError verifies error propagation. +func TestInitChainSync_WaitUntilSyncedError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where the backend is not current, + // then cancel the context. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + mockChain.On("IsCurrent").Return(false).Maybe() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + // Act: Attempt chain sync initialization. + err := s.initChainSync(ctx) + + // Assert: Verify failure. + require.ErrorContains(t, err, "unable to wait for backend sync") +} + +// TestScanBatchHeadersOnly_ContextCancelled verifies early exit. +func TestScanBatchHeadersOnly_ContextCancelled(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations and a cancelled context. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( + []chainhash.Hash{}, context.Canceled).Maybe() + + // Act: Attempt header-only scan. + results, err := s.scanBatchHeadersOnly(ctx, 0, 0) + + // Assert: Verify failure. + require.Nil(t, results) + require.ErrorIs(t, err, context.Canceled) +} + +// TestBroadcastUnminedTxns_BroadcastError verifies warning log (no error +// returned). +func TestBroadcastUnminedTxns_BroadcastError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where a transaction broadcast fails. + mockPublisher := &mockTxPublisher{} + mockTxStore := &mockTxStore{} + + db, cleanup := setupTestDB(t) + defer cleanup() + + s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher) + + tx := wire.NewMsgTx(1) + mockTxStore.On("UnminedTxs", mock.Anything).Return( + []*wire.MsgTx{tx}, nil).Once() + mockPublisher.On("Broadcast", mock.Anything, tx, "").Return( + errBroadcast).Once() + + // Act: Broadcast unmined transactions. + err := s.broadcastUnminedTxns(t.Context()) + + // Assert: Verify that the error is not propagated (it's only logged). + require.NoError(t, err) +} + +// TestCheckRollback_DBError verifies error propagation. +func TestCheckRollback_DBError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where local block hash lookup fails + // during a rollback check. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Once() + mockAddrStore.On("BlockHash", mock.Anything, mock.Anything).Return( + (*chainhash.Hash)(nil), errBlockHash).Once() + + // Act: Perform a rollback check. + err := s.checkRollback(t.Context()) + + // Assert: Verify failure. + require.ErrorIs(t, err, errBlockHash) +} + +// TestCheckRollback_RemoteError verifies error propagation from +// GetBlockHashes. +func TestCheckRollback_RemoteError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where remote hash lookup fails + // during a rollback check. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + s := newSyncer( + Config{Chain: mockChain, DB: db}, + mockAddrStore, nil, nil, + ) + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Once() + mockAddrStore.On("BlockHash", mock.Anything, mock.Anything).Return( + &chainhash.Hash{}, nil).Maybe() + mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( + ([]chainhash.Hash)(nil), errRemote).Once() + + // Act: Perform a rollback check. + err := s.checkRollback(t.Context()) + + // Assert: Verify failure. + require.ErrorIs(t, err, errRemote) +} + +// TestFilterBatch_NilFilter verifies logging and forcing download. +func TestFilterBatch_NilFilter(t *testing.T) { + t.Parallel() + + // Arrange: Setup a batch with a nil filter. + s := newSyncer(Config{}, nil, nil, nil) + + hash := chainhash.Hash{0x01} + results := []scanResult{ + {meta: &wtxmgr.BlockMeta{Block: wtxmgr.Block{Hash: hash}}}, + } + filters := []*gcs.Filter{nil} + blockMap := make(map[chainhash.Hash]*wire.MsgBlock) + + // Act: Filter the batch. + matched, err := s.filterBatch( + t.Context(), results, filters, blockMap, nil, + ) + + // Assert: Verify that the block is matched to force download. + require.NoError(t, err) + require.Len(t, matched, 1) + require.Equal(t, hash, matched[0]) +} + +// TestInitChainSync_NotifyBlocksError verifies error propagation. +func TestInitChainSync_NotifyBlocksError(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + defer cleanup() + + // Arrange: Setup mock expectations where block notification fails. + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + s := newSyncer( + Config{Chain: mockChain, DB: db}, + mockAddrStore, nil, nil, + ) + + mockChain.On("IsCurrent").Return(true).Once() + mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( + []chainhash.Hash{}, nil).Once() + mockChain.On("NotifyBlocks").Return(errNotify).Once() + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 0}).Once() + mockAddrStore.On("Birthday").Return(time.Time{}).Maybe() + + // Act: Attempt chain sync initialization. + err := s.initChainSync(t.Context()) + + // Assert: Verify failure. + require.ErrorContains(t, err, "unable to start block notifications") +} + +// TestScanBatchHeadersOnly_Errors verifies error paths. +func TestScanBatchHeadersOnly_Errors(t *testing.T) { + t.Parallel() + + t.Run("GetBlockHashes_Failure", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where GetBlockHashes fails. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + mockChain.On("GetBlockHashes", mock.Anything, + mock.Anything).Return(([]chainhash.Hash)(nil), + errHashes).Once() + + // Act: Perform header-only scan. + results, err := s.scanBatchHeadersOnly(t.Context(), 0, 0) + + // Assert: Verify failure. + require.Nil(t, results) + require.ErrorIs(t, err, errHashes) + }) + + t.Run("GetBlockHeaders_Failure", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where GetBlockHeaders fails. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + mockChain.On("GetBlockHashes", mock.Anything, + mock.Anything).Return([]chainhash.Hash{{}}, nil).Once() + mockChain.On("GetBlockHeaders", mock.Anything).Return( + ([]*wire.BlockHeader)(nil), errHeaders).Once() + + // Act: Perform header-only scan again. + results, err := s.scanBatchHeadersOnly(t.Context(), 0, 0) + + // Assert: Verify failure. + require.Nil(t, results) + require.ErrorIs(t, err, errHeaders) + }) +} + +// TestCheckRollback_HeaderError verifies error when fetching fork header. +func TestCheckRollback_HeaderError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations for a rollback check where a + // header fetch failure occurs at the fork point. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + s := newSyncer( + Config{Chain: mockChain, DB: db}, + mockAddrStore, nil, nil, + ) + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 101}).Once() + + hashA := &chainhash.Hash{0x0A} + hashB := &chainhash.Hash{0x0B} + hashC := chainhash.Hash{0x0C} + + mockAddrStore.On("BlockHash", mock.Anything, int32(101)).Return(hashB, + nil).Once() + mockAddrStore.On("BlockHash", mock.Anything, int32(100)).Return(hashA, + nil).Once() + mockAddrStore.On("BlockHash", mock.Anything, mock.Anything).Return( + &chainhash.Hash{}, nil).Maybe() + + remoteHashes := make([]chainhash.Hash, 10) + remoteHashes[8] = *hashA + remoteHashes[9] = hashC + mockChain.On("GetBlockHashes", int64(92), int64(101)).Return( + remoteHashes, nil).Once() + mockChain.On("GetBlockHeader", hashA).Return( + (*wire.BlockHeader)(nil), errHeader).Once() + + // Act: Perform the rollback check. + err := s.checkRollback(t.Context()) + + // Assert: Verify failure. + require.ErrorIs(t, err, errHeader) +} + +// TestFilterBatch_Match verifies positive match logic. +func TestFilterBatch_Match(t *testing.T) { + t.Parallel() + + // Arrange: Setup a batch with a matching filter. + s := newSyncer(Config{}, nil, nil, nil) + + hash := chainhash.Hash{0x01} + results := []scanResult{ + {meta: &wtxmgr.BlockMeta{Block: wtxmgr.Block{Hash: hash}}}, + } + blockMap := make(map[chainhash.Hash]*wire.MsgBlock) + + key := builder.DeriveKey(&hash) + filter, err := gcs.BuildGCSFilter( + builder.DefaultP, builder.DefaultM, key, [][]byte{{0x01}}, + ) + require.NoError(t, err) + + filters := []*gcs.Filter{filter} + watchList := [][]byte{{0x01}} + + // Act: Filter the batch. + matched, err := s.filterBatch( + t.Context(), results, filters, blockMap, watchList, + ) + + // Assert: Verify the match. + require.NoError(t, err) + require.Len(t, matched, 1) + require.Equal(t, hash, matched[0]) +} + +// TestScanWithTargets_Empty verifies handling of empty batch results. +func TestScanWithTargets_Empty(t *testing.T) { + t.Parallel() + + // Arrange: Setup a targeted scan where the resulting block batch is + // empty. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + s := newSyncer(Config{ + DB: db, + Chain: mockChain, + SyncMethod: SyncMethodAuto, + MaxCFilterItems: 100, + }, mockAddrStore, mockTxStore, nil) + + req := &scanReq{ + startBlock: waddrmgr.BlockStamp{Height: 100}, + targets: []waddrmgr.AccountScope{ + {Scope: waddrmgr.KeyScopeBIP0084, Account: 0}}, + } + + mockTxStore.On("OutputsToWatch", mock.Anything).Return( + []wtxmgr.Credit(nil), nil).Maybe() + + mgr := &mockAccountStore{} + mockAddrStore.On("FetchScopedKeyManager", mock.Anything).Return(mgr, + nil).Times(6) + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.AnythingOfType("func(btcutil.Address) error")).Return( + nil).Times(2) + // SyncedTo is not called in the targeted scan path. + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Maybe() + + mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), + nil).Once() + mockChain.On("GetBlockHashes", int64(99), int64(100)).Return( + []chainhash.Hash{}, nil).Once() + mockChain.On("GetBlockHeaders", []chainhash.Hash{}).Return( + []*wire.BlockHeader{}, nil).Once() + + req.targets = nil + req.startBlock.Height = 99 + + // Act: Perform the scan. + err := s.scanWithTargets(t.Context(), req) + + // Assert: Verify that an empty batch error is returned. + require.ErrorIs(t, err, ErrScanBatchEmpty) +} + +// TestInitChainSync_Neutrino verifies the type switch case for NeutrinoClient. +func TestInitChainSync_Neutrino(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock neutrino chain service and a syncer with a + // NeutrinoClient. + mockCS := &mockNeutrinoChain{} + // IsCurrent called by waitUntilBackendSynced. + // Return false to keep polling until context cancel. + mockCS.On("IsCurrent").Return(false).Maybe() + + nc := &chain.NeutrinoClient{ + CS: mockCS, + } + mockAddrStore := &mockAddrStore{} + // Birthday called by SetStartTime. + mockAddrStore.On("Birthday").Return(time.Time{}).Once() + + s := newSyncer(Config{Chain: nc}, mockAddrStore, nil, nil) + + // Cancel context immediately to abort waitUntilBackendSynced. + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + // Act: Attempt chain sync initialization. + err := s.initChainSync(ctx) + + // Assert: Verify cancellation error and that Birthday was accessed. + require.Error(t, err) + mockAddrStore.AssertExpectations(t) +} + +// TestFetchAndFilterBlocks_HeaderScan verifies the optimization for empty scan +// state. +func TestFetchAndFilterBlocks_HeaderScan(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer with an empty scan state. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + scanState := NewRecoveryState(10, nil, nil) + + // Expect hash and header fetching for the empty scan state. + mockChain.On("GetBlockHashes", int64(100), int64(100)).Return( + []chainhash.Hash{{0x01}}, nil, + ).Once() + mockChain.On("GetBlockHeaders", mock.Anything).Return( + []*wire.BlockHeader{{Timestamp: time.Unix(12345, 0)}}, nil, + ).Once() + + // Act: Perform the fetch and filter operation. + results, err := s.fetchAndFilterBlocks( + t.Context(), scanState, 100, 100, + ) + + // Assert: Verify results. + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, int32(100), results[0].meta.Height) +} + +// TestScanBatchWithFullBlocks_ProcessError verifies error from ProcessBlock. +func TestScanBatchWithFullBlocks_ProcessError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations to simulate an expansion failure + // during full block scanning. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain, DB: db}, nil, nil, nil) + + addrStore := &mockAccountStore{} + rs := NewRecoveryState(10, &chainParams, nil) + rs.addrFilters = make(map[string]AddrEntry) + rs.outpoints = make(map[wire.OutPoint][]byte) + rs.branchStates[waddrmgr.BranchScope{}] = NewBranchRecoveryState( + 10, addrStore, + ) + + // Force expansion by finding an address. + addr, err := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + + rs.addrFilters[addr.EncodeAddress()] = AddrEntry{ + Address: addr, + IsLookahead: true, + addrScope: waddrmgr.AddrScope{Index: 0}, + } + block := wire.NewMsgBlock(&wire.BlockHeader{}) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + tx := wire.NewMsgTx(1) + tx.AddTxOut(wire.NewTxOut(100, pkScript)) + require.NoError(t, block.AddTransaction(tx)) + + hashes := []chainhash.Hash{{0x01}} + mockChain.On("GetBlocks", hashes).Return([]*wire.MsgBlock{block}, + nil).Once() + addrStore.On("DeriveAddr", mock.Anything, mock.Anything, + mock.Anything).Return(nil, nil, errDeriveFail).Once() + + // Act: Execute the scan. + results, err := s.scanBatchWithFullBlocks( + t.Context(), rs, 100, hashes, + ) + + // Assert: Verify derivation failure. + require.Nil(t, results) + require.ErrorContains(t, err, "derive fail") +} + +// TestDispatchScanStrategy_Auto verifies heuristics. +func TestDispatchScanStrategy_Auto(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations for the auto dispatch strategy. + mockChain := &mockChain{} + s := newSyncer( + Config{ + Chain: mockChain, + SyncMethod: SyncMethodAuto, + MaxCFilterItems: 1, + }, nil, nil, nil, + ) + scanState := NewRecoveryState(10, nil, nil) + + scanState.outpoints = make(map[wire.OutPoint][]byte) + for i := range 5 { + scanState.outpoints[wire.OutPoint{Index: uint32(i)}] = []byte{} + } + + hashes := []chainhash.Hash{{0x01}} + mockChain.On("GetBlocks", hashes).Return( + []*wire.MsgBlock{wire.NewMsgBlock(&wire.BlockHeader{})}, nil, + ).Once() + + // Act: Dispatch the scan strategy. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 100, hashes, + ) + + // Assert: Verify successful dispatch. + require.NoError(t, err) + require.Len(t, results, 1) +} + +// TestDispatchScanStrategy_AutoFallback_Final verifies fallback on +// ErrCFiltersUnavailable. +func TestDispatchScanStrategy_AutoFallback_Final(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where CFilters are unavailable, + // triggering a fallback to full blocks. + mockChain := &mockChain{} + s := newSyncer( + Config{ + Chain: mockChain, + SyncMethod: SyncMethodAuto, + }, nil, nil, nil, + ) + + scanState := NewRecoveryState(10, nil, nil) + scanState.outpoints = make(map[wire.OutPoint][]byte) + scanState.outpoints[wire.OutPoint{}] = []byte{} + hashes := []chainhash.Hash{{0x01}} + + mockChain.On("GetCFilters", hashes, mock.Anything).Return( + []*gcs.Filter(nil), ErrCFiltersUnavailable).Once() + mockChain.On("GetBlocks", hashes).Return( + []*wire.MsgBlock{wire.NewMsgBlock(&wire.BlockHeader{})}, nil, + ).Once() + + // Act: Dispatch the strategy. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 100, hashes, + ) + + // Assert: Verify successful fallback. + require.NoError(t, err) + require.Len(t, results, 1) +} + +// TestProcessChainUpdate_Disconnected verifies rollback on disconnect. +func TestProcessChainUpdate_Disconnected(t *testing.T) { + t.Parallel() + + // Arrange: Create a syncer with a database and verify initial sync + // state. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 0}).Once() + + // Act: Process a BlockDisconnected update. + err := s.processChainUpdate( + t.Context(), chain.BlockDisconnected{}, + ) + + // Assert: Verify success. + require.NoError(t, err) +} + +// TestScanWithTargets_Errors verifies error paths in scanWithTargets. +func TestScanWithTargets_Errors(t *testing.T) { + t.Parallel() + + t.Run("GetBestBlock_Failure", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where GetBestBlock fails + // during targeted scan initialization. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + + s := newSyncer( + Config{ + Chain: mockChain, + DB: db, + }, mockAddrStore, mockTxStore, nil, + ) + + req := &scanReq{ + startBlock: waddrmgr.BlockStamp{Height: 100}, + targets: []waddrmgr.AccountScope{{ + Scope: waddrmgr.KeyScopeBIP0084, Account: 0, + }}, + } + + mgr := &mockAccountStore{} + mockAddrStore.On("FetchScopedKeyManager", + mock.Anything).Return(mgr, nil) + mgr.On("AccountProperties", mock.Anything, mock.Anything).Return( + &waddrmgr.AccountProperties{}, nil) + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything).Return(nil) + mockTxStore.On("OutputsToWatch", mock.Anything).Return( + []wtxmgr.Credit(nil), nil) + mockChain.On("GetBestBlock").Return(nil, int32(0), + errBestBlock).Once() + + // Act: Attempt targeted scan. + err := s.scanWithTargets(t.Context(), req) + + // Assert: Verify failure. + require.ErrorContains(t, err, "best block fail") + }) + + t.Run("GetBlockHashes_Failure", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where GetBlockHashes fails. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + + s := newSyncer( + Config{ + Chain: mockChain, + DB: db, + }, mockAddrStore, mockTxStore, nil, + ) + + req := &scanReq{ + startBlock: waddrmgr.BlockStamp{Height: 100}, + targets: []waddrmgr.AccountScope{{ + Scope: waddrmgr.KeyScopeBIP0084, Account: 0, + }}, + } + + mgr := &mockAccountStore{} + mockAddrStore.On("FetchScopedKeyManager", + mock.Anything).Return(mgr, nil) + mgr.On("AccountProperties", mock.Anything, mock.Anything).Return( + &waddrmgr.AccountProperties{}, nil).Once() + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything).Return(nil).Once() + mockTxStore.On("OutputsToWatch", mock.Anything).Return( + []wtxmgr.Credit(nil), nil).Once() + mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, + int32(200), nil).Once() + mockChain.On("GetBlockHashes", mock.Anything, + mock.Anything).Return([]chainhash.Hash(nil), + errHashes).Once() + + // Act: Attempt targeted scan. + err := s.scanWithTargets(t.Context(), req) + + // Assert: Verify failure. + require.ErrorContains(t, err, "hashes fail") + }) + + t.Run("FetchScopedKeyManager_Failure", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations to simulate a fetch failure during + // targeted scan initialization. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + mockAddrStore.On("FetchScopedKeyManager", mock.Anything).Return( + nil, errFetchFail).Once() + + targets := []waddrmgr.AccountScope{{ + Scope: waddrmgr.KeyScopeBIP0084, Account: 0, + }} + + // Act: Attempt a targeted scan. + err := s.scanWithTargets( + t.Context(), &scanReq{ + targets: targets, + startBlock: waddrmgr.BlockStamp{Height: 100}, + }, + ) + + // Assert: Verify propagation. + require.ErrorContains(t, err, "fetch fail") + }) +} + +// TestScanBatchWithCFilters_InitResultsError verifies error propagation. +func TestScanBatchWithCFilters_InitResultsError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where header retrieval fails during + // initialization for a CFilter scan. + mockChain := &mockChain{} + s := newSyncer( + Config{ + Chain: mockChain, + SyncMethod: SyncMethodCFilters, + }, nil, nil, nil, + ) + + hashes := []chainhash.Hash{{0x01}} + mockChain.On("GetCFilters", hashes, mock.Anything).Return( + []*gcs.Filter{{}}, nil).Once() + mockChain.On("GetBlockHeaders", hashes).Return( + []*wire.BlockHeader(nil), errHeaders).Once() + + // Act: Attempt batch scan with CFilters. + results, err := s.scanBatchWithCFilters( + t.Context(), nil, 100, hashes, + ) + + // Assert: Verify failure. + require.Nil(t, results) + require.ErrorContains(t, err, "headers fail") +} + +// TestProcessChainUpdate verifies processChainUpdate for all update types. +func TestProcessChainUpdate(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + tests := []struct { + name string + update interface{} + setup func(*mockAddrStore, *mockTxStore, *mockChain) + }{ + { + name: "BlockConnected", + update: chain.BlockConnected{ + Block: wtxmgr.Block{Height: 100}, + }, + setup: func(as *mockAddrStore, ts *mockTxStore, c *mockChain) { + as.On("SetSyncedTo", mock.Anything, mock.MatchedBy( + func(bs *waddrmgr.BlockStamp) bool { + return bs.Height == 100 + })).Return(nil).Once() + }, + }, + { + name: "RelevantTx", + update: chain.RelevantTx{ + TxRecord: &wtxmgr.TxRecord{MsgTx: *wire.NewMsgTx(1)}, + }, + setup: func(as *mockAddrStore, ts *mockTxStore, c *mockChain) { + ts.On("InsertUnconfirmedTx", mock.Anything, mock.Anything, + mock.Anything).Return(nil).Once() + }, + }, + { + name: "FilteredBlockConnected", + update: chain.FilteredBlockConnected{ + Block: &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 102}, + }, + }, + setup: func(as *mockAddrStore, ts *mockTxStore, c *mockChain) { + as.On("SetSyncedTo", mock.Anything, mock.MatchedBy( + func(bs *waddrmgr.BlockStamp) bool { + return bs.Height == 102 + })).Return(nil).Once() + }, + }, + { + name: "BlockDisconnected", + update: chain.BlockDisconnected{ + Block: wtxmgr.Block{Height: 100, Hash: chainhash.Hash{0x01}}, + }, + setup: func(as *mockAddrStore, ts *mockTxStore, c *mockChain) { + as.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}, + ).Once() + as.On( + "BlockHash", mock.Anything, mock.Anything, + ).Return(&chainhash.Hash{}, nil).Maybe() + + remoteHashes := make([]chainhash.Hash, 10) + c.On( + "GetBlockHashes", mock.Anything, mock.Anything, + ).Return(remoteHashes, nil).Once() + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Arrange + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + mockChain := &mockChain{} + s := newSyncer( + Config{ + Chain: mockChain, + ChainParams: &chainParams, + DB: db, + }, + mockAddrStore, mockTxStore, nil, + ) + + tc.setup(mockAddrStore, mockTxStore, mockChain) + + // Act + err := s.processChainUpdate(t.Context(), tc.update) + + // Assert + require.NoError(t, err) + }) + } +} + +// TestHandleChainUpdate_SpecialNotifs verifies RescanProgress and +// RescanFinished. +func TestHandleChainUpdate_SpecialNotifs(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer for special notification handling. + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{}, mockAddrStore, nil, nil) + + // 1. RescanProgress + // Act: Handle RescanProgress. + err := s.handleChainUpdate( + t.Context(), &chain.RescanProgress{ + Height: 100, Hash: chainhash.Hash{0x01}, + }, + ) + require.NoError(t, err) + + // 2. RescanFinished + // Act: Handle RescanFinished. + err = s.handleChainUpdate( + t.Context(), &chain.RescanFinished{ + Height: 100, Hash: &chainhash.Hash{0x01}, + }, + ) + require.NoError(t, err) +} + +// TestSyncStateString verifies String representations. +func TestSyncStateString(t *testing.T) { + t.Parallel() + + // Arrange: Define test cases for syncState string conversion. + tests := []struct { + state syncState + want string + }{ + {syncStateBackendSyncing, "backend-syncing"}, + {syncStateSyncing, "syncing"}, + {syncStateSynced, "synced"}, + {syncStateRescanning, "rescanning"}, + {syncState(99), "unknown sync state"}, + } + + // Act & Assert: Execute test cases. + for _, tt := range tests { + require.Equal(t, tt.want, tt.state.String()) + } +} + +// TestFetchAndFilterBlocks_BatchCapping verifies endHeight calculation. +func TestFetchAndFilterBlocks_BatchCapping(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer with expectations for batch capping. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + scanState := NewRecoveryState(10, nil, nil) + + // Expect GetBlockHashes with a capped range based on recoveryBatchSize. + mockChain.On("GetBlockHashes", int64(100), int64(2099)).Return( + []chainhash.Hash{{0x01}}, nil, + ).Once() + mockChain.On("GetBlockHeaders", mock.Anything).Return( + []*wire.BlockHeader{{}}, nil, + ).Once() + + // Act: Perform the fetch. + results, err := s.fetchAndFilterBlocks( + t.Context(), scanState, 100, 5000, + ) + + // Assert: Verify success. + require.NoError(t, err) + require.Len(t, results, 1) +} + +// TestRunSyncStep_Unfinished verifies the early return if sync not finished. +func TestRunSyncStep_Unfinished(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer and mock an incomplete sync state. + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + + db, cleanup := setupTestDB(t) + defer cleanup() + + s := newSyncer( + Config{ + Chain: mockChain, + DB: db, + }, mockAddrStore, mockTxStore, nil, + ) + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 90}).Maybe() + mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), + nil).Once() + + mockAddrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore(nil)).Maybe() + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything).Return(nil).Maybe() + + mockTxStore.On("OutputsToWatch", mock.Anything).Return( + []wtxmgr.Credit(nil), nil).Maybe() + mockChain.On("GetBlockHashes", int64(91), int64(100)).Return( + []chainhash.Hash{{0x01}}, nil).Once() + mockChain.On("GetBlockHeaders", mock.Anything).Return( + []*wire.BlockHeader{{}}, nil).Once() + mockAddrStore.On("SetSyncedTo", mock.Anything, + mock.Anything).Return(nil).Maybe() + + // Act: Execute a sync step. + err := s.runSyncStep(t.Context()) + + // Assert: Verify success. + require.NoError(t, err) +} + +// TestDispatchScanStrategy_OtherMethods verifies FullBlocks, CFilters and +// Default. +func TestDispatchScanStrategy_OtherMethods(t *testing.T) { + t.Parallel() + + hashes := []chainhash.Hash{{0x01}} + scanState := NewRecoveryState(10, nil, nil) + + t.Run("FullBlocks", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer for FullBlocks strategy. + mockChain := &mockChain{} + s := newSyncer( + Config{ + Chain: mockChain, + SyncMethod: SyncMethodFullBlocks, + }, nil, nil, nil, + ) + mockChain.On("GetBlocks", hashes).Return([]*wire.MsgBlock{ + wire.NewMsgBlock(&wire.BlockHeader{})}, nil).Once() + + // Act: Dispatch the strategy. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 100, hashes, + ) + + // Assert: Verify success. + require.NoError(t, err) + require.Len(t, results, 1) + }) + + t.Run("CFilters", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer for CFilters strategy. + mockChain := &mockChain{} + s := newSyncer( + Config{ + Chain: mockChain, + SyncMethod: SyncMethodCFilters, + }, nil, nil, nil, + ) + mockChain.On("GetCFilters", hashes, mock.Anything).Return( + []*gcs.Filter{{}}, nil).Once() + mockChain.On("GetBlockHeaders", hashes).Return( + []*wire.BlockHeader{{}}, nil).Once() + mockChain.On("GetBlocks", mock.Anything).Return( + []*wire.MsgBlock{wire.NewMsgBlock(&wire.BlockHeader{})}, + nil).Once() + + // Act: Dispatch the strategy. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 100, hashes, + ) + + // Assert: Verify success. + require.NoError(t, err) + require.Len(t, results, 1) + }) + + t.Run("Default_Unknown", func(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer with an unknown method. + mockChain := &mockChain{} + s := newSyncer( + Config{ + Chain: mockChain, + SyncMethod: 99, + }, nil, nil, nil, + ) + + // Act: Dispatch the strategy. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 100, hashes, + ) + + // Assert: Verify failure for unknown method. + require.Nil(t, results) + require.ErrorContains(t, err, "unknown sync method") + }) +} + +// TestHandleChainUpdate_Error verifies that handleChainUpdate returns error if +// processChainUpdate fails. +func TestHandleChainUpdate_Error(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + defer cleanup() + + // Arrange: Setup a syncer where chain update processing will fail due + // to a database error. + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Maybe() + mockAddrStore.On("BlockHash", mock.Anything, mock.Anything).Return( + (*chainhash.Hash)(nil), errDBFail).Once() + + // Act: Attempt to handle a BlockDisconnected update. + err := s.handleChainUpdate( + t.Context(), chain.BlockDisconnected{}, + ) + + // Assert: Verify failure. + require.ErrorContains(t, err, "failed to process chain update") +} + +// TestRunSyncStep_Success verifies the idle path in runSyncStep. +func TestRunSyncStep_Success(t *testing.T) { + t.Parallel() + + // Arrange: Setup a syncer and mock a notification arrival to trigger + // the idle processing path. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + s := newSyncer( + Config{ + Chain: mockChain, + DB: db, + }, mockAddrStore, mockTxStore, nil, + ) + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Maybe() + mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), + nil).Once() + mockTxStore.On("UnminedTxs", mock.Anything).Return([]*wire.MsgTx{}, + nil).Once() + + notifChan := make(chan any, 1) + mockChain.On("Notifications").Return((<-chan any)(notifChan)).Maybe() + + notifChan <- chain.BlockConnected{Block: wtxmgr.Block{Height: 101}} + + mockAddrStore.On("SetSyncedTo", mock.Anything, + mock.Anything).Return(nil).Once() + + // Act: Execute a sync step. + err := s.runSyncStep(t.Context()) + + // Assert: Verify success. + require.NoError(t, err) +} + +// TestScanBatchWithCFilters_HorizonExpansion verifies the re-matching logic +// when a horizon is expanded. +func TestScanBatchWithCFilters_HorizonExpansion(t *testing.T) { + t.Parallel() + + // Arrange: Setup a complex mock scenario where finding an address + // triggers a horizon expansion, requiring a re-match of the block + // batch. + mockChain := &mockChain{} + addrStore := &mockAddrStore{} + accountStore := &mockAccountStore{} + + db, cleanup := setupTestDB(t) + defer cleanup() + + s := newSyncer(Config{Chain: mockChain, DB: db}, addrStore, nil, nil) + + hashes := []chainhash.Hash{{0x01}, {0x02}} + + mockChain.On("GetCFilters", hashes, wire.GCSFilterRegular).Return( + []*gcs.Filter{nil, nil}, nil).Once() + mockChain.On("GetBlockHeaders", hashes).Return( + []*wire.BlockHeader{{}, {}}, nil).Once() + + block1 := wire.NewMsgBlock(&wire.BlockHeader{}) + block2 := wire.NewMsgBlock(&wire.BlockHeader{}) + mockChain.On("GetBlocks", mock.MatchedBy(func(h []chainhash.Hash) bool { + return len(h) == 2 + })).Return([]*wire.MsgBlock{block1, block2}, nil).Once() + + scanState := NewRecoveryState(1, &chainParams, addrStore) + + scanState.addrFilters = make(map[string]AddrEntry) + scanState.outpoints = make(map[wire.OutPoint][]byte) + + bs := waddrmgr.BranchScope{} + scanState.branchStates[bs] = NewBranchRecoveryState(1, accountStore) + + // Found address in block 1. + addr, err := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + + scanState.addrFilters[addr.EncodeAddress()] = AddrEntry{ + Address: addr, + IsLookahead: true, + addrScope: waddrmgr.AddrScope{Index: 0}, + } + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + tx1 := wire.NewMsgTx(1) + tx1.AddTxOut(wire.NewTxOut(100, pkScript)) + require.NoError(t, block1.AddTransaction(tx1)) + + // Mock DeriveAddr and ExtendAddresses for expansion. + expAddr, err := btcutil.NewAddressPubKeyHash( + append([]byte{1}, make([]byte, 19)...), &chainParams, + ) + require.NoError(t, err) + + accountStore.On("DeriveAddr", mock.Anything, mock.Anything, + mock.Anything).Return(expAddr, []byte{}, nil).Maybe() + accountStore.On("ExtendAddresses", mock.Anything, mock.Anything, + mock.Anything, mock.Anything).Return( + []btcutil.Address{expAddr}, [][]byte{make([]byte, 20)}, nil, + ).Once() + + // Act: Perform a batch scan with CFilters. + results, err := s.scanBatchWithCFilters( + t.Context(), scanState, 100, hashes, + ) + + // Assert: Verify that both blocks were returned after expansion. + require.NoError(t, err) + require.Len(t, results, 2) +} + +// TestRunSyncStep_AdvanceError verifies that runSyncStep returns errors +// from advanceChainSync. +func TestRunSyncStep_AdvanceError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations to simulate a failure during + // loadFullScanState within runSyncStep. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + s := newSyncer( + Config{Chain: mockChain, DB: db}, + mockAddrStore, nil, nil, + ) + + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Maybe() + + mockChain.On("GetBestBlock").Return( + &chainhash.Hash{}, int32(101), nil).Once() + + mgr := &mockAccountStore{} + mockAddrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore{mgr}).Once() + mgr.On("ActiveAccounts").Return([]uint32{0}).Once() + mgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + + mockAddrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0084).Return(nil, errLoadStateFail).Once() + + // Act: Execute a single sync step. + err := s.runSyncStep(t.Context()) + + // Assert: Verify error propagation. + require.ErrorContains(t, err, "load state fail") +} + +// TestLoadFullScanState_Error verifies error propagation. +func TestLoadFullScanState_Error(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations to simulate a database failure + // when loading scan state. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + mgr := &mockAccountStore{} + mgr.On("ActiveAccounts").Return([]uint32{0}).Once() + mgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + + mockAddrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore{mgr}).Once() + mockAddrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0084).Return(nil, errDBMock).Once() + + // Act: Attempt to load the full scan state. + state, err := s.loadFullScanState(t.Context()) + + // Assert: Verify failure. + require.Nil(t, state) + require.ErrorContains(t, err, "db error") +} + +// TestScanWithRewind_Error verifies error propagation from DBPutRewind. +func TestScanWithRewind_Error(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations for a rewind scan where a database + // rollback failure occurs. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockTxStore := &mockTxStore{} + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Maybe() + + mockAddrStore.On("SetSyncedTo", mock.Anything, + mock.Anything).Return(nil).Maybe() + mockTxStore.On("Rollback", mock.Anything, mock.Anything).Return( + errRollbackFail).Once() + + // Act: Attempt to perform a scan with rewind. + err := s.scanWithRewind( + t.Context(), &scanReq{ + startBlock: waddrmgr.BlockStamp{Height: 90}, + }, + ) + + // Assert: Verify rollback failure is propagated. + require.ErrorContains(t, err, "rollback fail") +} + +// TestMatchAndFetchBatch_GetBlockHeadersError verifies error handling. +func TestMatchAndFetchBatch_GetBlockHeadersError(t *testing.T) { + t.Parallel() + + // Arrange: Create a nil filter to force a match, bypassing complex + // filter logic, then mock a block fetch failure. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + filters := []*gcs.Filter{nil} + results := []scanResult{{ + meta: &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Hash: chainhash.Hash{0x01}}, + }, + }} + + blockMap := make(map[chainhash.Hash]*wire.MsgBlock) + + state := NewRecoveryState(10, nil, nil) + + mockChain.On("GetBlocks", mock.Anything).Return( + []*wire.MsgBlock(nil), errGetBlocks).Once() + + // Act: Attempt to match and fetch a batch. + err := s.matchAndFetchBatch( + t.Context(), state, results, filters, blockMap, + ) + + // Assert: Verify failure. + require.ErrorContains(t, err, "get blocks fail") +} + +// TestScanBatchWithCFilters_FilterBatchError verifies error propagation. +func TestScanBatchWithCFilters_FilterBatchError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where CFilter retrieval fails. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + hashes := []chainhash.Hash{{0x01}} + + mockChain.On("GetCFilters", hashes, wire.GCSFilterRegular).Return( + []*gcs.Filter(nil), errCFilterFail).Once() + + // Act: Attempt a batch scan using CFilters. + results, err := s.scanBatchWithCFilters( + t.Context(), nil, 100, hashes, + ) + + // Assert: Verify failure. + require.Nil(t, results) + require.ErrorContains(t, err, "cfilter fail") +} + +// TestScanBatch_GetScanDataError verifies scanBatch failure. +func TestScanBatch_GetScanDataError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where scan data loading fails + // during a batch scan. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockAddrStore := &mockAddrStore{} + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + + mgr := &mockAccountStore{} + mockAddrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore{mgr}).Once() + mgr.On("ActiveAccounts").Return([]uint32{0}).Once() + mgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + mockAddrStore.On("FetchScopedKeyManager", + waddrmgr.KeyScopeBIP0084).Return(nil, errActiveMgrsFail).Once() + + // Act: Attempt to execute scanBatch. + err := s.scanBatch( + t.Context(), waddrmgr.BlockStamp{Height: 100}, 105, + ) + + // Assert: Verify error propagation. + require.ErrorContains(t, err, "active managers fail") +} + +// TestInitResultsForCFilterScan_Error verifies basic error propagation (e.g. +// GetBlockHeader). +func TestInitResultsForCFilterScan_Error(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where header retrieval fails during + // initialization for a CFilter scan. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + hashes := []chainhash.Hash{{0x01}} + + mockChain.On("GetBlockHeaders", hashes).Return( + []*wire.BlockHeader(nil), errHeaders).Once() + + // Act: Initialize results for a CFilter scan. + results, err := s.initResultsForCFilterScan(t.Context(), 100, hashes) + + // Assert: Verify failure. + require.Nil(t, results) + require.ErrorContains(t, err, "headers fail") +} + +// TestDispatchScanStrategy_AutoError verifies error return in Auto mode. +func TestDispatchScanStrategy_AutoError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where header retrieval fails during + // an auto-dispatch scan. + mockChain := &mockChain{} + s := newSyncer( + Config{Chain: mockChain, SyncMethod: SyncMethodAuto}, + nil, nil, nil, + ) + + hashes := []chainhash.Hash{{0x01}} + scanState := NewRecoveryState(1, nil, nil) + + mockChain.On("GetCFilters", hashes, mock.Anything).Return( + []*gcs.Filter{{}}, nil).Once() + mockChain.On("GetBlockHeaders", hashes).Return( + ([]*wire.BlockHeader)(nil), errOther).Once() + + // Act: Dispatch the scan strategy. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 100, hashes, + ) + + // Assert: Verify failure. + require.Nil(t, results) + require.ErrorIs(t, err, errOther) +} + +// TestAdvanceChainSync_SmallGap verifies the silent sync path. +func TestAdvanceChainSync_SmallGap(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations for a small gap where silent sync + // is preferred. + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + + db, cleanup := setupTestDB(t) + defer cleanup() + + s := newSyncer( + Config{Chain: mockChain, DB: db}, + mockAddrStore, mockTxStore, nil, + ) + + mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(105), + nil).Once() + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Once() + mockAddrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore(nil)).Once() + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything).Return(nil).Once() + + mockTxStore.On("OutputsToWatch", mock.Anything).Return( + []wtxmgr.Credit(nil), nil).Once() + mockChain.On("GetBlockHashes", int64(101), int64(105)).Return( + []chainhash.Hash{{0x01}}, nil).Once() + mockChain.On("GetBlockHeaders", mock.Anything).Return( + []*wire.BlockHeader{{}}, nil).Once() + mockAddrStore.On("SetSyncedTo", mock.Anything, + mock.Anything).Return(nil).Once() + + // Act: Advance chain sync. + finished, err := s.advanceChainSync(t.Context()) + + // Assert: Verify state transition to backend-syncing. + require.NoError(t, err) + require.False(t, finished) + require.Equal(t, uint32(syncStateBackendSyncing), s.state.Load()) +} + +// TestRunSyncStep_BroadcastError verifies error propagation. +func TestRunSyncStep_BroadcastError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where a broadcast-related failure + // occurs during a sync step. + db, cleanup := setupTestDB(t) + defer cleanup() + + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + + s := newSyncer( + Config{Chain: mockChain, DB: db}, + mockAddrStore, mockTxStore, nil, + ) + + mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), + nil).Once() + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Maybe() + mockTxStore.On("UnminedTxs", mock.Anything).Return([]*wire.MsgTx(nil), + errBroadcast).Once() + + // Act: Execute a sync step. + err := s.runSyncStep(t.Context()) + + // Assert: Verify failure. + require.ErrorIs(t, err, errBroadcast) +} + +// TestFetchAndFilterBlocks_DispatchError verifies error from +// dispatchScanStrategy. +func TestFetchAndFilterBlocks_DispatchError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where an invalid sync method is + // encountered during block filtering. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain, SyncMethod: 99}, nil, nil, nil) + + hashes := []chainhash.Hash{{0x01}} + mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( + hashes, nil).Once() + + scanState := NewRecoveryState(1, nil, nil) + scanState.outpoints = make(map[wire.OutPoint][]byte) + scanState.outpoints[wire.OutPoint{}] = []byte{} + + // Act: Attempt to fetch and filter blocks. + results, err := s.fetchAndFilterBlocks(t.Context(), scanState, 100, 100) + + // Assert: Verify unknown sync method error. + require.Nil(t, results) + require.ErrorContains(t, err, "unknown sync method") +} + +// TestAdvanceChainSync_ScanBatchError verifies error propagation. +func TestAdvanceChainSync_ScanBatchError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where address iteration fails + // during chain sync advancement. + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + + db, cleanup := setupTestDB(t) + defer cleanup() + + s := newSyncer( + Config{Chain: mockChain, DB: db}, + mockAddrStore, nil, nil, + ) + + mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(105), + nil).Once() + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Once() + mockAddrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore(nil)).Once() + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything).Return(errScan).Once() + + // Act: Advance chain sync. + finished, err := s.advanceChainSync(t.Context()) + + // Assert: Verify failure. + require.False(t, finished) + require.ErrorIs(t, err, errScan) +} + +// TestDispatchScanStrategy_FullBlocksError verifies error propagation. +func TestDispatchScanStrategy_FullBlocksError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where block retrieval fails during + // a full-block scan. + mockChain := &mockChain{} + s := newSyncer( + Config{Chain: mockChain, SyncMethod: SyncMethodFullBlocks}, + nil, nil, nil, + ) + + hashes := []chainhash.Hash{{0x01}} + scanState := NewRecoveryState(1, nil, nil) + + mockChain.On("GetBlocks", hashes).Return([]*wire.MsgBlock(nil), + errBlocks).Once() + + // Act: Dispatch the strategy. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 100, hashes, + ) + + // Assert: Verify failure. + require.Nil(t, results) + require.ErrorIs(t, err, errBlocks) +} + +// TestExtractAddrEntries_NonStd verifies non-standard script handling. +func TestExtractAddrEntries_NonStd(t *testing.T) { + t.Parallel() + + // Arrange: Initialize a syncer and create various output scripts, + // including non-standard and OP_RETURN scripts. + s := newSyncer( + Config{ChainParams: &chainParams}, + nil, nil, nil, + ) + + pkh, err := btcutil.NewAddressPubKeyHash( + make([]byte, 20), &chainParams, + ) + require.NoError(t, err) + pkScript, err := txscript.PayToAddrScript(pkh) + require.NoError(t, err) + + txOuts := []*wire.TxOut{ + { + // OP_DATA_1 but no data byte follows. (Error path) + PkScript: []byte{0x01}, + }, + { + // OP_RETURN (Empty addrs, no error) + PkScript: []byte{0x6a, 0x04, 0xde, 0xad, 0xbe, 0xef}, + }, + { + // Standard P2PKH (Success path) + PkScript: pkScript, + }, + } + + // Act: Extract address entries. + entries := s.extractAddrEntries(txOuts) + + // Assert: Verify that only the standard P2PKH output was extracted. + require.Len(t, entries, 1) +} + +// TestAdvanceChainSync_GetBestBlockError verifies error propagation. +func TestAdvanceChainSync_GetBestBlockError(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations where GetBestBlock fails during + // chain sync advancement. + mockChain := &mockChain{} + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + + mockChain.On("GetBestBlock").Return((*chainhash.Hash)(nil), int32(0), + errBestBlock).Once() + + // Act: Advance chain sync. + finished, err := s.advanceChainSync(t.Context()) + + // Assert: Verify failure. + require.False(t, finished) + require.ErrorIs(t, err, errBestBlock) +} + +// TestDispatchScanStrategy_AutoDefaultThreshold verifies threshold=0 branch. +func TestDispatchScanStrategy_AutoDefaultThreshold(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations for auto strategy with a zero + // threshold for compact filters. + mockChain := &mockChain{} + s := newSyncer(Config{ + Chain: mockChain, + SyncMethod: SyncMethodAuto, + MaxCFilterItems: 0, + }, nil, nil, nil) + + hashes := []chainhash.Hash{{0x01}} + scanState := NewRecoveryState(1, nil, nil) + + mockChain.On("GetCFilters", hashes, mock.Anything).Return( + []*gcs.Filter{{}}, nil).Once() + mockChain.On("GetBlockHeaders", hashes).Return( + []*wire.BlockHeader{{}}, nil).Once() + mockChain.On("GetBlocks", mock.Anything).Return( + []*wire.MsgBlock{ + wire.NewMsgBlock(&wire.BlockHeader{})}, nil).Once() + + // Act: Dispatch the strategy. + results, err := s.dispatchScanStrategy( + t.Context(), scanState, 100, hashes, + ) + + // Assert: Verify success. + require.NoError(t, err) + require.Len(t, results, 1) +} + +// TestAdvanceChainSync_LargeGap verifies the explicit syncing state. +func TestAdvanceChainSync_LargeGap(t *testing.T) { + t.Parallel() + + // Arrange: Setup mock expectations for a large sync gap where explicit + // scanning is triggered. + mockChain := &mockChain{} + mockAddrStore := &mockAddrStore{} + mockTxStore := &mockTxStore{} + + db, cleanup := setupTestDB(t) + defer cleanup() + + s := newSyncer( + Config{Chain: mockChain, DB: db}, + mockAddrStore, mockTxStore, nil, + ) + + mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(110), + nil).Once() + mockAddrStore.On("SyncedTo").Return( + waddrmgr.BlockStamp{Height: 100}).Once() + + // The following mocks use Maybe() because for a large gap, the syncer + // transitions to SyncStateSyncing and returns early, skipping these + // calls. + mockAddrStore.On("ActiveScopedKeyManagers").Return( + []waddrmgr.AccountStore(nil)).Maybe() + mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, + mock.Anything).Return(nil).Maybe() + + mockTxStore.On("OutputsToWatch", mock.Anything).Return( + []wtxmgr.Credit(nil), nil).Maybe() + mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( + []chainhash.Hash{{0x01}}, nil).Maybe() + mockChain.On("GetBlockHeaders", mock.Anything).Return( + []*wire.BlockHeader{{}}, nil).Maybe() + mockAddrStore.On("SetSyncedTo", mock.Anything, + mock.Anything).Return(nil).Maybe() + + // Act: Advance chain sync. + finished, err := s.advanceChainSync(t.Context()) + + // Assert: Verify state transition to syncing. + require.NoError(t, err) + require.False(t, finished) + require.Equal(t, uint32(syncStateSyncing), s.state.Load()) +} From 424d210f42c86697f0361c5169f9e8f02b32c47e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 22 Jan 2026 08:09:44 +0800 Subject: [PATCH 279/691] wallet: address gemini reviews --- wallet/controller_test.go | 59 +++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/wallet/controller_test.go b/wallet/controller_test.go index cad13d6ceb..efbb83431d 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -50,40 +50,45 @@ func TestHandleUnlockReq(t *testing.T) { func TestHandleUnlockReq_Errors(t *testing.T) { t.Parallel() - // 1. ErrStateForbidden (Wallet Locked). - // - // Arrange: Create a test wallet. By default, it is in the 'Stopped' - // state. - w, deps := createTestWalletWithMocks(t) + t.Run("ErrStateForbidden", func(t *testing.T) { + t.Parallel() - pass := []byte("password") - req := newUnlockReq(UnlockRequest{Passphrase: pass}) + // Arrange: Create a test wallet. By default, it is in the 'Stopped' + // state. + w, _ := createTestWalletWithMocks(t) - // Act: Attempt to unlock the wallet while it is stopped. - w.handleUnlockReq(req) + pass := []byte("password") + req := newUnlockReq(UnlockRequest{Passphrase: pass}) - // Assert: Verify that the request fails with ErrStateForbidden. - err := <-req.resp - require.ErrorIs(t, err, ErrStateForbidden) + // Act: Attempt to unlock the wallet while it is stopped. + w.handleUnlockReq(req) - // 2. DBUnlock failure. - // - // Arrange: Transition the wallet to 'Started' so the state check - // passes, but setup the address manager to return an error. - require.NoError(t, w.state.toStarting()) - require.NoError(t, w.state.toStarted()) + // Assert: Verify that the request fails with ErrStateForbidden. + err := <-req.resp + require.ErrorIs(t, err, ErrStateForbidden) + }) - req = newUnlockReq(UnlockRequest{Passphrase: pass}) - deps.addrStore.On("Unlock", mock.Anything, pass).Return( - errDBMock, - ).Once() + t.Run("DBUnlock_Failure", func(t *testing.T) { + t.Parallel() - // Act: Attempt to unlock the wallet again. - w.handleUnlockReq(req) + // Arrange: Create a test wallet and transition to 'Started'. + w, deps := createTestWalletWithMocks(t) + require.NoError(t, w.state.toStarting()) + require.NoError(t, w.state.toStarted()) + + pass := []byte("password") + req := newUnlockReq(UnlockRequest{Passphrase: pass}) + deps.addrStore.On("Unlock", mock.Anything, pass).Return( + errDBMock, + ).Once() + + // Act: Attempt to unlock the wallet. + w.handleUnlockReq(req) - // Assert: Verify that the database error is propagated. - err = <-req.resp - require.ErrorContains(t, err, "db error") + // Assert: Verify that the database error is propagated. + err := <-req.resp + require.ErrorContains(t, err, "db error") + }) } // TestHandleLockReq verifies that the handleLockReq method correctly processes From e15a57072da84a4a765645d101beb50899ef0a6c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 23 Jan 2026 09:23:58 +0800 Subject: [PATCH 280/691] wallet: fix targeted scan loop and refactor TestScanWithTargets_Empty This commit fixes a logic error in 'scanWithTargets' where the scan loop incorrectly used an exclusive upper bound, causing it to skip the best block (chain tip). The loop now uses an inclusive bound ('<='). Additionally, 'TestScanWithTargets_Empty' is refactored to correctly simulate a targeted scan path that results in an empty batch. The test now properly mocks 'OutputsToWatch' and 'FetchScopedKeyManager' calls, ensures 'scanState' is non-empty to avoid header-only fallback, and verifies all mock expectations. --- wallet/syncer.go | 8 ++++++-- wallet/syncer_test.go | 20 +++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/wallet/syncer.go b/wallet/syncer.go index 625f531963..d1ea32b3b9 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -1253,8 +1253,12 @@ func (s *syncer) scanWithTargets(ctx context.Context, req *scanReq) error { log.Infof("Starting targeted rescan from height %d to %d for %d "+ "accounts", startHeight, bestHeight, len(req.targets)) - // Loop until caught up. - for startHeight < bestHeight { + // Loop until caught up. We use an inclusive condition (<=) because + // startHeight represents the first block of the missing range and + // bestHeight is the last block (the chain tip). If we used a strict + // inequality (<), the tip would be skipped when the wallet is only one + // block behind. + for startHeight <= bestHeight { // Cap end height. endHeight := min( startHeight+int32(recoveryBatchSize)-1, bestHeight, diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index d77a03aaac..58cf332f6d 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -2117,6 +2117,11 @@ func TestScanWithTargets_Empty(t *testing.T) { mockChain := &mockChain{} mockAddrStore := &mockAddrStore{} mockTxStore := &mockTxStore{} + + defer mockChain.AssertExpectations(t) + defer mockAddrStore.AssertExpectations(t) + defer mockTxStore.AssertExpectations(t) + s := newSyncer(Config{ DB: db, Chain: mockChain, @@ -2131,28 +2136,29 @@ func TestScanWithTargets_Empty(t *testing.T) { } mockTxStore.On("OutputsToWatch", mock.Anything).Return( - []wtxmgr.Credit(nil), nil).Maybe() + []wtxmgr.Credit{{PkScript: []byte{0x01}}}, nil).Once() mgr := &mockAccountStore{} mockAddrStore.On("FetchScopedKeyManager", mock.Anything).Return(mgr, - nil).Times(6) + nil).Times(3) + mgr.On("AccountProperties", mock.Anything, mock.Anything).Return( + &waddrmgr.AccountProperties{}, nil).Once() mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, mock.AnythingOfType("func(btcutil.Address) error")).Return( - nil).Times(2) + nil).Once() // SyncedTo is not called in the targeted scan path. mockAddrStore.On("SyncedTo").Return( waddrmgr.BlockStamp{Height: 100}).Maybe() mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), nil).Once() - mockChain.On("GetBlockHashes", int64(99), int64(100)).Return( + mockChain.On("GetBlockHashes", int64(100), int64(100)).Return( []chainhash.Hash{}, nil).Once() + mockChain.On("GetCFilters", []chainhash.Hash{}, + wire.GCSFilterRegular).Return([]*gcs.Filter{}, nil).Once() mockChain.On("GetBlockHeaders", []chainhash.Hash{}).Return( []*wire.BlockHeader{}, nil).Once() - req.targets = nil - req.startBlock.Height = 99 - // Act: Perform the scan. err := s.scanWithTargets(t.Context(), req) From 8b0cb4a64bb8957fb80b4332636606153d6e3e13 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sun, 21 Dec 2025 21:18:02 +0800 Subject: [PATCH 281/691] wallet: notify chain backend of new addresses in address manager --- wallet/address_manager.go | 31 ++++++++++++++++++++++--------- wallet/example_test.go | 1 + 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 163b0d6d9c..5ef600dbd1 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -205,11 +205,6 @@ func (w *Wallet) NewAddress(_ context.Context, accountName string, return nil, err } - chainClient, err := w.requireChainClient() - if err != nil { - return nil, err - } - manager, err := w.addrStore.FetchScopedKeyManager(keyScope) if err != nil { return nil, err @@ -221,7 +216,7 @@ func (w *Wallet) NewAddress(_ context.Context, accountName string, } // Notify the rpc server about the newly created address. - err = chainClient.NotifyReceived([]btcutil.Address{addr}) + err = w.cfg.Chain.NotifyReceived([]btcutil.Address{addr}) if err != nil { return nil, err } @@ -602,12 +597,25 @@ func (w *Wallet) ImportPublicKey(_ context.Context, pubKey *btcec.PublicKey, return err } - return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + var addr btcutil.Address + + err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) - _, err := manager.ImportPublicKey(addrmgrNs, pubKey, nil) - return err + ma, err := manager.ImportPublicKey(addrmgrNs, pubKey, nil) + if err != nil { + return err + } + + addr = ma.Address() + + return nil }) + if err != nil { + return err + } + + return w.cfg.Chain.NotifyReceived([]btcutil.Address{addr}) } // ImportTaprootScript imports a taproot script for tracking and spending. @@ -662,6 +670,11 @@ func (w *Wallet) ImportTaprootScript(_ context.Context, return nil, err } + err = w.cfg.Chain.NotifyReceived([]btcutil.Address{addr.Address()}) + if err != nil { + return nil, err + } + return addr, nil } diff --git a/wallet/example_test.go b/wallet/example_test.go index 7c18e01f6d..6babad60d7 100644 --- a/wallet/example_test.go +++ b/wallet/example_test.go @@ -40,6 +40,7 @@ func testWallet(t *testing.T) *Wallet { chainClient := &mockChainClient{} w.chainClient = chainClient + w.cfg.Chain = chainClient // Start the wallet. w.StartDeprecated() From a92763cfbcdd6dbaba7fbdc325daa57648ddbdf9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 15 Jan 2026 17:09:16 +0800 Subject: [PATCH 282/691] wallet: use `t.Context` in the tests --- wallet/account_manager_test.go | 2 +- wallet/tx_publisher_test.go | 23 ++++++++--------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 02d3252fae..059fd150e6 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -659,7 +659,7 @@ func TestFetchAccountBalances(t *testing.T) { ) require.NoError(t, err) _, err = w.NewAccount( - ctx, waddrmgr.KeyScopeBIP0049Plus, "acc1-bip49", + t.Context(), waddrmgr.KeyScopeBIP0049Plus, "acc1-bip49", ) require.NoError(t, err) diff --git a/wallet/tx_publisher_test.go b/wallet/tx_publisher_test.go index ddbf7981ec..070e908a9b 100644 --- a/wallet/tx_publisher_test.go +++ b/wallet/tx_publisher_test.go @@ -5,7 +5,6 @@ package wallet import ( - "context" "crypto/sha256" "errors" "testing" @@ -38,7 +37,6 @@ const testTxLabel = "test-tx" func TestCheckMempoolAcceptance(t *testing.T) { t.Parallel() - ctx := context.Background() tx := &wire.MsgTx{} mempoolAcceptResultAllowed := []*btcjson.TestMempoolAcceptResult{ @@ -108,7 +106,7 @@ func TestCheckMempoolAcceptance(t *testing.T) { ).Return(errInsufficientFee) } - err := w.CheckMempoolAcceptance(ctx, tc.tx) + err := w.CheckMempoolAcceptance(t.Context(), tc.tx) require.ErrorIs(t, err, tc.expectedErr) }) } @@ -568,7 +566,6 @@ func TestRemoveUnminedTx(t *testing.T) { func TestCheckMempool(t *testing.T) { t.Parallel() - ctx := context.Background() tx := &wire.MsgTx{} testCases := []struct { @@ -636,7 +633,7 @@ func TestCheckMempool(t *testing.T) { ).Return(nil, tc.mempoolAcceptErr) } - err := w.checkMempool(ctx, tx) + err := w.checkMempool(t.Context(), tx) require.ErrorIs(t, err, tc.expectedErr) }) } @@ -687,6 +684,7 @@ func TestPublishTx(t *testing.T) { w, m := testWalletWithMocks(t) m.chain.On("NotifyReceived", + mock.Anything, mock.Anything, mock.Anything).Return(tc.notifyErr) // We only expect SendRawTransaction to be called if @@ -707,7 +705,6 @@ func TestPublishTx(t *testing.T) { func TestBroadcastSuccess(t *testing.T) { t.Parallel() - ctx := context.Background() label := testTxLabel w, m := testWalletWithMocks(t) @@ -756,7 +753,7 @@ func TestBroadcastSuccess(t *testing.T) { mock.Anything, mock.Anything, ).Return(nil, nil) - err = w.Broadcast(ctx, tx, label) + err = w.Broadcast(t.Context(), tx, label) require.NoError(t, err) } @@ -765,7 +762,6 @@ func TestBroadcastSuccess(t *testing.T) { func TestBroadcastAlreadyBroadcasted(t *testing.T) { t.Parallel() - ctx := context.Background() label := testTxLabel w, m := testWalletWithMocks(t) @@ -778,7 +774,7 @@ func TestBroadcastAlreadyBroadcasted(t *testing.T) { m.chain.On("TestMempoolAccept", mock.Anything, mock.Anything). Return(nil, chain.ErrTxAlreadyInMempool) - err := w.Broadcast(ctx, tx, label) + err := w.Broadcast(t.Context(), tx, label) require.NoError(t, err) } @@ -787,7 +783,6 @@ func TestBroadcastAlreadyBroadcasted(t *testing.T) { func TestBroadcastPublishFailsRemoveSucceeds(t *testing.T) { t.Parallel() - ctx := context.Background() label := testTxLabel w, m := testWalletWithMocks(t) @@ -841,7 +836,7 @@ func TestBroadcastPublishFailsRemoveSucceeds(t *testing.T) { mock.Anything, mock.Anything, ).Return(nil).Once() - err = w.Broadcast(ctx, tx, label) + err = w.Broadcast(t.Context(), tx, label) require.ErrorIs(t, err, errPublish) } @@ -850,7 +845,6 @@ func TestBroadcastPublishFailsRemoveSucceeds(t *testing.T) { func TestBroadcastPublishFailsRemoveFails(t *testing.T) { t.Parallel() - ctx := context.Background() label := testTxLabel w, m := testWalletWithMocks(t) @@ -908,7 +902,7 @@ func TestBroadcastPublishFailsRemoveFails(t *testing.T) { mock.Anything, mock.Anything, ).Return(errRemove).Once() - err = w.Broadcast(ctx, tx, label) + err = w.Broadcast(t.Context(), tx, label) require.Error(t, err) require.Contains(t, err.Error(), errPublish.Error()) require.Contains(t, err.Error(), errRemove.Error()) @@ -919,10 +913,9 @@ func TestBroadcastPublishFailsRemoveFails(t *testing.T) { func TestBroadcastNilTx(t *testing.T) { t.Parallel() - ctx := context.Background() label := testTxLabel w, _ := testWalletWithMocks(t) - err := w.Broadcast(ctx, nil, label) + err := w.Broadcast(t.Context(), nil, label) require.ErrorIs(t, err, ErrTxCannotBeNil) } From fc21fcf15802e4ed46631046321a7282d1918fb1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 23 Jan 2026 17:35:18 +0800 Subject: [PATCH 283/691] wallet: replace deprecated fields by using w.cfg --- wallet/account_manager.go | 22 +++++++++++-------- wallet/account_manager_test.go | 20 ++++++++--------- wallet/address_manager.go | 18 ++++++++-------- wallet/address_manager_test.go | 11 +++++----- wallet/benchmark_helpers_test.go | 12 +++++------ wallet/multisig.go | 10 +++++---- wallet/psbt_manager.go | 8 +++---- wallet/signer.go | 4 ++-- wallet/signer_test.go | 31 ++++++++++++++++----------- wallet/tx_creator.go | 5 +++-- wallet/tx_publisher.go | 8 +++---- wallet/tx_publisher_benchmark_test.go | 4 ++-- wallet/tx_publisher_test.go | 12 ++++++----- wallet/tx_reader.go | 6 +++--- wallet/tx_writer.go | 2 +- wallet/tx_writer_benchmark_test.go | 2 +- wallet/utxo_manager.go | 16 +++++++------- 17 files changed, 103 insertions(+), 88 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 4273b8f2f7..e52831fca1 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -125,7 +125,7 @@ func (w *Wallet) NewAccount(_ context.Context, scope waddrmgr.KeyScope, var props *waddrmgr.AccountProperties - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) // Create a new account under the current key scope. @@ -185,7 +185,7 @@ func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { var accounts []AccountResult - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // First, build a map of balances for all accounts that own at @@ -257,7 +257,7 @@ func (w *Wallet) ListAccountsByScope(_ context.Context, var accounts []AccountResult - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // Calculate the balances for all accounts, but only for the @@ -308,7 +308,7 @@ func (w *Wallet) ListAccountsByName(_ context.Context, var accounts []AccountResult - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { // First, calculate the balances for any accounts that match the // given name. This is efficient as it iterates over the UTXO // set, not accounts. @@ -391,7 +391,7 @@ func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, var account *AccountResult - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // Look up the account number for the given name and scope. This @@ -457,7 +457,7 @@ func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, return err } - return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + return walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) // Look up the account number for the given name. This is @@ -486,7 +486,7 @@ func (w *Wallet) Balance(_ context.Context, conf uint32, var balance btcutil.Amount - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) @@ -538,7 +538,9 @@ func (w *Wallet) balanceForUTXO(addrmgrNs walletdb.ReadBucket, utxo wtxmgr.Credit) btcutil.Amount { // Extract the address from the UTXO's public key script. - addr := extractAddrFromPKScript(utxo.PkScript, w.chainParams) + addr := extractAddrFromPKScript( + utxo.PkScript, w.cfg.ChainParams, + ) if addr == nil { return 0 } @@ -713,7 +715,9 @@ func (w *Wallet) fetchAccountBalances(tx walletdb.ReadTx, // Iterate through all UTXOs, mapping them back to their owning account // to aggregate the total balance for each. for _, utxo := range utxos { - addr := extractAddrFromPKScript(utxo.PkScript, w.chainParams) + addr := extractAddrFromPKScript( + utxo.PkScript, w.cfg.ChainParams, + ) if addr == nil { // This can happen for non-standard script types. continue diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 059fd150e6..26e82bf489 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -356,7 +356,7 @@ func TestBalance(t *testing.T) { }, time.Now()) require.NoError(t, err) - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) err := w.txStore.InsertTx(ns, rec, &wtxmgr.BlockMeta{ @@ -377,7 +377,7 @@ func TestBalance(t *testing.T) { require.NoError(t, err) // Now, we'll update the wallet's sync state. - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) return w.addrStore.SetSyncedTo(addrmgrNs, &waddrmgr.BlockStamp{ @@ -467,20 +467,20 @@ func TestExtractAddrFromPKScript(t *testing.T) { w := testWallet(t) - w.chainParams = &chaincfg.MainNetParams + w.cfg.ChainParams = &chaincfg.MainNetParams p2pkhAddr, err := btcutil.DecodeAddress( - "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhem", w.chainParams, + "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhem", w.cfg.ChainParams, ) require.NoError(t, err) p2shAddr, err := btcutil.DecodeAddress( - "347N1Thc213QqfYCz3PZkjoJpNv5b14kBd", w.chainParams, + "347N1Thc213QqfYCz3PZkjoJpNv5b14kBd", w.cfg.ChainParams, ) require.NoError(t, err) p2wpkhAddr, err := btcutil.DecodeAddress( - "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", w.chainParams, + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", w.cfg.ChainParams, ) require.NoError(t, err) @@ -550,7 +550,7 @@ func TestExtractAddrFromPKScript(t *testing.T) { t.Parallel() addr := extractAddrFromPKScript( - testCase.script(), w.chainParams, + testCase.script(), w.cfg.ChainParams, ) if addr == nil { require.Empty(t, testCase.addr) @@ -676,7 +676,7 @@ func TestFetchAccountBalances(t *testing.T) { // Update sync state. err = walletdb.Update( - w.db, func(tx walletdb.ReadWriteTx) error { + w.cfg.DB, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket( waddrmgrNamespaceKey, ) @@ -744,7 +744,7 @@ func TestFetchAccountBalances(t *testing.T) { var balances scopedBalances err := walletdb.View( - w.db, func(tx walletdb.ReadTx) error { + w.cfg.DB, func(tx walletdb.ReadTx) error { var err error balances, err = w.fetchAccountBalances( @@ -789,7 +789,7 @@ func TestListAccountsWithBalances(t *testing.T) { // Now, we'll call listAccountsWithBalances within a read transaction // and verify the results. - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) require.NoError(t, err) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 5ef600dbd1..92920713be 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -293,7 +293,7 @@ func (w *Wallet) newAddress(manager waddrmgr.AccountStore, err error ) - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) addr, err = manager.NewAddress(addrmgrNs, accountName, change) @@ -364,7 +364,7 @@ func (w *Wallet) GetUnusedAddress(ctx context.Context, accountName string, var unusedAddr btcutil.Address - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // First, look up the account number for the passed account @@ -447,7 +447,7 @@ func (w *Wallet) AddressInfo(_ context.Context, err error ) - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) managedAddress, err = w.addrStore.Address(addrmgrNs, a) @@ -498,7 +498,7 @@ func (w *Wallet) ListAddresses(_ context.Context, accountName string, var properties []AddressProperty - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) @@ -513,7 +513,7 @@ func (w *Wallet) ListAddresses(_ context.Context, accountName string, for _, utxo := range utxos { addr := extractAddrFromPKScript( - utxo.PkScript, w.chainParams, + utxo.PkScript, w.cfg.ChainParams, ) if addr == nil { continue @@ -599,7 +599,7 @@ func (w *Wallet) ImportPublicKey(_ context.Context, pubKey *btcec.PublicKey, var addr btcutil.Address - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) ma, err := manager.ImportPublicKey(addrmgrNs, pubKey, nil) @@ -657,7 +657,7 @@ func (w *Wallet) ImportTaprootScript(_ context.Context, var addr waddrmgr.ManagedAddress - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) syncedTo := w.addrStore.SyncedTo() addr, err = manager.ImportTaprootScript( @@ -713,7 +713,7 @@ func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( Script, error) { // First, we'll extract the address from the output's pkScript. - addr := extractAddrFromPKScript(output.PkScript, w.chainParams) + addr := extractAddrFromPKScript(output.PkScript, w.cfg.ChainParams) if addr == nil { return Script{}, fmt.Errorf("%w: from pkscript %x", ErrUnableToExtractAddress, output.PkScript) @@ -734,7 +734,7 @@ func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( } witnessProgram, redeemScript, err := buildScriptsForManagedAddress( - pubKeyAddr, output.PkScript, w.chainParams, + pubKeyAddr, output.PkScript, w.cfg.ChainParams, ) if err != nil { return Script{}, err diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index ebde88e9bb..c934821c28 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -197,7 +197,7 @@ func TestGetUnusedAddress(t *testing.T) { // We need to create a realistic transaction that has at least one // input. - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) // Create a new transaction and set the output to the address @@ -395,7 +395,7 @@ func TestGetDerivationInfoNoDerivationInfo(t *testing.T) { pubKey := privKey.PubKey() addr, err := btcutil.NewAddressWitnessPubKeyHash( btcutil.Hash160(pubKey.SerializeCompressed()), - w.chainParams, + w.cfg.ChainParams, ) require.NoError(t, err) @@ -434,7 +434,7 @@ func TestListAddresses(t *testing.T) { // We need to create a realistic transaction that has at least one // input. - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) // Create a new transaction and set the output to the address @@ -500,7 +500,8 @@ func TestImportPublicKey(t *testing.T) { // Check that the address is now managed by the wallet. addr, err := btcutil.NewAddressWitnessPubKeyHash( - btcutil.Hash160(pubKey.SerializeCompressed()), w.chainParams, + btcutil.Hash160(pubKey.SerializeCompressed()), + w.cfg.ChainParams, ) require.NoError(t, err) managed, err := w.HaveAddress(addr) @@ -546,7 +547,7 @@ func TestImportTaprootScript(t *testing.T) { addr, err := btcutil.NewAddressTaproot( schnorr.SerializePubKey(txscript.ComputeTaprootOutputKey( pubKey, rootHash[:], - )), w.chainParams, + )), w.cfg.ChainParams, ) require.NoError(t, err) managed, err := w.HaveAddress(addr) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index d52a5242d2..ece842ca45 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -339,7 +339,7 @@ func setSyncedToHeight(tb testing.TB, w *Wallet, height int32, tb.Helper() - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) return w.addrStore.SetSyncedTo(addrmgrNs, &waddrmgr.BlockStamp{ @@ -359,7 +359,7 @@ func createTestAccounts(tb testing.TB, w *Wallet, scopes []waddrmgr.KeyScope, var allAddresses []waddrmgr.ManagedAddress - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { // Distribute accounts across the specified key scopes. accountsPerScope := numAccounts / len(scopes) remainder := numAccounts % len(scopes) @@ -460,7 +460,7 @@ func createTestWalletTxs(tb testing.TB, w *Wallet, highestBlockMeta wtxmgr.BlockMeta ) - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) msgTx := TstTx.MsgTx() @@ -764,7 +764,7 @@ func getTestAddress(tb testing.TB, w *Wallet, numAccounts int) btcutil.Address { func markAddressAsUsed(b *testing.B, w *Wallet, addr btcutil.Address) { b.Helper() - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) manager, err := w.addrStore.FetchScopedKeyManager( @@ -1051,7 +1051,7 @@ func getUtxoDeprecated(w *Wallet, prevOut wire.OutPoint) (*Utxo, error) { } // Additional lookup 1: Extract address from pkScript. - addr := extractAddrFromPKScript(txOut.PkScript, w.chainParams) + addr := extractAddrFromPKScript(txOut.PkScript, w.cfg.ChainParams) if addr == nil { return nil, ErrNotMine } @@ -1064,7 +1064,7 @@ func getUtxoDeprecated(w *Wallet, prevOut wire.OutPoint) (*Utxo, error) { addrType waddrmgr.AddressType ) - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) spendable, account, addrType = w.addrStore.AddressDetails( addrmgrNs, addr, diff --git a/wallet/multisig.go b/wallet/multisig.go index 8ab388999a..e64b025a10 100644 --- a/wallet/multisig.go +++ b/wallet/multisig.go @@ -46,7 +46,8 @@ func (w *Wallet) MakeMultiSigScript(addrs []btcutil.Address, nRequired int) ([]b case *btcutil.AddressPubKeyHash: if dbtx == nil { var err error - dbtx, err = w.db.BeginReadTx() + + dbtx, err = w.cfg.DB.BeginReadTx() if err != nil { return nil, err } @@ -61,7 +62,7 @@ func (w *Wallet) MakeMultiSigScript(addrs []btcutil.Address, nRequired int) ([]b PubKey().SerializeCompressed() pubKeyAddr, err := btcutil.NewAddressPubKey( - serializedPubKey, w.chainParams) + serializedPubKey, w.cfg.ChainParams) if err != nil { return nil, err } @@ -75,7 +76,8 @@ func (w *Wallet) MakeMultiSigScript(addrs []btcutil.Address, nRequired int) ([]b // ImportP2SHRedeemScript adds a P2SH redeem script to the wallet. func (w *Wallet) ImportP2SHRedeemScript(script []byte) (*btcutil.AddressScriptHash, error) { var p2shAddr *btcutil.AddressScriptHash - err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + + err := walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) // TODO(oga) blockstamp current block? @@ -102,7 +104,7 @@ func (w *Wallet) ImportP2SHRedeemScript(script []byte) (*btcutil.AddressScriptHa // This function will never error as it always // hashes the script to the correct length. p2shAddr, _ = btcutil.NewAddressScriptHash(script, - w.chainParams) + w.cfg.ChainParams) return nil } return err diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 641efb5376..938458e0c6 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -418,7 +418,7 @@ func (w *Wallet) decorateInput(ctx context.Context, pInput *psbt.PInput, // We'll start by extracting the address from the UTXO's pkScript. // This will be used to look up the managed address from the // database. - addr := extractAddrFromPKScript(utxo.PkScript, w.chainParams) + addr := extractAddrFromPKScript(utxo.PkScript, w.cfg.ChainParams) if addr == nil { return fmt.Errorf("%w: from pkscript %x", ErrUnableToExtractAddress, utxo.PkScript) @@ -459,7 +459,7 @@ func (w *Wallet) decorateInput(ctx context.Context, pInput *psbt.PInput, default: // We'll need to build the redeem script for the input. _, redeemScript, err := buildScriptsForManagedAddress( - pubKeyAddr, utxo.PkScript, w.chainParams, + pubKeyAddr, utxo.PkScript, w.cfg.ChainParams, ) if err != nil { return err @@ -999,10 +999,10 @@ func (w *Wallet) parseBip32Path(path []uint32) (BIP32Path, error) { index := path[4] // Verify that the coin type matches the wallet's chain parameters. - if coinType != w.chainParams.HDCoinType { + if coinType != w.cfg.ChainParams.HDCoinType { return BIP32Path{}, fmt.Errorf("%w: expected coin type %d, "+ "got %d", ErrInvalidBip32Path, - w.chainParams.HDCoinType, coinType) + w.cfg.ChainParams.HDCoinType, coinType) } scope := waddrmgr.KeyScope{ diff --git a/wallet/signer.go b/wallet/signer.go index 671cedf1bb..ae406d5bad 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -504,7 +504,7 @@ func (w *Wallet) fetchManagedPubKeyAddress(path BIP32Path) ( // database transaction. var addr waddrmgr.ManagedAddress - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // Derive the managed address from the derivation path. @@ -844,7 +844,7 @@ func (w *Wallet) PrivKeyForAddress(a btcutil.Address) ( var privKey *btcec.PrivateKey - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) addr, err := w.addrStore.Address(addrmgrNs, a) diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 35b1c1a8fc..453442319b 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -233,7 +233,7 @@ func TestDerivePubKeyNotPubKeyAddr(t *testing.T) { // We need a valid address for the error message. addr, err := btcutil.NewAddressWitnessPubKeyHash( - make([]byte, 20), w.chainParams, + make([]byte, 20), w.cfg.ChainParams, ) require.NoError(t, err) @@ -642,7 +642,8 @@ func TestComputeUnlockingScriptP2PKH(t *testing.T) { // Create a P2PKH address and the corresponding previous output script. // This is the output we want to create an unlocking script for. addr, err := btcutil.NewAddressPubKeyHash( - btcutil.Hash160(pubKey.SerializeCompressed()), w.chainParams, + btcutil.Hash160(pubKey.SerializeCompressed()), + w.cfg.ChainParams, ) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) @@ -714,7 +715,8 @@ func TestComputeUnlockingScriptP2WKH(t *testing.T) { // Create a P2WKH address and the corresponding previous output script. addr, err := btcutil.NewAddressWitnessPubKeyHash( - btcutil.Hash160(pubKey.SerializeCompressed()), w.chainParams, + btcutil.Hash160(pubKey.SerializeCompressed()), + w.cfg.ChainParams, ) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) @@ -788,7 +790,7 @@ func TestComputeUnlockingScriptNP2WKH(t *testing.T) { AddData(btcutil.Hash160(pubKey.SerializeCompressed())). Script() require.NoError(t, err) - addr, err := btcutil.NewAddressScriptHash(p2sh, w.chainParams) + addr, err := btcutil.NewAddressScriptHash(p2sh, w.cfg.ChainParams) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) require.NoError(t, err) @@ -864,7 +866,7 @@ func TestComputeUnlockingScriptP2TR(t *testing.T) { addr, err := btcutil.NewAddressTaproot( schnorr.SerializePubKey( txscript.ComputeTaprootOutputKey(pubKey, nil), - ), w.chainParams, + ), w.cfg.ChainParams, ) require.NoError(t, err) pkScript, err := txscript.PayToAddrScript(addr) @@ -1122,7 +1124,8 @@ func TestComputeUnlockingScriptUnknownAddrType(t *testing.T) { privKey, pubKey := deterministicPrivKey(t) privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) addr, err := btcutil.NewAddressPubKeyHash( - btcutil.Hash160(pubKey.SerializeCompressed()), w.chainParams, + btcutil.Hash160(pubKey.SerializeCompressed()), + w.cfg.ChainParams, ) require.NoError(t, err) @@ -1180,7 +1183,7 @@ func TestComputeRawSigLegacyP2PKH(t *testing.T) { // Create a P2PKH address from the public key. pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) addr, err := btcutil.NewAddressPubKeyHash( - pubKeyHash, w.chainParams, + pubKeyHash, w.cfg.ChainParams, ) require.NoError(t, err) @@ -1266,7 +1269,9 @@ func TestComputeRawSigLegacyP2SH(t *testing.T) { require.NoError(t, err) // Create the P2SH address corresponding to the redeem script hash. - addr, err := btcutil.NewAddressScriptHash(redeemScript, w.chainParams) + addr, err := btcutil.NewAddressScriptHash( + redeemScript, w.cfg.ChainParams, + ) require.NoError(t, err) // Create the Pay-To-Addr script (P2SH script) which will be the @@ -1325,7 +1330,7 @@ func TestComputeRawSigSegwitV0(t *testing.T) { // Create a P2WKH address from the public key. pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) addr, err := btcutil.NewAddressWitnessPubKeyHash( - pubKeyHash, w.chainParams, + pubKeyHash, w.cfg.ChainParams, ) require.NoError(t, err) @@ -1406,7 +1411,7 @@ func TestComputeRawSigTaprootKeySpendPath(t *testing.T) { addr, err := btcutil.NewAddressTaproot( schnorr.SerializePubKey( txscript.ComputeTaprootOutputKey(internalKey, nil), - ), w.chainParams, + ), w.cfg.ChainParams, ) require.NoError(t, err) @@ -1491,7 +1496,7 @@ func TestComputeRawSigTaprootScriptPath(t *testing.T) { // Create a P2TR address from the output key. addr, err := btcutil.NewAddressTaproot( - schnorr.SerializePubKey(outputKey), w.chainParams, + schnorr.SerializePubKey(outputKey), w.cfg.ChainParams, ) require.NoError(t, err) @@ -1885,7 +1890,7 @@ func TestGetPrivKeyForAddressSuccess(t *testing.T) { // Create a P2PKH address from the public key. pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) addr, err := btcutil.NewAddressPubKeyHash( - pubKeyHash, w.chainParams, + pubKeyHash, w.cfg.ChainParams, ) require.NoError(t, err) @@ -1914,7 +1919,7 @@ func TestGetPrivKeyForAddressFail(t *testing.T) { // Arrange: Set up the wallet and mocks. w, mocks := testWalletWithMocks(t) addr, err := btcutil.NewAddressPubKeyHash( - make([]byte, 20), w.chainParams, + make([]byte, 20), w.cfg.ChainParams, ) require.NoError(t, err) diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index 39ab5983a7..c9635e1a92 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -940,7 +940,7 @@ func (w *Wallet) filterEligibleOutputs(dbtx walletdb.ReadTx, } if output.FromCoinBase { - target := w.chainParams.CoinbaseMaturity + target := w.cfg.ChainParams.CoinbaseMaturity if !hasMinConfs( uint32(target), output.Height, bs.Height, ) { @@ -960,7 +960,8 @@ func (w *Wallet) filterEligibleOutputs(dbtx walletdb.ReadTx, // TODO: Handle multisig outputs by determining if enough of the // addresses are controlled. _, addrs, _, err := txscript.ExtractPkScriptAddrs( - output.PkScript, w.chainParams) + output.PkScript, w.cfg.ChainParams, + ) if err != nil || len(addrs) != 1 { continue } diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 9566311b2d..12432ac297 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -326,7 +326,7 @@ func (w *Wallet) addTxToWallet(tx *wire.MsgTx, func (w *Wallet) recordTxAndCredits(txRec *wtxmgr.TxRecord, label string, creditsToWrite []creditInfo) error { - return walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { + return walletdb.Update(w.cfg.DB, func(dbTx walletdb.ReadWriteTx) error { addrmgrNs := dbTx.ReadWriteBucket(waddrmgrNamespaceKey) txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) @@ -385,7 +385,7 @@ func (w *Wallet) extractTxAddrs(tx *wire.MsgTx) map[uint32][]btcutil.Address { txOutAddrs := make(map[uint32][]btcutil.Address) for i, output := range tx.TxOut { _, addrs, _, err := txscript.ExtractPkScriptAddrs( - output.PkScript, w.chainParams, + output.PkScript, w.cfg.ChainParams, ) // Ignore non-standard scripts. if err != nil { @@ -429,7 +429,7 @@ func (w *Wallet) filterOwnedAddresses( } } - err := walletdb.View(w.db, func(dbTx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(dbTx walletdb.ReadTx) error { addrmgrNs := dbTx.ReadBucket(waddrmgrNamespaceKey) for addr, indices := range uniqueAddrs { @@ -518,7 +518,7 @@ func (w *Wallet) publishTx(tx *wire.MsgTx, ourAddrs []btcutil.Address) error { func (w *Wallet) removeUnminedTx(tx *wire.MsgTx) error { txHash := tx.TxHash() - dbErr := walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { + dbErr := walletdb.Update(w.cfg.DB, func(dbTx walletdb.ReadWriteTx) error { txmgrNs := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) txRec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) diff --git a/wallet/tx_publisher_benchmark_test.go b/wallet/tx_publisher_benchmark_test.go index aba2779ddb..9e27e91652 100644 --- a/wallet/tx_publisher_benchmark_test.go +++ b/wallet/tx_publisher_benchmark_test.go @@ -120,7 +120,7 @@ func BenchmarkBroadcastAPI(b *testing.B) { numTxOutputs: txIOGrowth[i], }, ) - bw.chainClient = chainBackend + bw.cfg.Chain = chainBackend var ( beforeResult map[chainhash.Hash]*wire.MsgTx @@ -336,7 +336,7 @@ func BenchmarkBroadcastAPIConcurrently(b *testing.B) { numTxOutputs: txIOGrowth[i], }, ) - bw.chainClient = chainBackend + bw.cfg.Chain = chainBackend var ( beforeResult map[chainhash.Hash]*wire.MsgTx diff --git a/wallet/tx_publisher_test.go b/wallet/tx_publisher_test.go index 070e908a9b..575698252c 100644 --- a/wallet/tx_publisher_test.go +++ b/wallet/tx_publisher_test.go @@ -143,7 +143,8 @@ func createTestTx(t *testing.T, w *Wallet) *testTxData { pubKey1 := privKey1.PubKey() addr1, err := btcutil.NewAddressWitnessPubKeyHash( - btcutil.Hash160(pubKey1.SerializeCompressed()), w.chainParams, + btcutil.Hash160(pubKey1.SerializeCompressed()), + w.cfg.ChainParams, ) require.NoError(t, err) @@ -169,7 +170,7 @@ func createTestTx(t *testing.T, w *Wallet) *testTxData { // Output 1: P2SH script2 := []byte{txscript.OP_1} addr2, err := btcutil.NewAddressScriptHash( - script2, w.chainParams, + script2, w.cfg.ChainParams, ) require.NoError(t, err) pkScript2, err := txscript.PayToAddrScript(addr2) @@ -197,7 +198,7 @@ func createTestTx(t *testing.T, w *Wallet) *testTxData { scriptHash := sha256.Sum256(multiSigScript) addr3, err := btcutil.NewAddressWitnessScriptHash( - scriptHash[:], w.chainParams, + scriptHash[:], w.cfg.ChainParams, ) require.NoError(t, err) pkScript4, err := txscript.PayToAddrScript(addr3) @@ -260,14 +261,15 @@ func TestFilterOwnedAddresses(t *testing.T) { ownedPrivKey, err := btcec.NewPrivateKey() require.NoError(t, err) ownedAddr, err := btcutil.NewAddressPubKey( - ownedPrivKey.PubKey().SerializeCompressed(), w.chainParams, + ownedPrivKey.PubKey().SerializeCompressed(), w.cfg.ChainParams, ) require.NoError(t, err) unownedPrivKey, err := btcec.NewPrivateKey() require.NoError(t, err) unownedAddr, err := btcutil.NewAddressPubKey( - unownedPrivKey.PubKey().SerializeCompressed(), w.chainParams, + unownedPrivKey.PubKey().SerializeCompressed(), + w.cfg.ChainParams, ) require.NoError(t, err) diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go index 487330fc1e..1b534d9d73 100644 --- a/wallet/tx_reader.go +++ b/wallet/tx_reader.go @@ -190,7 +190,7 @@ func (w *Wallet) ListTxns(_ context.Context, startHeight, // time we hold the database lock. var records []wtxmgr.TxDetails - err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(dbtx walletdb.ReadTx) error { txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) err := w.txStore.RangeTransactions( @@ -229,7 +229,7 @@ func (w *Wallet) fetchTxDetails(txHash *chainhash.Hash) ( var txDetails *wtxmgr.TxDetails - err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(dbtx walletdb.ReadTx) error { txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) var err error @@ -355,7 +355,7 @@ func (w *Wallet) populateOutputs(details *TxDetail, for i, txOut := range txDetails.MsgTx.TxOut { sc, outAddresses, _, err := txscript.ExtractPkScriptAddrs( - txOut.PkScript, w.chainParams, + txOut.PkScript, w.cfg.ChainParams, ) var addresses []btcutil.Address diff --git a/wallet/tx_writer.go b/wallet/tx_writer.go index 1faafdb7cf..b94b960e0d 100644 --- a/wallet/tx_writer.go +++ b/wallet/tx_writer.go @@ -27,7 +27,7 @@ var _ TxWriter = (*Wallet)(nil) func (w *Wallet) LabelTx(_ context.Context, hash chainhash.Hash, label string) error { - err := walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { + err := walletdb.Update(w.cfg.DB, func(dbtx walletdb.ReadWriteTx) error { txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) // Check that the transaction is known to the wallet. diff --git a/wallet/tx_writer_benchmark_test.go b/wallet/tx_writer_benchmark_test.go index 7fb3f83c1c..87c11d8ae0 100644 --- a/wallet/tx_writer_benchmark_test.go +++ b/wallet/tx_writer_benchmark_test.go @@ -283,7 +283,7 @@ func assertLabelTxAPIsEquivalent(b *testing.B, w *Wallet, hash chainhash.Hash, var actualLabel string - err := walletdb.View(w.db, func(dbtx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(dbtx walletdb.ReadTx) error { txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) var err error diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index f91091b23a..1566a46c75 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -167,7 +167,7 @@ func (w *Wallet) ListUnspent(_ context.Context, var utxos []*Utxo - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) @@ -197,7 +197,7 @@ func (w *Wallet) ListUnspent(_ context.Context, // script. // For multi-address scripts, the first address is used. addr := extractAddrFromPKScript( - output.PkScript, w.chainParams, + output.PkScript, w.cfg.ChainParams, ) if addr == nil { continue @@ -221,7 +221,7 @@ func (w *Wallet) ListUnspent(_ context.Context, // A UTXO is also unspendable if it is an immature // coinbase output. if output.FromCoinBase { - maturity := w.chainParams.CoinbaseMaturity + maturity := w.cfg.ChainParams.CoinbaseMaturity if confs < int32(maturity) { spendable = false } @@ -313,7 +313,7 @@ func (w *Wallet) GetUtxo(_ context.Context, var utxo *Utxo - err := walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) @@ -333,7 +333,7 @@ func (w *Wallet) GetUtxo(_ context.Context, // Extract the address from the UTXO's public key script. // For multi-address scripts, the first address is used. - addr := extractAddrFromPKScript(output.PkScript, w.chainParams) + addr := extractAddrFromPKScript(output.PkScript, w.cfg.ChainParams) if addr == nil { return wtxmgr.ErrUtxoNotFound } @@ -412,7 +412,7 @@ func (w *Wallet) LeaseOutput(_ context.Context, id wtxmgr.LockID, err error ) - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) expiration, err = w.txStore.LockOutput( @@ -463,7 +463,7 @@ func (w *Wallet) LeaseOutput(_ context.Context, id wtxmgr.LockID, func (w *Wallet) ReleaseOutput(_ context.Context, id wtxmgr.LockID, op wire.OutPoint) error { - return walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + return walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) return w.txStore.UnlockOutput(txmgrNs, id, op) }) @@ -509,7 +509,7 @@ func (w *Wallet) ListLeasedOutputs( err error ) - err = walletdb.View(w.db, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) leasedOutputs, err = w.txStore.ListLockedOutputs(txmgrNs) From a1361ee5ef60c1770470cea232b5126a3ede5689 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 23 Jan 2026 17:52:15 +0800 Subject: [PATCH 284/691] wallet: add wallet state checks to RPCs --- wallet/account_manager.go | 51 +++++++++++++++++++++++++++++++++------ wallet/address_manager.go | 47 ++++++++++++++++++++++++++++++++---- wallet/controller.go | 5 ++++ wallet/psbt_manager.go | 31 +++++++++++++++++++++--- wallet/signer.go | 45 +++++++++++++++++++++++++++++++--- wallet/tx_creator.go | 7 +++++- wallet/tx_publisher.go | 12 ++++++++- wallet/tx_reader.go | 12 ++++++++- wallet/tx_writer.go | 7 +++++- wallet/utxo_manager.go | 39 ++++++++++++++++++++++-------- 10 files changed, 224 insertions(+), 32 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index e52831fca1..89c5b0ee67 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -112,6 +112,11 @@ var _ AccountManager = (*Wallet)(nil) func (w *Wallet) NewAccount(_ context.Context, scope waddrmgr.KeyScope, name string) (*waddrmgr.AccountProperties, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err @@ -179,13 +184,18 @@ type AccountsResult struct { // UTXOs and A is the number of accounts in the wallet. A potential future // improvement is to make the balance calculation optional. func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + // Get all active key scope managers to iterate through all available // scopes. scopes := w.addrStore.ActiveScopedKeyManagers() var accounts []AccountResult - err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // First, build a map of balances for all accounts that own at @@ -248,6 +258,11 @@ func (w *Wallet) ListAccounts(_ context.Context) (*AccountsResult, error) { func (w *Wallet) ListAccountsByScope(_ context.Context, scope waddrmgr.KeyScope) (*AccountsResult, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + // First, we'll fetch the scoped key manager for the given scope. This // manager will be used to list the accounts. manager, err := w.addrStore.FetchScopedKeyManager(scope) @@ -304,11 +319,16 @@ func (w *Wallet) ListAccountsByScope(_ context.Context, func (w *Wallet) ListAccountsByName(_ context.Context, name string) (*AccountsResult, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + scopes := w.addrStore.ActiveScopedKeyManagers() var accounts []AccountResult - err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { // First, calculate the balances for any accounts that match the // given name. This is efficient as it iterates over the UTXO // set, not accounts. @@ -384,6 +404,11 @@ func (w *Wallet) ListAccountsByName(_ context.Context, func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, name string) (*AccountResult, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return nil, err @@ -445,6 +470,11 @@ func (w *Wallet) GetAccount(_ context.Context, scope waddrmgr.KeyScope, func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, oldName, newName string) error { + err := w.state.validateStarted() + if err != nil { + return err + } + manager, err := w.addrStore.FetchScopedKeyManager(scope) if err != nil { return err @@ -484,9 +514,14 @@ func (w *Wallet) RenameAccount(_ context.Context, scope waddrmgr.KeyScope, func (w *Wallet) Balance(_ context.Context, conf uint32, scope waddrmgr.KeyScope, name string) (btcutil.Amount, error) { + err := w.state.validateStarted() + if err != nil { + return 0, err + } + var balance btcutil.Amount - err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) @@ -570,10 +605,12 @@ func (w *Wallet) ImportAccount(_ context.Context, masterKeyFingerprint uint32, addrType waddrmgr.AddressType, dryRun bool) (*waddrmgr.AccountProperties, error) { - var ( - props *waddrmgr.AccountProperties - err error - ) + err := w.state.validateStarted() + if err != nil { + return nil, err + } + + var props *waddrmgr.AccountProperties if dryRun { props, _, _, err = w.ImportAccountDryRun( diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 92920713be..76f42b73e2 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -195,6 +195,11 @@ var _ AddressManager = (*Wallet)(nil) func (w *Wallet) NewAddress(_ context.Context, accountName string, addrType waddrmgr.AddressType, change bool) (btcutil.Address, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + // Addresses cannot be derived from the catch-all imported accounts. if accountName == waddrmgr.ImportedAddrAccountName { return nil, ErrImportedAccountNoAddrGen @@ -348,6 +353,11 @@ func (w *Wallet) newAddress(manager waddrmgr.AccountStore, func (w *Wallet) GetUnusedAddress(ctx context.Context, accountName string, addrType waddrmgr.AddressType, change bool) (btcutil.Address, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + if accountName == waddrmgr.ImportedAddrAccountName { return nil, ErrImportedAccountNoAddrGen } @@ -442,10 +452,12 @@ func (w *Wallet) GetUnusedAddress(ctx context.Context, accountName string, func (w *Wallet) AddressInfo(_ context.Context, a btcutil.Address) (waddrmgr.ManagedAddress, error) { - var ( - managedAddress waddrmgr.ManagedAddress - err error - ) + err := w.state.validateStarted() + if err != nil { + return nil, err + } + + var managedAddress waddrmgr.ManagedAddress err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) @@ -496,9 +508,14 @@ func (w *Wallet) AddressInfo(_ context.Context, func (w *Wallet) ListAddresses(_ context.Context, accountName string, addrType waddrmgr.AddressType) ([]AddressProperty, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + var properties []AddressProperty - err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) @@ -587,6 +604,11 @@ func (w *Wallet) ListAddresses(_ context.Context, accountName string, func (w *Wallet) ImportPublicKey(_ context.Context, pubKey *btcec.PublicKey, addrType waddrmgr.AddressType) error { + err := w.state.validateStarted() + if err != nil { + return err + } + keyScope, err := w.keyScopeFromAddrType(addrType) if err != nil { return err @@ -648,6 +670,11 @@ func (w *Wallet) ImportPublicKey(_ context.Context, pubKey *btcec.PublicKey, func (w *Wallet) ImportTaprootScript(_ context.Context, tapscript waddrmgr.Tapscript) (waddrmgr.ManagedAddress, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + manager, err := w.addrStore.FetchScopedKeyManager( waddrmgr.KeyScopeBIP0086, ) @@ -712,6 +739,11 @@ func (w *Wallet) ImportTaprootScript(_ context.Context, func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( Script, error) { + err := w.state.validateStarted() + if err != nil { + return Script{}, err + } + // First, we'll extract the address from the output's pkScript. addr := extractAddrFromPKScript(output.PkScript, w.cfg.ChainParams) if addr == nil { @@ -804,6 +836,11 @@ func buildScriptsForManagedAddress(pubKeyAddr waddrmgr.ManagedPubKeyAddress, func (w *Wallet) GetDerivationInfo(ctx context.Context, addr btcutil.Address) (*psbt.Bip32Derivation, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + // We'll use the address to look up the derivation path. managedAddr, err := w.AddressInfo(ctx, addr) if err != nil { diff --git a/wallet/controller.go b/wallet/controller.go index 95d1c188f7..4a66de81e0 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -337,6 +337,11 @@ func (w *Wallet) ChangePassphrase(ctx context.Context, // // This is part of the Controller interface. func (w *Wallet) Info(_ context.Context) (*Info, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + info := &Info{ BirthdayBlock: w.birthdayBlock, Backend: w.cfg.Chain.BackEnd(), diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 938458e0c6..7a9573f407 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -371,6 +371,11 @@ type PsbtManager interface { func (w *Wallet) DecorateInputs(ctx context.Context, packet *psbt.Packet, skipUnknown bool) (*psbt.Packet, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + // We'll iterate through all the inputs of the PSBT and decorate them // if they are owned by the wallet. The `skipUnknown` parameter // determines whether an error is returned if an input is not owned @@ -558,8 +563,13 @@ func findCredit(txDetail *wtxmgr.TxDetails, outputIndex uint32) bool { func (w *Wallet) FundPsbt(ctx context.Context, intent *FundIntent) ( *psbt.Packet, int32, error) { + err := w.state.validateSynced() + if err != nil { + return nil, 0, err + } + // Validate the funding intent before proceeding. - err := w.validateFundIntent(intent) + err = w.validateFundIntent(intent) if err != nil { return nil, 0, err } @@ -838,6 +848,11 @@ func (w *Wallet) createTxIntent(intent *FundIntent) *TxIntent { func (w *Wallet) SignPsbt(ctx context.Context, params *SignPsbtParams) ( *SignPsbtResult, error) { + err := w.state.canSign() + if err != nil { + return nil, err + } + if params == nil { return nil, ErrNilArguments } @@ -855,7 +870,7 @@ func (w *Wallet) SignPsbt(ctx context.Context, params *SignPsbtParams) ( // has at least a WitnessUtxo or NonWitnessUtxo, which is crucial for // signature generation. If this check fails, it indicates a malformed // or incomplete PSBT that cannot be signed. - err := psbt.InputsReadyToSign(packet) + err = psbt.InputsReadyToSign(packet) if err != nil { return nil, fmt.Errorf("psbt inputs not ready: %w", err) } @@ -1561,8 +1576,13 @@ func (w *Wallet) signBip32PsbtInput(ctx context.Context, packet *psbt.Packet, // constructs the final witnesses and strips the PSBT metadata, leaving a // ready-to-broadcast transaction. func (w *Wallet) FinalizePsbt(ctx context.Context, packet *psbt.Packet) error { + err := w.state.canSign() + if err != nil { + return err + } + // Check that the PSBT is structurally ready to be signed/finalized. - err := psbt.InputsReadyToSign(packet) + err = psbt.InputsReadyToSign(packet) if err != nil { return fmt.Errorf("psbt inputs not ready: %w", err) } @@ -1704,6 +1724,11 @@ func addScriptToPInput(pInput *psbt.PInput, func (w *Wallet) CombinePsbt(_ context.Context, psbts ...*psbt.Packet) ( *psbt.Packet, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + // 1. Validation Pass: Ensure compatibility of all packets and prepare // a fresh result packet. combined, err := validatePsbtMerge(psbts) diff --git a/wallet/signer.go b/wallet/signer.go index ae406d5bad..b76d4ad011 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -461,6 +461,11 @@ var _ SpendDetails = (*TaprootSpendDetails)(nil) func (w *Wallet) DerivePubKey(_ context.Context, path BIP32Path) ( *btcec.PublicKey, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) if err != nil { return nil, err @@ -541,6 +546,11 @@ func (w *Wallet) fetchManagedPubKeyAddress(path BIP32Path) ( func (w *Wallet) ECDH(_ context.Context, path BIP32Path, pub *btcec.PublicKey) ([32]byte, error) { + err := w.state.canSign() + if err != nil { + return [32]byte{}, err + } + managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) if err != nil { return [32]byte{}, err @@ -592,7 +602,12 @@ func validateSignDigestIntent(intent *SignDigestIntent) error { func (w *Wallet) SignDigest(_ context.Context, path BIP32Path, intent *SignDigestIntent) (Signature, error) { - err := validateSignDigestIntent(intent) + err := w.state.canSign() + if err != nil { + return nil, err + } + + err = validateSignDigestIntent(intent) if err != nil { return nil, err } @@ -668,6 +683,11 @@ func signDigestECDSA(privKey *btcec.PrivateKey, func (w *Wallet) ComputeUnlockingScript(ctx context.Context, params *UnlockingScriptParams) (*UnlockingScript, error) { + err := w.state.canSign() + if err != nil { + return nil, err + } + // First, we'll fetch the managed address that corresponds to the // output being spent. This will be used to look up the private key // required for signing. @@ -775,6 +795,11 @@ func signAndAssembleScript(params *UnlockingScriptParams, func (w *Wallet) ComputeRawSig(_ context.Context, params *RawSigParams) ( RawSignature, error) { + err := w.state.canSign() + if err != nil { + return nil, err + } + // Get the managed address for the specified derivation path. This will // be used to retrieve the private key. managedAddr, err := w.fetchManagedPubKeyAddress(params.Path) @@ -815,6 +840,11 @@ func (w *Wallet) ComputeRawSig(_ context.Context, params *RawSigParams) ( func (w *Wallet) DerivePrivKey(_ context.Context, path BIP32Path) ( *btcec.PrivateKey, error) { + err := w.state.canSign() + if err != nil { + return nil, err + } + managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) if err != nil { return nil, err @@ -834,6 +864,11 @@ func (w *Wallet) DerivePrivKey(_ context.Context, path BIP32Path) ( func (w *Wallet) GetPrivKeyForAddress(_ context.Context, a btcutil.Address) ( *btcec.PrivateKey, error) { + err := w.state.canSign() + if err != nil { + return nil, err + } + return w.PrivKeyForAddress(a) } @@ -842,9 +877,14 @@ func (w *Wallet) GetPrivKeyForAddress(_ context.Context, a btcutil.Address) ( func (w *Wallet) PrivKeyForAddress(a btcutil.Address) ( *btcec.PrivateKey, error) { + err := w.state.canSign() + if err != nil { + return nil, err + } + var privKey *btcec.PrivateKey - err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) addr, err := w.addrStore.Address(addrmgrNs, a) @@ -864,7 +904,6 @@ func (w *Wallet) PrivKeyForAddress(a btcutil.Address) ( return nil }) - if err != nil { return nil, fmt.Errorf("failed to view database: %w", err) } diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index c9635e1a92..a4c400f017 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -521,6 +521,11 @@ func (w *Wallet) prepareTxAuthSources(intent *TxIntent) ( func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( *txauthor.AuthoredTx, error) { + err := w.state.validateSynced() + if err != nil { + return nil, err + } + // Check that the intent is not nil. if intent == nil { return nil, ErrNilTxIntent @@ -535,7 +540,7 @@ func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( intent.Inputs = &InputsPolicy{} } - err := validateTxIntent(intent) + err = validateTxIntent(intent) if err != nil { return nil, err } diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 12432ac297..944422954b 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -54,6 +54,11 @@ var _ TxPublisher = (*Wallet)(nil) func (w *Wallet) CheckMempoolAcceptance(_ context.Context, tx *wire.MsgTx) error { + err := w.state.validateStarted() + if err != nil { + return err + } + if tx == nil { return ErrTxCannotBeNil } @@ -103,12 +108,17 @@ func (w *Wallet) CheckMempoolAcceptance(_ context.Context, func (w *Wallet) Broadcast(ctx context.Context, tx *wire.MsgTx, label string) error { + err := w.state.validateStarted() + if err != nil { + return err + } + if tx == nil { return ErrTxCannotBeNil } // We'll start by checking if the tx is acceptable to the mempool. - err := w.checkMempool(ctx, tx) + err = w.checkMempool(ctx, tx) if errors.Is(err, errAlreadyBroadcasted) { return nil } diff --git a/wallet/tx_reader.go b/wallet/tx_reader.go index 1b534d9d73..8a149f7d10 100644 --- a/wallet/tx_reader.go +++ b/wallet/tx_reader.go @@ -150,6 +150,11 @@ type TxDetail struct { func (w *Wallet) GetTx(_ context.Context, txHash chainhash.Hash) ( *TxDetail, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + txDetails, err := w.fetchTxDetails(&txHash) if err != nil { return nil, err @@ -182,6 +187,11 @@ func (w *Wallet) GetTx(_ context.Context, txHash chainhash.Hash) ( func (w *Wallet) ListTxns(_ context.Context, startHeight, endHeight int32) ([]*TxDetail, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + bestBlock := w.SyncedTo() currentHeight := bestBlock.Height @@ -190,7 +200,7 @@ func (w *Wallet) ListTxns(_ context.Context, startHeight, // time we hold the database lock. var records []wtxmgr.TxDetails - err := walletdb.View(w.cfg.DB, func(dbtx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(dbtx walletdb.ReadTx) error { txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) err := w.txStore.RangeTransactions( diff --git a/wallet/tx_writer.go b/wallet/tx_writer.go index b94b960e0d..c70a16f6e3 100644 --- a/wallet/tx_writer.go +++ b/wallet/tx_writer.go @@ -27,7 +27,12 @@ var _ TxWriter = (*Wallet)(nil) func (w *Wallet) LabelTx(_ context.Context, hash chainhash.Hash, label string) error { - err := walletdb.Update(w.cfg.DB, func(dbtx walletdb.ReadWriteTx) error { + err := w.state.validateStarted() + if err != nil { + return err + } + + err = walletdb.Update(w.cfg.DB, func(dbtx walletdb.ReadWriteTx) error { txmgrNs := dbtx.ReadWriteBucket(wtxmgrNamespaceKey) // Check that the transaction is known to the wallet. diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 1566a46c75..85950f4af2 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -160,6 +160,11 @@ type UtxoManager interface { func (w *Wallet) ListUnspent(_ context.Context, query UtxoQuery) ([]*Utxo, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + log.Debugf("ListUnspent using query: %v", query) syncBlock := w.addrStore.SyncedTo() @@ -167,7 +172,7 @@ func (w *Wallet) ListUnspent(_ context.Context, var utxos []*Utxo - err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) @@ -306,6 +311,11 @@ func (w *Wallet) ListUnspent(_ context.Context, func (w *Wallet) GetUtxo(_ context.Context, prevOut wire.OutPoint) (*Utxo, error) { + err := w.state.validateStarted() + if err != nil { + return nil, err + } + // Calculate the current confirmation status based on the wallet's // synced block height. syncBlock := w.addrStore.SyncedTo() @@ -313,7 +323,7 @@ func (w *Wallet) GetUtxo(_ context.Context, var utxo *Utxo - err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) @@ -407,10 +417,12 @@ func (w *Wallet) GetUtxo(_ context.Context, func (w *Wallet) LeaseOutput(_ context.Context, id wtxmgr.LockID, op wire.OutPoint, duration time.Duration) (time.Time, error) { - var ( - expiration time.Time - err error - ) + err := w.state.validateStarted() + if err != nil { + return time.Time{}, err + } + + var expiration time.Time err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) @@ -463,6 +475,11 @@ func (w *Wallet) LeaseOutput(_ context.Context, id wtxmgr.LockID, func (w *Wallet) ReleaseOutput(_ context.Context, id wtxmgr.LockID, op wire.OutPoint) error { + err := w.state.validateStarted() + if err != nil { + return err + } + return walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) return w.txStore.UnlockOutput(txmgrNs, id, op) @@ -504,10 +521,12 @@ func (w *Wallet) ReleaseOutput(_ context.Context, id wtxmgr.LockID, func (w *Wallet) ListLeasedOutputs( _ context.Context) ([]*wtxmgr.LockedOutput, error) { - var ( - leasedOutputs []*wtxmgr.LockedOutput - err error - ) + err := w.state.validateStarted() + if err != nil { + return nil, err + } + + var leasedOutputs []*wtxmgr.LockedOutput err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) From 789729e366fab96bc6aa818d36d4b9797fd80c22 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 23 Jan 2026 17:55:20 +0800 Subject: [PATCH 285/691] wtxmgr+wallet: add locked field to `CreditRecord` and `Credit` We now use `Locked` field from `Credit` and `CreditRecord`. --- wallet/psbt_manager.go | 17 ++++++++++------- wallet/tx_creator.go | 2 +- wallet/utxo_manager.go | 21 ++++++++++++--------- wtxmgr/query.go | 21 +++++++++++++++++++++ wtxmgr/tx.go | 27 +++++++++++++++++---------- 5 files changed, 61 insertions(+), 27 deletions(-) diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 7a9573f407..ce02f14531 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -518,14 +518,15 @@ func (w *Wallet) fetchAndValidateUtxo(txIn *wire.TxIn) ( // With the transaction details retrieved, we'll make an additional // check to ensure we actually have control of this output. - if !findCredit(txDetail, txIn.PreviousOutPoint.Index) { + cred := findCredit(txDetail, txIn.PreviousOutPoint.Index) + if cred == nil { return nil, nil, fmt.Errorf("%w: %v", ErrNotMine, txIn.PreviousOutPoint) } // Now that we've confirmed we know about the UTXO, we'll check if it // is locked. - if w.LockedOutpoint(txIn.PreviousOutPoint) { + if cred.Locked { return nil, nil, fmt.Errorf("%w: %v", ErrUtxoLocked, txIn.PreviousOutPoint) } @@ -541,14 +542,16 @@ func (w *Wallet) fetchAndValidateUtxo(txIn *wire.TxIn) ( // findCredit determines whether a transaction's details contain a credit for a // specific output index. -func findCredit(txDetail *wtxmgr.TxDetails, outputIndex uint32) bool { - for _, cred := range txDetail.Credits { - if cred.Index == outputIndex { - return true +func findCredit(txDetail *wtxmgr.TxDetails, + outputIndex uint32) *wtxmgr.CreditRecord { + + for i := range txDetail.Credits { + if txDetail.Credits[i].Index == outputIndex { + return &txDetail.Credits[i] } } - return false + return nil } // FundPsbt performs coin selection and funds the PSBT. diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index a4c400f017..9779c21c08 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -955,7 +955,7 @@ func (w *Wallet) filterEligibleOutputs(dbtx walletdb.ReadTx, } // Locked unspent outputs are skipped. - if w.LockedOutpoint(output.OutPoint) { + if output.Locked { continue } diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 85950f4af2..d8ab482158 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -233,8 +233,11 @@ func (w *Wallet) ListUnspent(_ context.Context, } // TODO(yy): This should be a column in the new utxo - // SQL table. - locked := w.LockedOutpoint(output.OutPoint) + // SQL table. Note that currently UnspentOutputs only + // returns unlocked outputs, so this field will always + // be false. This will be fixed in the upcoming + // sqlization PRs. + locked := output.Locked // If all filters pass, construct the final Utxo struct // with all the combined data. @@ -343,7 +346,9 @@ func (w *Wallet) GetUtxo(_ context.Context, // Extract the address from the UTXO's public key script. // For multi-address scripts, the first address is used. - addr := extractAddrFromPKScript(output.PkScript, w.cfg.ChainParams) + addr := extractAddrFromPKScript( + output.PkScript, w.cfg.ChainParams, + ) if addr == nil { return wtxmgr.ErrUtxoNotFound } @@ -351,11 +356,9 @@ func (w *Wallet) GetUtxo(_ context.Context, // In a single lookup, get all the required // address-related details: spendability, account name, // and address type. This avoids the N+1 query problem. - spendable, account, addrType := w.addrStore. - AddressDetails(addrmgrNs, addr) - - // TODO(yy): This should be a column in the new utxo SQL table. - locked := w.LockedOutpoint(output.OutPoint) + spendable, account, addrType := w.addrStore.AddressDetails( + addrmgrNs, addr, + ) // If all filters pass, construct the final Utxo struct // with all the combined data. @@ -368,7 +371,7 @@ func (w *Wallet) GetUtxo(_ context.Context, Address: addr, Account: account, AddressType: addrType, - Locked: locked, + Locked: output.Locked, } return nil diff --git a/wtxmgr/query.go b/wtxmgr/query.go index 38cf9880c8..b671b40c26 100644 --- a/wtxmgr/query.go +++ b/wtxmgr/query.go @@ -23,6 +23,9 @@ type CreditRecord struct { Index uint32 Spent bool Change bool + + // Locked indicates whether the output is locked by the wallet. + Locked bool } // DebitRecord contains metadata regarding a transaction debit for a known @@ -78,6 +81,12 @@ func (s *Store) minedTxDetails(ns walletdb.ReadBucket, txHash *chainhash.Hash, r spent := existsRawUnminedInput(ns, k) != nil credIter.elem.Spent = spent } + + // Check if locked. + op := wire.OutPoint{Hash: *txHash, Index: credIter.elem.Index} + _, _, locked := isLockedOutput(ns, op, s.clock.Now()) + credIter.elem.Locked = locked + details.Credits = append(details.Credits, credIter.elem) } if credIter.err != nil { @@ -126,6 +135,12 @@ func (s *Store) unminedTxDetails(ns walletdb.ReadBucket, txHash *chainhash.Hash, // Set the Spent field since this is not done by the iterator. it.elem.Spent = existsRawUnminedInput(ns, it.ck) != nil + + // Check if locked. + op := wire.OutPoint{Hash: *txHash, Index: it.elem.Index} + _, _, locked := isLockedOutput(ns, op, s.clock.Now()) + it.elem.Locked = locked + details.Credits = append(details.Credits, it.elem) } if it.err != nil { @@ -489,6 +504,8 @@ func (s *Store) getMinedUtxo(ns walletdb.ReadBucket, outpoint wire.OutPoint, return nil, err } + _, _, locked := isLockedOutput(ns, outpoint, s.clock.Now()) + credit := &Credit{ OutPoint: outpoint, BlockMeta: BlockMeta{ @@ -499,6 +516,7 @@ func (s *Store) getMinedUtxo(ns walletdb.ReadBucket, outpoint wire.OutPoint, PkScript: txOut.PkScript, Received: rec.Received, FromCoinBase: blockchain.IsCoinBaseTx(&rec.MsgTx), + Locked: locked, } return credit, nil } @@ -531,6 +549,8 @@ func (s *Store) getUnminedUtxo(ns walletdb.ReadBucket, } txOut := rec.MsgTx.TxOut[outpoint.Index] + _, _, locked := isLockedOutput(ns, outpoint, s.clock.Now()) + credit := &Credit{ OutPoint: outpoint, BlockMeta: BlockMeta{ @@ -540,6 +560,7 @@ func (s *Store) getUnminedUtxo(ns walletdb.ReadBucket, PkScript: txOut.PkScript, Received: rec.Received, FromCoinBase: false, // Unmined can't be coinbase. + Locked: locked, } return credit, nil } diff --git a/wtxmgr/tx.go b/wtxmgr/tx.go index a09a948b16..748187741f 100644 --- a/wtxmgr/tx.go +++ b/wtxmgr/tx.go @@ -187,6 +187,9 @@ type Credit struct { PkScript []byte Received time.Time FromCoinBase bool + + // Locked indicates whether the output is locked by the wallet. + Locked bool } // LockID represents a unique context-specific ID assigned to an output lock. @@ -915,12 +918,13 @@ func (s *Store) fetchCredits(ns walletdb.ReadBucket, includeLocked bool, return err } + // We check if this output is actually locked and set + // the Locked field. + _, _, isLocked := isLockedOutput(ns, op, now) + // Check if locked, skip if necessary. - if !includeLocked { - _, _, isLocked := isLockedOutput(ns, op, now) - if isLocked { - return nil - } + if isLocked && !includeLocked { + return nil } // Check if spent by unmined, skip if necessary. @@ -952,6 +956,7 @@ func (s *Store) fetchCredits(ns walletdb.ReadBucket, includeLocked bool, cred := Credit{ OutPoint: op, PkScript: txOut.PkScript, + Locked: isLocked, } // Populate full details if requested. @@ -1001,12 +1006,13 @@ func (s *Store) fetchCredits(ns walletdb.ReadBucket, includeLocked bool, return err } + // We check if this output is actually locked and set + // the Locked field. + _, _, isLocked := isLockedOutput(ns, op, now) + // Check if locked, skip if necessary. - if !includeLocked { - _, _, isLocked := isLockedOutput(ns, op, now) - if isLocked { - return nil - } + if isLocked && !includeLocked { + return nil } // Check if spent by unmined, skip if necessary. @@ -1044,6 +1050,7 @@ func (s *Store) fetchCredits(ns walletdb.ReadBucket, includeLocked bool, cred := Credit{ OutPoint: op, PkScript: txOut.PkScript, + Locked: isLocked, } // Populate full details if requested. From 3c01c030df6f8e0c8a2771757345f46c96367d3b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 19 Jan 2026 08:53:53 +0800 Subject: [PATCH 286/691] wallet: remove the usage of the deprecated methods We now remove the usage of the deprecated methods `AccountNumber` and `requireChainClient`. --- wallet/tx_creator.go | 35 +++++++++++++++++++++++------------ wallet/tx_publisher.go | 18 ++++-------------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index 9779c21c08..f815468739 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -459,20 +459,30 @@ func (w *Wallet) prepareTxAuthSources(intent *TxIntent) ( // used. changeAccount := w.determineChangeSource(intent) + manager, err := w.addrStore.FetchScopedKeyManager( + changeAccount.KeyScope, + ) + if err != nil { + return nil, nil, fmt.Errorf("%w: %s", ErrAccountNotFound, + changeAccount.AccountName) + } + var ( changeSource *txauthor.ChangeSource inputSource txauthor.InputSource ) // We perform the core logic of creating the input and change sources // within a single database transaction to ensure atomicity. - err := walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { + err = walletdb.Update(w.cfg.DB, func(dbtx walletdb.ReadWriteTx) error { changeKeyScope := &changeAccount.KeyScope accountName := changeAccount.AccountName + addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) + // Query the account's number using the account name. // // TODO(yy): Remove this query in upcoming SQL. - account, err := w.AccountNumber(*changeKeyScope, accountName) + account, err := manager.LookupAccount(addrmgrNs, accountName) if err != nil { return fmt.Errorf("%w: %s", ErrAccountNotFound, accountName) @@ -748,16 +758,9 @@ func (w *Wallet) createPolicyInputSource(dbtx walletdb.ReadTx, func (w *Wallet) getEligibleUTXOs(dbtx walletdb.ReadTx, source CoinSource, minconf uint32) ([]wtxmgr.Credit, error) { - // TODO(yy): remove this requireChainClient. The block stamp should be + // TODO(yy): remove this block stamp check. The block stamp should be // passed in as a parameter. - chainClient, err := w.requireChainClient() - if err != nil { - return nil, err - } - - // Get the current block's height and hash. This is needed to determine - // the number of confirmations for UTXOs. - bs, err := chainClient.BlockStamp() + bs, err := w.cfg.Chain.BlockStamp() if err != nil { return nil, err } @@ -795,7 +798,15 @@ func (w *Wallet) getEligibleUTXOsFromAccount(dbtx walletdb.ReadTx, keyScope := &source.KeyScope - account, err := w.AccountNumber(*keyScope, source.AccountName) + manager, err := w.addrStore.FetchScopedKeyManager(*keyScope) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrAccountNotFound, + source.AccountName) + } + + addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) + + account, err := manager.LookupAccount(addrmgrNs, source.AccountName) if err != nil { return nil, fmt.Errorf("%w: %s", ErrAccountNotFound, source.AccountName) diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index 944422954b..ceb65e4aa5 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -64,11 +64,6 @@ func (w *Wallet) CheckMempoolAcceptance(_ context.Context, } // TODO(yy): thread context through. - chainClient, err := w.requireChainClient() - if err != nil { - return err - } - // The TestMempoolAccept rpc expects a slice of transactions. txns := []*wire.MsgTx{tx} @@ -77,7 +72,7 @@ func (w *Wallet) CheckMempoolAcceptance(_ context.Context, // or 10,000 sat/vb. maxFeeRate := float64(0) - results, err := chainClient.TestMempoolAccept(txns, maxFeeRate) + results, err := w.cfg.Chain.TestMempoolAccept(txns, maxFeeRate) if err != nil { return err } @@ -100,7 +95,7 @@ func (w *Wallet) CheckMempoolAcceptance(_ context.Context, //nolint:err113 err = errors.New(result.RejectReason) - return chainClient.MapRPCErr(err) + return w.cfg.Chain.MapRPCErr(err) } // Broadcast broadcasts a tx to the network. It is the main implementation of @@ -478,15 +473,10 @@ func (w *Wallet) filterOwnedAddresses( // transaction to the network. This includes getting a chain client, // registering for notifications, and sending the raw transaction. func (w *Wallet) publishTx(tx *wire.MsgTx, ourAddrs []btcutil.Address) error { - chainClient, err := w.requireChainClient() - if err != nil { - return err - } - // We'll also ask to be notified of the tx once it confirms on-chain. // This is done outside of the database tx to prevent backend // interaction within it. - err = chainClient.NotifyReceived(ourAddrs) + err := w.cfg.Chain.NotifyReceived(ourAddrs) if err != nil { return err } @@ -501,7 +491,7 @@ func (w *Wallet) publishTx(tx *wire.MsgTx, ourAddrs []btcutil.Address) error { //nolint:lll allowHighFees := false - _, rpcErr := chainClient.SendRawTransaction(tx, allowHighFees) + _, rpcErr := w.cfg.Chain.SendRawTransaction(tx, allowHighFees) if rpcErr == nil { return nil } From 9ba28d1f8192688b5745e45e8e30337e169c2929 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 23 Jan 2026 18:00:17 +0800 Subject: [PATCH 287/691] waddrmgr+wallet: resolve race condition in address derivation This commit fixes a race condition where concurrent address generation requests could result in duplicate addresses. The issue was caused by relying on the in-memory account index cache, which could be stale during a pending database commit. We now resolve this by: 1. Always fetching the 'next index' directly from the database's consistent transaction view in 'nextAddresses' and 'extendAddresses'. 2. Implementing a monotonic check in the 'OnCommit' cache update hook, ensuring the in-memory cache only moves forward and never regresses if concurrent transactions commit out of order. 3. Adding a 'getNextIndex' helper to encapsulate the authoritative index retrieval logic. With this fix at the manager level, the broad 'newAddrMtx' lock in the wallet package is no longer necessary. --- waddrmgr/scoped_manager.go | 128 +++++++++++++++++++++++++++++-------- wallet/address_manager.go | 17 +---- wallet/tx_creator.go | 14 +--- 3 files changed, 107 insertions(+), 52 deletions(-) diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 510e4344c2..3b5c456d49 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -1116,8 +1116,10 @@ func (s *ScopedKeyManager) nextAddresses(ns walletdb.ReadWriteBucket, account uint32, numAddresses uint32, internal bool) ([]ManagedAddress, error) { - // The next address can only be generated for accounts that have - // already been created. + // The next address can only be generated for accounts that have already + // been created. We load the account info to retrieve the decrypted keys and + // other cached metadata. This ensures we don't perform expensive crypto + // operations for every address generation. acctInfo, err := s.loadAccountInfo(ns, account) if err != nil { return nil, err @@ -1126,24 +1128,37 @@ func (s *ScopedKeyManager) nextAddresses(ns walletdb.ReadWriteBucket, // Choose the account key to used based on whether the address manager // is locked. acctKey := acctInfo.acctKeyPub - watchOnly := s.rootManager.WatchOnly() || len(acctInfo.acctKeyEncrypted) == 0 + watchOnly := s.rootManager.WatchOnly() || + len(acctInfo.acctKeyEncrypted) == 0 + if !s.rootManager.IsLocked() && !watchOnly { acctKey = acctInfo.acctKeyPriv } + // Choose the appropriate type of address to derive since it's possible + // for a watch-only account to have a different schema from the + // manager's. + addrType := s.accountAddrType(acctInfo, internal) + + // We also fetch the raw account row directly from the database to + // ensure we have the most up-to-date address indices, bypassing any + // potentially stale cached state. This is critical for preventing + // race conditions during concurrent address generation. + // + // NOTE: We must do this *after* loadAccountInfo to ensure the account + // exists and is cached, but we override the indices with the DB values. + nextIndex, err := s.getNextIndex(ns, account, internal) + if err != nil { + return nil, err + } + // Choose the branch key and index depending on whether or not this is // an internal address. - branchNum, nextIndex := ExternalBranch, acctInfo.nextExternalIndex + branchNum := ExternalBranch if internal { branchNum = InternalBranch - nextIndex = acctInfo.nextInternalIndex } - // Choose the appropriate type of address to derive since it's possible - // for a watch-only account to have a different schema from the - // manager's. - addrType := s.accountAddrType(acctInfo, internal) - // Ensure the requested number of addresses doesn't exceed the maximum // allowed for this account. if numAddresses > MaxAddressesPerAccount || nextIndex+numAddresses > @@ -1315,13 +1330,22 @@ func (s *ScopedKeyManager) nextAddresses(ns walletdb.ReadWriteBucket, } // Set the last address and next address for tracking. + // + // NOTE: We only update the cache if the new index is strictly greater + // than the current cached index. This protects against a race condition + // where a slower transaction commits *after* a faster one, which could + // otherwise cause the cache to regress. ma := addressInfo[len(addressInfo)-1].managedAddr if internal { - acctInfo.nextInternalIndex = nextIndex - acctInfo.lastInternalAddr = ma + if nextIndex > acctInfo.nextInternalIndex { + acctInfo.nextInternalIndex = nextIndex + acctInfo.lastInternalAddr = ma + } } else { - acctInfo.nextExternalIndex = nextIndex - acctInfo.lastExternalAddr = ma + if nextIndex > acctInfo.nextExternalIndex { + acctInfo.nextExternalIndex = nextIndex + acctInfo.lastExternalAddr = ma + } } } ns.Tx().OnCommit(onCommit) @@ -1354,19 +1378,30 @@ func (s *ScopedKeyManager) extendAddresses(ns walletdb.ReadWriteBucket, acctKey = acctInfo.acctKeyPriv } + // Choose the appropriate type of address to derive since it's possible + // for a watch-only account to have a different schema from the + // manager's. + addrType := s.accountAddrType(acctInfo, internal) + + // We also fetch the raw account row directly from the database to + // ensure we have the most up-to-date address indices, bypassing any + // potentially stale cached state. This is critical for preventing + // race conditions during concurrent address generation. + // + // NOTE: We must do this *after* loadAccountInfo to ensure the account + // exists and is cached, but we override the indices with the DB values. + nextIndex, err := s.getNextIndex(ns, account, internal) + if err != nil { + return err + } + // Choose the branch key and index depending on whether or not this is // an internal address. - branchNum, nextIndex := ExternalBranch, acctInfo.nextExternalIndex + branchNum := ExternalBranch if internal { branchNum = InternalBranch - nextIndex = acctInfo.nextInternalIndex } - // Choose the appropriate type of address to derive since it's possible - // for a watch-only account to have a different schema from the - // manager's. - addrType := s.accountAddrType(acctInfo, internal) - // If the last index requested is already lower than the next index, we // can return early. if lastIndex < nextIndex { @@ -1505,13 +1540,22 @@ func (s *ScopedKeyManager) extendAddresses(ns walletdb.ReadWriteBucket, } // Set the last address and next address for tracking. + // + // NOTE: We only update the cache if the new index is strictly greater + // than the current cached index. This protects against a race condition + // where a slower transaction commits *after* a faster one, which could + // otherwise cause the cache to regress. ma := addressInfo[len(addressInfo)-1].managedAddr if internal { - acctInfo.nextInternalIndex = nextIndex - acctInfo.lastInternalAddr = ma + if nextIndex > acctInfo.nextInternalIndex { + acctInfo.nextInternalIndex = nextIndex + acctInfo.lastInternalAddr = ma + } } else { - acctInfo.nextExternalIndex = nextIndex - acctInfo.lastExternalAddr = ma + if nextIndex > acctInfo.nextExternalIndex { + acctInfo.nextExternalIndex = nextIndex + acctInfo.lastExternalAddr = ma + } } return nil @@ -2818,6 +2862,40 @@ func (s *ScopedKeyManager) NewAddress(addrmgrNs walletdb.ReadWriteBucket, return addr, nil } +// getNextIndex fetches the current next address index for the specified branch +// (internal/external) of an account directly from the database. This bypasses +// the in-memory cache to ensure the most up-to-date state is used, avoiding +// potential race conditions. +func (s *ScopedKeyManager) getNextIndex(ns walletdb.ReadBucket, + account uint32, internal bool) (uint32, error) { + + rowInterface, err := fetchAccountInfo(ns, &s.scope, account) + if err != nil { + return 0, maybeConvertDbError(err) + } + + var nextInternal, nextExternal uint32 + switch row := rowInterface.(type) { + case *dbDefaultAccountRow: + nextInternal = row.nextInternalIndex + nextExternal = row.nextExternalIndex + + case *dbWatchOnlyAccountRow: + nextInternal = row.nextInternalIndex + nextExternal = row.nextExternalIndex + + default: + str := fmt.Sprintf("unsupported account type %T", row) + return 0, managerError(ErrDatabase, str, nil) + } + + if internal { + return nextInternal, nil + } + + return nextExternal, nil +} + // deriveAddr performs the actual derivation logic for a single address using // the provided account info. It assumes the manager lock is held. func (s *ScopedKeyManager) deriveAddr(acctInfo *accountInfo, account, branch, diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 76f42b73e2..a738e59554 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -276,23 +276,12 @@ func (w *Wallet) keyScopeFromAddrType( // newAddress returns the next external chained address for a wallet. It // wraps the database transaction and the call to the scoped key manager's -// NewAddress method. A mutex is used to protect the in-memory state of the -// address manager from concurrent access during address creation. +// NewAddress method. The underlying address manager handles its own +// synchronization to ensure that in-memory state remains consistent with the +// database, preventing race conditions during address creation. func (w *Wallet) newAddress(manager waddrmgr.AccountStore, accountName string, change bool) (btcutil.Address, error) { - // The address manager uses OnCommit on the walletdb tx to update the - // in-memory state of the account state. But because the commit happens - // _after_ the account manager internal lock has been released, there - // is a chance for the address index to be accessed concurrently, even - // though the closure in OnCommit re-acquires the lock. To avoid this - // issue, we surround the whole address creation process with a lock. - // - // TODO(yy): remove the lock - we should separate the db action and - // memory cache. - w.newAddrMtx.Lock() - defer w.newAddrMtx.Unlock() - var ( addr btcutil.Address err error diff --git a/wallet/tx_creator.go b/wallet/tx_creator.go index f815468739..1d82aba0d4 100644 --- a/wallet/tx_creator.go +++ b/wallet/tx_creator.go @@ -499,8 +499,7 @@ func (w *Wallet) prepareTxAuthSources(intent *TxIntent) ( // `AccountStore` that accepts an active database transaction // and returns an unused address. This will allow the address // derivation to occur within the same atomic transaction as - // the rest of the tx creation logic. Once fixed, we can remove - // the above `w.newAddrMtx` lock. + // the rest of the tx creation logic. _, changeSource, err = w.addrMgrWithChangeSource( dbtx, changeKeyScope, account, ) @@ -555,17 +554,6 @@ func (w *Wallet) CreateTransaction(_ context.Context, intent *TxIntent) ( return nil, err } - // The addrMgrWithChangeSource function of the wallet creates a new - // change address. The address manager uses OnCommit on the walletdb tx - // to update the in-memory state of the account state. But because the - // commit happens _after_ the account manager internal lock has been - // released, there is a chance for the address index to be accessed - // concurrently, even though the closure in OnCommit re-acquires the - // lock. To avoid this issue, we surround the whole address creation - // process with a lock. - w.newAddrMtx.Lock() - defer w.newAddrMtx.Unlock() - inputSource, changeSource, err := w.prepareTxAuthSources(intent) if err != nil { return nil, err From c2b3efe34b2c098f8ddc4dcde840c6e4a9fce46f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 19 Jan 2026 09:36:24 +0800 Subject: [PATCH 288/691] wallet: fix cyclop linter issues This commit refactors several functions to reduce their cyclomatic complexity, making the code easier to read and maintain. --- wallet/address_manager.go | 34 ++++++---- wallet/psbt_manager.go | 137 ++++++++++++++++++++----------------- wallet/utxo_manager.go | 139 ++++++++++++++++++++------------------ 3 files changed, 170 insertions(+), 140 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index a738e59554..73549de18b 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -361,9 +361,28 @@ func (w *Wallet) GetUnusedAddress(ctx context.Context, accountName string, return nil, err } + unusedAddr, err := w.findUnusedAddress(manager, accountName, change) + // We'll ignore the special error that we use to stop the iteration. + if err != nil && !errors.Is(err, errStopIteration) { + return nil, err + } + + // If we found an unused address, we can return it now. + if unusedAddr != nil { + return unusedAddr, nil + } + + // Otherwise, we'll generate a new one. + return w.NewAddress(ctx, accountName, addrType, change) +} + +// findUnusedAddress scans for an unused address for the given account. +func (w *Wallet) findUnusedAddress(manager waddrmgr.AccountStore, + accountName string, change bool) (btcutil.Address, error) { + var unusedAddr btcutil.Address - err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) // First, look up the account number for the passed account @@ -400,18 +419,7 @@ func (w *Wallet) GetUnusedAddress(ctx context.Context, accountName string, ) }) - // We'll ignore the special error that we use to stop the iteration. - if err != nil && !errors.Is(err, errStopIteration) { - return nil, err - } - - // If we found an unused address, we can return it now. - if unusedAddr != nil { - return unusedAddr, nil - } - - // Otherwise, we'll generate a new one. - return w.NewAddress(ctx, accountName, addrType, change) + return unusedAddr, err } // AddressInfo returns detailed information regarding a wallet address. diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index ce02f14531..612daf9db5 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -899,72 +899,24 @@ func (w *Wallet) SignPsbt(ctx context.Context, params *SignPsbtParams) ( // Taproot (SegWit v1) and legacy/SegWit v0 inputs, adapting the // signing process accordingly. for i := range packet.Inputs { - pInput := &packet.Inputs[i] - - // First, we check if the current input should be skipped. This - // helper function identifies inputs that are already finalized - // or lack any derivation information (meaning we don't own the - // key or it's not intended for us to sign). Skipping these - // allows the wallet to focus on relevant inputs and gracefully - // handle multi-signer PSBTs. - if shouldSkipInput(pInput, i) { - continue - } - - // Validate the derivation information to ensure we have an - // unambiguous signing path. Our policy enforces that an input - // should not contain conflicting Taproot and BIP32 derivation - // paths, nor multiple paths of the same type. This prevents - // misinterpretations and ensures deterministic signing. - isTaproot, err := validateDerivation(pInput, i) - if err != nil { - return nil, err - } - - // Based on the validated derivation information, we dispatch - // the signing task to the appropriate helper function. If the - // input is identified as Taproot, we use - // `signTaprootPsbtInput`; otherwise, we assume it's a legacy - // or SegWit v0 input and use `signBip32PsbtInput`. - if isTaproot { - err = w.signTaprootPsbtInput( - ctx, packet, i, sigHashes, - params.InputTweakers[i], - ) - } else { - err = w.signBip32PsbtInput( - ctx, packet, i, sigHashes, - params.InputTweakers[i], - ) - } - - // If an error occurred during signing, we first check if it's - // an error that permits us to skip the current input (e.g., if - // the key is not found, implying it's another signer's input - // in a collaborative PSBT). If the error is *not* skippable, - // it indicates a critical issue, and we return it immediately. - // Otherwise, we continue to the next input. + signed, err := w.signPsbtInput( + ctx, packet, i, sigHashes, params.InputTweakers, + ) if err != nil { - if shouldSkipSigningError(err, i) { - continue - } - return nil, fmt.Errorf("input %d: %w", i, err) } - // If signing was successful (or the error was gracefully - // skipped), we record the index of this input as one that the - // wallet has contributed a signature to. This provides - // valuable feedback to the caller about the progress of the - // signing operation. - // - // We convert the index i (int) to uint32. This is safe because - // a Bitcoin transaction is strictly bounded by the block size - // limit (4MB). Even with the smallest possible input size, the - // maximum number of inputs is less than 100,000, which is far - // below MaxUint32 (~4.2 billion). - //nolint:gosec - signedInputs = append(signedInputs, uint32(i)) + if signed { + // If signing was successful, we record the index of + // this input as one that the wallet has contributed a + // signature to. + // + // We convert the index i (int) to uint32. This is safe + // because a Bitcoin transaction is strictly bounded by + // the block size limit. + //nolint:gosec + signedInputs = append(signedInputs, uint32(i)) + } } // Finally, return the result, which includes the list of inputs that @@ -976,6 +928,67 @@ func (w *Wallet) SignPsbt(ctx context.Context, params *SignPsbtParams) ( }, nil } +// signPsbtInput attempts to sign a single input of the PSBT. It returns true +// if the input was successfully signed, false if it was skipped (e.g. already +// signed or not owned), and an error if a fatal signing error occurred. +func (w *Wallet) signPsbtInput(ctx context.Context, packet *psbt.Packet, + i int, sigHashes *txscript.TxSigHashes, + tweakers map[int]PrivKeyTweaker) (bool, error) { + + pInput := &packet.Inputs[i] + + // First, we check if the current input should be skipped. This + // helper function identifies inputs that are already finalized + // or lack any derivation information (meaning we don't own the + // key or it's not intended for us to sign). Skipping these + // allows the wallet to focus on relevant inputs and gracefully + // handle multi-signer PSBTs. + if shouldSkipInput(pInput, i) { + return false, nil + } + + // Validate the derivation information to ensure we have an + // unambiguous signing path. Our policy enforces that an input + // should not contain conflicting Taproot and BIP32 derivation + // paths, nor multiple paths of the same type. This prevents + // misinterpretations and ensures deterministic signing. + isTaproot, err := validateDerivation(pInput, i) + if err != nil { + return false, err + } + + // Based on the validated derivation information, we dispatch + // the signing task to the appropriate helper function. If the + // input is identified as Taproot, we use + // `signTaprootPsbtInput`; otherwise, we assume it's a legacy + // or SegWit v0 input and use `signBip32PsbtInput`. + if isTaproot { + err = w.signTaprootPsbtInput( + ctx, packet, i, sigHashes, tweakers[i], + ) + } else { + err = w.signBip32PsbtInput( + ctx, packet, i, sigHashes, tweakers[i], + ) + } + + // If an error occurred during signing, we first check if it's + // an error that permits us to skip the current input (e.g., if + // the key is not found, implying it's another signer's input + // in a collaborative PSBT). If the error is *not* skippable, + // it indicates a critical issue, and we return it immediately. + // Otherwise, we continue to the next input. + if err != nil { + if shouldSkipSigningError(err, i) { + return false, nil + } + + return false, err + } + + return true, nil +} + // parseBip32Path parses a raw derivation path (sequence of uint32s) and // verifies that it conforms to the BIP44-like hierarchy structure // (m / purpose' / coin_type' / account' / branch / index) used by this wallet. diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index d8ab482158..a38ffa34fa 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -186,73 +186,12 @@ func (w *Wallet) ListUnspent(_ context.Context, // Iterate through each UTXO to apply filters and enrich it with // address-specific details. for _, output := range unspent { - confs := calcConf(output.Height, currentHeight) - - log.Tracef("Checking utxo[%v]: current height=%v, "+ - "confirm height=%v, conf=%v", output.OutPoint, - currentHeight, output.Height, confs) - - // Apply the MinConfs and MaxConfs filters from the - // query. - if confs < query.MinConfs || confs > query.MaxConfs { - continue - } - - // Extract the address from the UTXO's public key - // script. - // For multi-address scripts, the first address is used. - addr := extractAddrFromPKScript( - output.PkScript, w.cfg.ChainParams, + utxo := w.processUnspentOutput( + addrmgrNs, output, currentHeight, query, ) - if addr == nil { - continue - } - - // Get all the required address-related details. - // - // NOTE: This lookup is the source of the N+1 query - // problem. - spendable, account, addrType := w.addrStore. - AddressDetails(addrmgrNs, addr) - - log.Debugf("Found address: %s from account: %s", - addr.String(), account) - - // Apply the Account filter from the query. - if query.Account != "" && account != query.Account { - continue + if utxo != nil { + utxos = append(utxos, utxo) } - - // A UTXO is also unspendable if it is an immature - // coinbase output. - if output.FromCoinBase { - maturity := w.cfg.ChainParams.CoinbaseMaturity - if confs < int32(maturity) { - spendable = false - } - } - - // TODO(yy): This should be a column in the new utxo - // SQL table. Note that currently UnspentOutputs only - // returns unlocked outputs, so this field will always - // be false. This will be fixed in the upcoming - // sqlization PRs. - locked := output.Locked - - // If all filters pass, construct the final Utxo struct - // with all the combined data. - utxo := &Utxo{ - OutPoint: output.OutPoint, - Amount: output.Amount, - PkScript: output.PkScript, - Confirmations: confs, - Spendable: spendable, - Address: addr, - Account: account, - AddressType: addrType, - Locked: locked, - } - utxos = append(utxos, utxo) } return nil @@ -268,6 +207,76 @@ func (w *Wallet) ListUnspent(_ context.Context, return utxos, err } +// processUnspentOutput processes a single unspent output, applying filters and +// enriching it with address details. Returns nil if the output should be +// skipped. +func (w *Wallet) processUnspentOutput(addrmgrNs walletdb.ReadBucket, + output wtxmgr.Credit, currentHeight int32, query UtxoQuery) *Utxo { + + confs := calcConf(output.Height, currentHeight) + + log.Tracef("Checking utxo[%v]: current height=%v, "+ + "confirm height=%v, conf=%v", output.OutPoint, + currentHeight, output.Height, confs) + + // Apply the MinConfs and MaxConfs filters from the query. + if confs < query.MinConfs || confs > query.MaxConfs { + return nil + } + + // Extract the address from the UTXO's public key script. + // For multi-address scripts, the first address is used. + addr := extractAddrFromPKScript( + output.PkScript, w.cfg.ChainParams, + ) + if addr == nil { + return nil + } + + // Get all the required address-related details. + // + // NOTE: This lookup is the source of the N+1 query problem. + spendable, account, addrType := w.addrStore.AddressDetails( + addrmgrNs, addr, + ) + + log.Debugf("Found address: %s from account: %s", + addr.String(), account) + + // Apply the Account filter from the query. + if query.Account != "" && account != query.Account { + return nil + } + + // A UTXO is also unspendable if it is an immature coinbase output. + if output.FromCoinBase { + maturity := w.cfg.ChainParams.CoinbaseMaturity + if confs < int32(maturity) { + spendable = false + } + } + + // TODO(yy): This should be a column in the new utxo SQL table. Note + // that currently UnspentOutputs only returns unlocked outputs, so this + // field will always be false. This will be fixed in the upcoming + // sqlization PRs. + locked := output.Locked + + // If all filters pass, construct the final Utxo struct with all the + // combined data. + return &Utxo{ + OutPoint: output.OutPoint, + Amount: output.Amount, + PkScript: output.PkScript, + Confirmations: confs, + Spendable: spendable, + Address: addr, + Account: account, + AddressType: addrType, + Locked: locked, + } +} + // GetUtxo returns the output information for a given outpoint. // // This method provides a detailed view of a single UTXO, identified by its From 884fd33b29705319e71236c3cf33b220e4265d12 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 19 Jan 2026 08:41:57 +0800 Subject: [PATCH 289/691] wallet: move deprecated methods to `deprecated.go` --- wallet/deprecated.go | 1100 +++++++++++++++++++++++++++++++++++++++- wallet/loader.go | 433 ---------------- wallet/recovery.go | 210 -------- wallet/rescan.go | 313 ------------ wallet/utxo_manager.go | 11 - wallet/wallet.go | 113 ----- 6 files changed, 1093 insertions(+), 1087 deletions(-) delete mode 100644 wallet/loader.go delete mode 100644 wallet/rescan.go diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 192cf72ff7..1f412aae04 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -3,11 +3,12 @@ package wallet import ( "bytes" - "context" "encoding/binary" "encoding/hex" "errors" "fmt" + "os" + "path/filepath" "sort" "sync" "sync/atomic" @@ -24,11 +25,13 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/internal/prompt" "github.com/btcsuite/btcwallet/netparams" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/walletdb/migration" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/davecgh/go-spew/spew" "github.com/lightningnetwork/lnd/fn/v2" @@ -184,19 +187,63 @@ func (w *Wallet) RenameAccountDeprecated(scope waddrmgr.KeyScope, func (w *Wallet) ScriptForOutputDeprecated(output *wire.TxOut) ( waddrmgr.ManagedPubKeyAddress, []byte, []byte, error) { - script, err := w.ScriptForOutput(context.Background(), *output) + // First make sure we can sign for the input by making sure the script + // in the UTXO belongs to our wallet and we have the private key for it. + walletAddr, err := w.fetchOutputAddr(output.PkScript) if err != nil { return nil, nil, nil, err } - addr := script.Addr - pubKeyAddr, ok := addr.(waddrmgr.ManagedPubKeyAddress) + pubKeyAddr, ok := walletAddr.(waddrmgr.ManagedPubKeyAddress) if !ok { - return nil, nil, nil, fmt.Errorf("%w: addr %s", - ErrNotPubKeyAddress, addr.Address()) + return nil, nil, nil, fmt.Errorf("address %s is not a "+ + "p2wkh or np2wkh address", walletAddr.Address()) } - return pubKeyAddr, script.WitnessProgram, script.RedeemScript, nil + var ( + witnessProgram []byte + sigScript []byte + ) + + switch { + // If we're spending p2wkh output nested within a p2sh output, then + // we'll need to attach a sigScript in addition to witness data. + case walletAddr.AddrType() == waddrmgr.NestedWitnessPubKey: + pubKey := pubKeyAddr.PubKey() + pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) + + // Next, we'll generate a valid sigScript that will allow us to + // spend the p2sh output. The sigScript will contain only a + // single push of the p2wkh witness program corresponding to + // the matching public key of this address. + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + pubKeyHash, w.chainParams, + ) + if err != nil { + return nil, nil, nil, err + } + witnessProgram, err = txscript.PayToAddrScript(p2wkhAddr) + if err != nil { + return nil, nil, nil, err + } + + bldr := txscript.NewScriptBuilder() + bldr.AddData(witnessProgram) + sigScript, err = bldr.Script() + if err != nil { + return nil, nil, nil, err + } + + // Otherwise, this is a regular p2wkh or p2tr output, so we include the + // witness program itself as the subscript to generate the proper + // sighash digest. As part of the new sighash digest algorithm, the + // p2wkh witness program will be expanded into a regular p2kh + // script. + default: + witnessProgram = output.PkScript + } + + return pubKeyAddr, witnessProgram, sigScript, nil } // ComputeInputScript generates a complete InputScript for the passed @@ -2710,6 +2757,17 @@ func (w *Wallet) UnlockOutpoint(op wire.OutPoint) { delete(w.lockedOutpoints, op) } +// LockedOutpoint returns whether an outpoint has been marked as locked and +// should not be used as an input for created transactions. +func (w *Wallet) LockedOutpoint(op wire.OutPoint) bool { + w.lockedOutpointsMtx.Lock() + defer w.lockedOutpointsMtx.Unlock() + + _, locked := w.lockedOutpoints[op] + + return locked +} + // ResetLockedOutpoints resets the set of locked outpoints so all may be used // as inputs for new transactions. func (w *Wallet) ResetLockedOutpoints() { @@ -6333,3 +6391,1031 @@ func (w *Wallet) RescanDeprecated(addrs []btcutil.Address, return w.rescanWithTarget(addrs, unspent, nil) } + +// RescanProgressMsg reports the current progress made by a rescan for a +// set of wallet addresses. +type RescanProgressMsg struct { + Addresses []btcutil.Address + Notification chain.RescanProgress +} + +// RescanFinishedMsg reports the addresses that were rescanned when a +// rescanfinished message was received rescanning a batch of addresses. +type RescanFinishedMsg struct { + Addresses []btcutil.Address + Notification *chain.RescanFinished +} + +// RescanJob is a job to be processed by the RescanManager. The job includes +// a set of wallet addresses, a starting height to begin the rescan, and +// outpoints spendable by the addresses thought to be unspent. After the +// rescan completes, the error result of the rescan RPC is sent on the Err +// channel. +type RescanJob struct { + InitialSync bool + Addrs []btcutil.Address + OutPoints map[wire.OutPoint]btcutil.Address + BlockStamp waddrmgr.BlockStamp + err chan error +} + +// rescanBatch is a collection of one or more RescanJobs that were merged +// together before a rescan is performed. +type rescanBatch struct { + initialSync bool + addrs []btcutil.Address + outpoints map[wire.OutPoint]btcutil.Address + bs waddrmgr.BlockStamp + errChans []chan error +} + +// SubmitRescan submits a RescanJob to the RescanManager. A channel is +// returned with the final error of the rescan. The channel is buffered +// and does not need to be read to prevent a deadlock. +func (w *Wallet) SubmitRescan(job *RescanJob) <-chan error { + errChan := make(chan error, 1) + job.err = errChan + select { + case w.rescanAddJob <- job: + case <-w.quitChan(): + errChan <- ErrWalletShuttingDown + } + return errChan +} + +// batch creates the rescanBatch for a single rescan job. +func (job *RescanJob) batch() *rescanBatch { + return &rescanBatch{ + initialSync: job.InitialSync, + addrs: job.Addrs, + outpoints: job.OutPoints, + bs: job.BlockStamp, + errChans: []chan error{job.err}, + } +} + +// merge merges the work from k into j, setting the starting height to +// the minimum of the two jobs. This method does not check for +// duplicate addresses or outpoints. +func (b *rescanBatch) merge(job *RescanJob) { + if job.InitialSync { + b.initialSync = true + } + b.addrs = append(b.addrs, job.Addrs...) + + for op, addr := range job.OutPoints { + b.outpoints[op] = addr + } + + if job.BlockStamp.Height < b.bs.Height { + b.bs = job.BlockStamp + } + b.errChans = append(b.errChans, job.err) +} + +// done iterates through all error channels, duplicating sending the error +// to inform callers that the rescan finished (or could not complete due +// to an error). +func (b *rescanBatch) done(err error) { + for _, c := range b.errChans { + c <- err + } +} + +// rescanBatchHandler handles incoming rescan request, serializing rescan +// submissions, and possibly batching many waiting requests together so they +// can be handled by a single rescan after the current one completes. +func (w *Wallet) rescanBatchHandler() { + defer w.wg.Done() + + var curBatch, nextBatch *rescanBatch + quit := w.quitChan() + + for { + select { + case job := <-w.rescanAddJob: + if curBatch == nil { + // Set current batch as this job and send + // request. + curBatch = job.batch() + select { + case w.rescanBatch <- curBatch: + case <-quit: + job.err <- ErrWalletShuttingDown + return + } + } else { + // Create next batch if it doesn't exist, or + // merge the job. + if nextBatch == nil { + nextBatch = job.batch() + } else { + nextBatch.merge(job) + } + } + + case n := <-w.rescanNotifications: + switch n := n.(type) { + case *chain.RescanProgress: + if curBatch == nil { + log.Warnf("Received rescan progress " + + "notification but no rescan " + + "currently running") + continue + } + select { + case w.rescanProgress <- &RescanProgressMsg{ + Addresses: curBatch.addrs, + Notification: *n, + }: + case <-quit: + for _, errChan := range curBatch.errChans { + errChan <- ErrWalletShuttingDown + } + return + } + + case *chain.RescanFinished: + if curBatch == nil { + log.Warnf("Received rescan finished " + + "notification but no rescan " + + "currently running") + continue + } + select { + case w.rescanFinished <- &RescanFinishedMsg{ + Addresses: curBatch.addrs, + Notification: n, + }: + case <-quit: + for _, errChan := range curBatch.errChans { + errChan <- ErrWalletShuttingDown + } + return + } + + curBatch, nextBatch = nextBatch, nil + + if curBatch != nil { + select { + case w.rescanBatch <- curBatch: + case <-quit: + for _, errChan := range curBatch.errChans { + errChan <- ErrWalletShuttingDown + } + return + } + } + + default: + // Unexpected message + panic(n) + } + + case <-quit: + return + } + } +} + +// rescanProgressHandler handles notifications for partially and fully completed +// rescans by marking each rescanned address as partially or fully synced. +func (w *Wallet) rescanProgressHandler() { + quit := w.quitChan() +out: + for { + // These can't be processed out of order since both chans are + // unbuffured and are sent from same context (the batch + // handler). + select { + case msg := <-w.rescanProgress: + n := msg.Notification + log.Infof("Rescanned through block %v (height %d)", + n.Hash, n.Height) + + case msg := <-w.rescanFinished: + n := msg.Notification + addrs := msg.Addresses + noun := pickNoun(len(addrs), "address", "addresses") + log.Infof("Finished rescan for %d %s (synced to block "+ + "%s, height %d)", len(addrs), noun, n.Hash, + n.Height) + + go w.resendUnminedTxs() + + case <-quit: + break out + } + } + w.wg.Done() +} + +// rescanRPCHandler reads batch jobs sent by rescanBatchHandler and sends the +// RPC requests to perform a rescan. New jobs are not read until a rescan +// finishes. +func (w *Wallet) rescanRPCHandler() { + chainClient, err := w.requireChainClient() + if err != nil { + log.Errorf("rescanRPCHandler called without an RPC client") + w.wg.Done() + return + } + + quit := w.quitChan() + +out: + for { + select { + case batch := <-w.rescanBatch: + // Log the newly-started rescan. + numAddrs := len(batch.addrs) + numOps := len(batch.outpoints) + + log.Infof("Started rescan from block %v (height %d) "+ + "for %d addrs, %d outpoints", batch.bs.Hash, + batch.bs.Height, numAddrs, numOps) + + err := chainClient.Rescan( + &batch.bs.Hash, batch.addrs, batch.outpoints, + ) + if err != nil { + log.Errorf("Rescan for %d addrs, %d outpoints "+ + "failed: %v", numAddrs, numOps, err) + } + batch.done(err) + case <-quit: + break out + } + } + + w.wg.Done() +} + +// rescanWithTarget performs a rescan starting at the optional startStamp. If +// none is provided, the rescan will begin from the manager's sync tip. +func (w *Wallet) rescanWithTarget(addrs []btcutil.Address, + unspent []wtxmgr.Credit, startStamp *waddrmgr.BlockStamp) error { + + outpoints := make(map[wire.OutPoint]btcutil.Address, len(unspent)) + for _, output := range unspent { + _, outputAddrs, _, err := txscript.ExtractPkScriptAddrs( + output.PkScript, w.chainParams, + ) + if err != nil { + return err + } + + outpoints[output.OutPoint] = outputAddrs[0] + } + + // If a start block stamp was provided, we will use that as the initial + // starting point for the rescan. + if startStamp == nil { + startStamp = &waddrmgr.BlockStamp{} + *startStamp = w.addrStore.SyncedTo() + } + + job := &RescanJob{ + InitialSync: true, + Addrs: addrs, + OutPoints: outpoints, + BlockStamp: *startStamp, + } + + // Submit merged job and block until rescan completes. + select { + case err := <-w.SubmitRescan(job): + return err + case <-w.quitChan(): + return ErrWalletShuttingDown + } +} + +// ChainParams returns the network parameters for the blockchain the wallet +// belongs to. +func (w *Wallet) ChainParams() *chaincfg.Params { + return w.chainParams +} + +// Database returns the underlying walletdb database. This method is provided +// in order to allow applications wrapping btcwallet to store app-specific data +// with the wallet's database. +func (w *Wallet) Database() walletdb.DB { + return w.db +} + +// Open loads an already-created wallet from the passed database and namespaces. +func Open(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, + params *chaincfg.Params, recoveryWindow uint32) (*Wallet, error) { + + return OpenWithRetry( + db, pubPass, cbs, params, recoveryWindow, + defaultSyncRetryInterval, + ) +} + +// OpenWithRetry loads an already-created wallet from the passed database and +// namespaces and re-tries on errors during initial sync. +func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, + params *chaincfg.Params, recoveryWindow uint32, + syncRetryInterval time.Duration) (*Wallet, error) { + + var ( + addrMgr *waddrmgr.Manager + txMgr *wtxmgr.Store + ) + + // Before attempting to open the wallet, we'll check if there are any + // database upgrades for us to proceed. We'll also create our references + // to the address and transaction managers, as they are backed by the + // database. + err := walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + addrMgrBucket := tx.ReadWriteBucket(waddrmgrNamespaceKey) + if addrMgrBucket == nil { + return errors.New("missing address manager namespace") + } + txMgrBucket := tx.ReadWriteBucket(wtxmgrNamespaceKey) + if txMgrBucket == nil { + return errors.New("missing transaction manager namespace") + } + + addrMgrUpgrader := waddrmgr.NewMigrationManager(addrMgrBucket) + txMgrUpgrader := wtxmgr.NewMigrationManager(txMgrBucket) + err := migration.Upgrade(txMgrUpgrader, addrMgrUpgrader) + if err != nil { + return err + } + + addrMgr, err = waddrmgr.Open(addrMgrBucket, pubPass, params) + if err != nil { + return err + } + txMgr, err = wtxmgr.Open(txMgrBucket, params) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + log.Infof("Opened wallet") // TODO: log balance? last sync height? + + deprecated := &walletDeprecated{ + lockedOutpoints: map[wire.OutPoint]struct{}{}, + publicPassphrase: pubPass, + db: db, + recoveryWindow: recoveryWindow, + rescanAddJob: make(chan *RescanJob), + rescanBatch: make(chan *rescanBatch), + rescanNotifications: make(chan interface{}), + rescanProgress: make(chan *RescanProgressMsg), + rescanFinished: make(chan *RescanFinishedMsg), + createTxRequests: make(chan createTxRequest), + unlockRequests: make(chan unlockRequest), + lockRequests: make(chan struct{}), + holdUnlockRequests: make(chan chan heldUnlock), + lockState: make(chan bool), + changePassphrase: make(chan changePassphraseRequest), + changePassphrases: make(chan changePassphrasesRequest), + chainParams: params, + quit: make(chan struct{}), + syncRetryInterval: syncRetryInterval, + } + + w := &Wallet{ + addrStore: addrMgr, + txStore: txMgr, + walletDeprecated: deprecated, + } + + w.NtfnServer = newNotificationServer(w) + txMgr.NotifyUnspent = func(hash *chainhash.Hash, index uint32) { + w.NtfnServer.notifyUnspentOutput(0, hash, index) + } + + return w, nil +} + +// RecoveryManager maintains the state required to recover previously used +// addresses, and coordinates batched processing of the blocks to search. +// +// TODO(yy): Deprecated, remove. +type RecoveryManager struct { + // recoveryWindow defines the key-derivation lookahead used when + // attempting to recover the set of used addresses. + recoveryWindow uint32 + + // started is true after the first block has been added to the batch. + started bool + + // blockBatch contains a list of blocks that have not yet been searched + // for recovered addresses. + blockBatch []wtxmgr.BlockMeta + + // state encapsulates and allocates the necessary recovery state for all + // key scopes and subsidiary derivation paths. + state *RecoveryState + + // chainParams are the parameters that describe the chain we're trying + // to recover funds on. + chainParams *chaincfg.Params +} + +// NewRecoveryManager initializes a new RecoveryManager with a derivation +// look-ahead of `recoveryWindow` child indexes, and pre-allocates a backing +// array for `batchSize` blocks to scan at once. +// +// TODO(yy): Deprecated, remove. +func NewRecoveryManager(recoveryWindow, batchSize uint32, + chainParams *chaincfg.Params) *RecoveryManager { + + return &RecoveryManager{ + recoveryWindow: recoveryWindow, + blockBatch: make([]wtxmgr.BlockMeta, 0, batchSize), + chainParams: chainParams, + state: NewRecoveryState( + recoveryWindow, chainParams, nil, + ), + } +} + +// Resurrect restores all known addresses for the provided scopes that can be +// found in the walletdb namespace, in addition to restoring all outpoints that +// have been previously found. This method ensures that the recovery state's +// horizons properly start from the last found address of a prior recovery +// attempt. +// +// TODO(yy): Deprecated, remove. +func (rm *RecoveryManager) Resurrect(ns walletdb.ReadBucket, + scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, + credits []wtxmgr.Credit) error { + + // First, for each scope that we are recovering, rederive all of the + // addresses up to the last found address known to each branch. + for keyScope, scopedMgr := range scopedMgrs { + // Load the current account properties for this scope, using the + // the default account number. + // TODO(conner): rescan for all created accounts if we allow + // users to use non-default address + scopeState := rm.state.StateForScope(keyScope) + acctProperties, err := scopedMgr.AccountProperties( + ns, waddrmgr.DefaultAccountNum, + ) + if err != nil { + return err + } + + // Fetch the external key count, which bounds the indexes we + // will need to rederive. + externalCount := acctProperties.ExternalKeyCount + + // Walk through all indexes through the last external key, + // deriving each address and adding it to the external branch + // recovery state's set of addresses to look for. + for i := uint32(0); i < externalCount; i++ { + keyPath := externalKeyPath(i) + addr, err := scopedMgr.DeriveFromKeyPath(ns, keyPath) + if err != nil && err != hdkeychain.ErrInvalidChild { + return err + } else if err == hdkeychain.ErrInvalidChild { + scopeState.ExternalBranch.MarkInvalidChild(i) + continue + } + + scopeState.ExternalBranch.AddAddr(i, addr.Address()) + } + + // Fetch the internal key count, which bounds the indexes we + // will need to rederive. + internalCount := acctProperties.InternalKeyCount + + // Walk through all indexes through the last internal key, + // deriving each address and adding it to the internal branch + // recovery state's set of addresses to look for. + for i := uint32(0); i < internalCount; i++ { + keyPath := internalKeyPath(i) + addr, err := scopedMgr.DeriveFromKeyPath(ns, keyPath) + if err != nil && err != hdkeychain.ErrInvalidChild { + return err + } else if err == hdkeychain.ErrInvalidChild { + scopeState.InternalBranch.MarkInvalidChild(i) + continue + } + + scopeState.InternalBranch.AddAddr(i, addr.Address()) + } + + // The key counts will point to the next key that can be + // derived, so we subtract one to point to last known key. If + // the key count is zero, then no addresses have been found. + if externalCount > 0 { + scopeState.ExternalBranch.ReportFound(externalCount - 1) + } + if internalCount > 0 { + scopeState.InternalBranch.ReportFound(internalCount - 1) + } + } + + // In addition, we will re-add any outpoints that are known the wallet + // to our global set of watched outpoints, so that we can watch them for + // spends. + for _, credit := range credits { + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + credit.PkScript, rm.chainParams, + ) + if err != nil { + return err + } + + rm.state.AddWatchedOutPoint(&credit.OutPoint, addrs[0]) + } + + return nil +} + +// AddToBlockBatch appends the block information, consisting of hash and height, +// to the batch of blocks to be searched. +// +// TODO(yy): Deprecated, remove. +func (rm *RecoveryManager) AddToBlockBatch(hash *chainhash.Hash, height int32, + timestamp time.Time) { + + if !rm.started { + log.Infof("Seed birthday surpassed, starting recovery "+ + "of wallet from height=%d hash=%v with "+ + "recovery-window=%d", height, *hash, rm.recoveryWindow) + rm.started = true + } + + block := wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: *hash, + Height: height, + }, + Time: timestamp, + } + rm.blockBatch = append(rm.blockBatch, block) +} + +// BlockBatch returns a buffer of blocks that have not yet been searched. +// +// TODO(yy): Deprecated, remove. +func (rm *RecoveryManager) BlockBatch() []wtxmgr.BlockMeta { + return rm.blockBatch +} + +// ResetBlockBatch resets the internal block buffer to conserve memory. +// +// TODO(yy): Deprecated, remove. +func (rm *RecoveryManager) ResetBlockBatch() { + rm.blockBatch = rm.blockBatch[:0] +} + +// State returns the current RecoveryState. +// +// TODO(yy): Deprecated, remove. +func (rm *RecoveryManager) State() *RecoveryState { + return rm.state +} + +// ScopeRecoveryState is used to manage the recovery of addresses generated +// under a particular BIP32 account. Each account tracks both an external and +// internal branch recovery state, both of which use the same recovery window. +// +// TODO(yy): Deprecated, remove. +type ScopeRecoveryState struct { + // ExternalBranch is the recovery state of addresses generated for + // external use, i.e. receiving addresses. + ExternalBranch *BranchRecoveryState + + // InternalBranch is the recovery state of addresses generated for + // internal use, i.e. change addresses. + InternalBranch *BranchRecoveryState +} + +// NewScopeRecoveryState initializes an ScopeRecoveryState with the chosen +// recovery window. +// +// TODO(yy): Deprecated, remove. +func NewScopeRecoveryState(recoveryWindow uint32) *ScopeRecoveryState { + return &ScopeRecoveryState{ + ExternalBranch: NewBranchRecoveryState(recoveryWindow, nil), + InternalBranch: NewBranchRecoveryState(recoveryWindow, nil), + } +} + +const ( + // WalletDBName specified the database filename for the wallet. + WalletDBName = "wallet.db" + + // DefaultDBTimeout is the default timeout value when opening the wallet + // database. + DefaultDBTimeout = 60 * time.Second +) + +var ( + // ErrLoaded describes the error condition of attempting to load or + // create a wallet when the loader has already done so. + ErrLoaded = errors.New("wallet already loaded") + + // ErrNotLoaded describes the error condition of attempting to close a + // loaded wallet when a wallet has not been loaded. + ErrNotLoaded = errors.New("wallet is not loaded") + + // ErrExists describes the error condition of attempting to create a new + // wallet when one exists already. + ErrExists = errors.New("wallet already exists") +) + +// loaderConfig contains the configuration options for the loader. +type loaderConfig struct { + walletSyncRetryInterval time.Duration +} + +// defaultLoaderConfig returns the default configuration options for the loader. +func defaultLoaderConfig() *loaderConfig { + return &loaderConfig{ + walletSyncRetryInterval: defaultSyncRetryInterval, + } +} + +// LoaderOption is a configuration option for the loader. +type LoaderOption func(*loaderConfig) + +// WithWalletSyncRetryInterval specifies the interval at which the wallet +// should retry syncing to the chain if it encounters an error. +func WithWalletSyncRetryInterval(interval time.Duration) LoaderOption { + return func(c *loaderConfig) { + c.walletSyncRetryInterval = interval + } +} + +// Loader implements the creating of new and opening of existing wallets, while +// providing a callback system for other subsystems to handle the loading of a +// wallet. This is primarily intended for use by the RPC servers, to enable +// methods and services which require the wallet when the wallet is loaded by +// another subsystem. +// +// Loader is safe for concurrent access. +type Loader struct { + cfg *loaderConfig + callbacks []func(*Wallet) + chainParams *chaincfg.Params + dbDirPath string + noFreelistSync bool + timeout time.Duration + recoveryWindow uint32 + wallet *Wallet + localDB bool + walletExists func() (bool, error) + walletCreated func(db walletdb.ReadWriteTx) error + db walletdb.DB + mu sync.Mutex +} + +// NewLoader constructs a Loader with an optional recovery window. If the +// recovery window is non-zero, the wallet will attempt to recovery addresses +// starting from the last SyncedTo height. +func NewLoader(chainParams *chaincfg.Params, dbDirPath string, + noFreelistSync bool, timeout time.Duration, recoveryWindow uint32, + opts ...LoaderOption) *Loader { + + cfg := defaultLoaderConfig() + for _, opt := range opts { + opt(cfg) + } + + return &Loader{ + cfg: cfg, + chainParams: chainParams, + dbDirPath: dbDirPath, + noFreelistSync: noFreelistSync, + timeout: timeout, + recoveryWindow: recoveryWindow, + localDB: true, + } +} + +// NewLoaderWithDB constructs a Loader with an externally provided DB. This way +// users are free to use their own walletdb implementation (eg. leveldb, etcd) +// to store the wallet. Given that the external DB may be shared an additional +// function is also passed which will override Loader.WalletExists(). +func NewLoaderWithDB(chainParams *chaincfg.Params, recoveryWindow uint32, + db walletdb.DB, walletExists func() (bool, error), + opts ...LoaderOption) (*Loader, error) { + + if db == nil { + return nil, fmt.Errorf("no DB provided") + } + + if walletExists == nil { + return nil, fmt.Errorf("unable to check if wallet exists") + } + + cfg := defaultLoaderConfig() + for _, opt := range opts { + opt(cfg) + } + + return &Loader{ + cfg: cfg, + chainParams: chainParams, + recoveryWindow: recoveryWindow, + localDB: false, + walletExists: walletExists, + db: db, + }, nil +} + +// onLoaded executes each added callback and prevents loader from loading any +// additional wallets. Requires mutex to be locked. +func (l *Loader) onLoaded(w *Wallet) { + for _, fn := range l.callbacks { + fn(w) + } + + l.wallet = w + l.callbacks = nil // not needed anymore +} + +// RunAfterLoad adds a function to be executed when the loader creates or opens +// a wallet. Functions are executed in a single goroutine in the order they are +// added. +func (l *Loader) RunAfterLoad(fn func(*Wallet)) { + l.mu.Lock() + if l.wallet != nil { + w := l.wallet + l.mu.Unlock() + fn(w) + } else { + l.callbacks = append(l.callbacks, fn) + l.mu.Unlock() + } +} + +// OnWalletCreated adds a function that will be executed the wallet structure +// is initialized in the wallet database. This is useful if users want to add +// extra fields in the same transaction (eg. to flag wallet existence). +func (l *Loader) OnWalletCreated(fn func(walletdb.ReadWriteTx) error) { + l.mu.Lock() + defer l.mu.Unlock() + l.walletCreated = fn +} + +// CreateNewWallet creates a new wallet using the provided public and private +// passphrases. The seed is optional. If non-nil, addresses are derived from +// this seed. If nil, a secure random seed is generated. +func (l *Loader) CreateNewWallet(pubPassphrase, privPassphrase, seed []byte, + bday time.Time) (*Wallet, error) { + + var ( + rootKey *hdkeychain.ExtendedKey + err error + ) + + // If a seed was specified, we check its length now. If no seed is + // passed, the wallet will create a new random one. + if seed != nil { + if len(seed) < hdkeychain.MinSeedBytes || + len(seed) > hdkeychain.MaxSeedBytes { + + return nil, hdkeychain.ErrInvalidSeedLen + } + + // Derive the master extended key from the seed. + rootKey, err = hdkeychain.NewMaster(seed, l.chainParams) + if err != nil { + return nil, fmt.Errorf("failed to derive master " + + "extended key") + } + } + + return l.createNewWallet( + pubPassphrase, privPassphrase, rootKey, bday, false, + ) +} + +// CreateNewWalletExtendedKey creates a new wallet from an extended master root +// key using the provided public and private passphrases. The root key is +// optional. If non-nil, addresses are derived from this root key. If nil, a +// secure random seed is generated and the root key is derived from that. +func (l *Loader) CreateNewWalletExtendedKey(pubPassphrase, privPassphrase []byte, + rootKey *hdkeychain.ExtendedKey, bday time.Time) (*Wallet, error) { + + return l.createNewWallet( + pubPassphrase, privPassphrase, rootKey, bday, false, + ) +} + +// CreateNewWatchingOnlyWallet creates a new wallet using the provided +// public passphrase. No seed or private passphrase may be provided +// since the wallet is watching-only. +func (l *Loader) CreateNewWatchingOnlyWallet(pubPassphrase []byte, + bday time.Time) (*Wallet, error) { + + return l.createNewWallet( + pubPassphrase, nil, nil, bday, true, + ) +} + +func (l *Loader) createNewWallet(pubPassphrase, privPassphrase []byte, + rootKey *hdkeychain.ExtendedKey, bday time.Time, + isWatchingOnly bool) (*Wallet, error) { + + defer l.mu.Unlock() + l.mu.Lock() + + if l.wallet != nil { + return nil, ErrLoaded + } + + exists, err := l.WalletExists() + if err != nil { + return nil, err + } + if exists { + return nil, ErrExists + } + + if l.localDB { + dbPath := filepath.Join(l.dbDirPath, WalletDBName) + + // Create the wallet database backed by bolt db. + err = os.MkdirAll(l.dbDirPath, 0700) + if err != nil { + return nil, err + } + l.db, err = walletdb.Create( + "bdb", dbPath, l.noFreelistSync, l.timeout, false, + ) + if err != nil { + return nil, err + } + } + + // Initialize the newly created database for the wallet before opening. + if isWatchingOnly { + err := CreateWatchingOnlyWithCallback( + l.db, pubPassphrase, l.chainParams, bday, + l.walletCreated, + ) + if err != nil { + return nil, err + } + } else { + err := CreateWithCallback( + l.db, pubPassphrase, privPassphrase, rootKey, + l.chainParams, bday, l.walletCreated, + ) + if err != nil { + return nil, err + } + } + + // Open the newly-created wallet. + w, err := OpenWithRetry( + l.db, pubPassphrase, nil, l.chainParams, l.recoveryWindow, + l.cfg.walletSyncRetryInterval, + ) + if err != nil { + return nil, err + } + + l.onLoaded(w) + return w, nil +} + +var errNoConsole = errors.New("db upgrade requires console access for additional input") + +func noConsole() ([]byte, error) { + return nil, errNoConsole +} + +// OpenExistingWallet opens the wallet from the loader's wallet database path +// and the public passphrase. If the loader is being called by a context where +// standard input prompts may be used during wallet upgrades, setting +// canConsolePrompt will enables these prompts. +func (l *Loader) OpenExistingWallet(pubPassphrase []byte, + canConsolePrompt bool) (*Wallet, error) { + + defer l.mu.Unlock() + l.mu.Lock() + + if l.wallet != nil { + return nil, ErrLoaded + } + + if l.localDB { + var err error + // Ensure that the network directory exists. + if err = checkCreateDir(l.dbDirPath); err != nil { + return nil, err + } + + // Open the database using the boltdb backend. + dbPath := filepath.Join(l.dbDirPath, WalletDBName) + l.db, err = walletdb.Open( + "bdb", dbPath, l.noFreelistSync, l.timeout, false, + ) + if err != nil { + log.Errorf("Failed to open database: %v", err) + return nil, err + } + } + + var cbs *waddrmgr.OpenCallbacks + if canConsolePrompt { + cbs = &waddrmgr.OpenCallbacks{ + ObtainSeed: prompt.ProvideSeed, + ObtainPrivatePass: prompt.ProvidePrivPassphrase, + } + } else { + cbs = &waddrmgr.OpenCallbacks{ + ObtainSeed: noConsole, + ObtainPrivatePass: noConsole, + } + } + w, err := OpenWithRetry( + l.db, pubPassphrase, cbs, l.chainParams, l.recoveryWindow, + l.cfg.walletSyncRetryInterval, + ) + if err != nil { + // If opening the wallet fails (e.g. because of wrong + // passphrase), we must close the backing database to + // allow future calls to walletdb.Open(). + if l.localDB { + e := l.db.Close() + if e != nil { + log.Warnf("Error closing database: %v", e) + } + } + + return nil, err + } + + w.StartDeprecated() + + l.onLoaded(w) + return w, nil +} + +// WalletExists returns whether a file exists at the loader's database path. +// This may return an error for unexpected I/O failures. +func (l *Loader) WalletExists() (bool, error) { + if l.localDB { + dbPath := filepath.Join(l.dbDirPath, WalletDBName) + return fileExists(dbPath) + } + + return l.walletExists() +} + +// LoadedWallet returns the loaded wallet, if any, and a bool for whether the +// wallet has been loaded or not. If true, the wallet pointer should be safe to +// dereference. +func (l *Loader) LoadedWallet() (*Wallet, bool) { + l.mu.Lock() + w := l.wallet + l.mu.Unlock() + return w, w != nil +} + +// UnloadWallet stops the loaded wallet, if any, and closes the wallet database. +// This returns ErrNotLoaded if the wallet has not been loaded with +// CreateNewWallet or LoadExistingWallet. The Loader may be reused if this +// function returns without error. +func (l *Loader) UnloadWallet() error { + defer l.mu.Unlock() + l.mu.Lock() + + if l.wallet == nil { + return ErrNotLoaded + } + + l.wallet.StopDeprecated() + l.wallet.WaitForShutdown() + if l.localDB { + err := l.db.Close() + if err != nil { + return err + } + } + + l.wallet = nil + l.db = nil + return nil +} + +func fileExists(filePath string) (bool, error) { + _, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/wallet/loader.go b/wallet/loader.go deleted file mode 100644 index a3e6bdb726..0000000000 --- a/wallet/loader.go +++ /dev/null @@ -1,433 +0,0 @@ -// Copyright (c) 2015-2016 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "sync" - "time" - - "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcwallet/internal/prompt" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/walletdb" -) - -const ( - // WalletDBName specified the database filename for the wallet. - WalletDBName = "wallet.db" - - // DefaultDBTimeout is the default timeout value when opening the wallet - // database. - DefaultDBTimeout = 60 * time.Second -) - -var ( - // ErrLoaded describes the error condition of attempting to load or - // create a wallet when the loader has already done so. - ErrLoaded = errors.New("wallet already loaded") - - // ErrNotLoaded describes the error condition of attempting to close a - // loaded wallet when a wallet has not been loaded. - ErrNotLoaded = errors.New("wallet is not loaded") - - // ErrExists describes the error condition of attempting to create a new - // wallet when one exists already. - ErrExists = errors.New("wallet already exists") -) - -// loaderConfig contains the configuration options for the loader. -type loaderConfig struct { - walletSyncRetryInterval time.Duration -} - -// defaultLoaderConfig returns the default configuration options for the loader. -func defaultLoaderConfig() *loaderConfig { - return &loaderConfig{ - walletSyncRetryInterval: defaultSyncRetryInterval, - } -} - -// LoaderOption is a configuration option for the loader. -type LoaderOption func(*loaderConfig) - -// WithWalletSyncRetryInterval specifies the interval at which the wallet -// should retry syncing to the chain if it encounters an error. -func WithWalletSyncRetryInterval(interval time.Duration) LoaderOption { - return func(c *loaderConfig) { - c.walletSyncRetryInterval = interval - } -} - -// Loader implements the creating of new and opening of existing wallets, while -// providing a callback system for other subsystems to handle the loading of a -// wallet. This is primarily intended for use by the RPC servers, to enable -// methods and services which require the wallet when the wallet is loaded by -// another subsystem. -// -// Loader is safe for concurrent access. -type Loader struct { - cfg *loaderConfig - callbacks []func(*Wallet) - chainParams *chaincfg.Params - dbDirPath string - noFreelistSync bool - timeout time.Duration - recoveryWindow uint32 - wallet *Wallet - localDB bool - walletExists func() (bool, error) - walletCreated func(db walletdb.ReadWriteTx) error - db walletdb.DB - mu sync.Mutex -} - -// NewLoader constructs a Loader with an optional recovery window. If the -// recovery window is non-zero, the wallet will attempt to recovery addresses -// starting from the last SyncedTo height. -func NewLoader(chainParams *chaincfg.Params, dbDirPath string, - noFreelistSync bool, timeout time.Duration, recoveryWindow uint32, - opts ...LoaderOption) *Loader { - - cfg := defaultLoaderConfig() - for _, opt := range opts { - opt(cfg) - } - - return &Loader{ - cfg: cfg, - chainParams: chainParams, - dbDirPath: dbDirPath, - noFreelistSync: noFreelistSync, - timeout: timeout, - recoveryWindow: recoveryWindow, - localDB: true, - } -} - -// NewLoaderWithDB constructs a Loader with an externally provided DB. This way -// users are free to use their own walletdb implementation (eg. leveldb, etcd) -// to store the wallet. Given that the external DB may be shared an additional -// function is also passed which will override Loader.WalletExists(). -func NewLoaderWithDB(chainParams *chaincfg.Params, recoveryWindow uint32, - db walletdb.DB, walletExists func() (bool, error), - opts ...LoaderOption) (*Loader, error) { - - if db == nil { - return nil, fmt.Errorf("no DB provided") - } - - if walletExists == nil { - return nil, fmt.Errorf("unable to check if wallet exists") - } - - cfg := defaultLoaderConfig() - for _, opt := range opts { - opt(cfg) - } - - return &Loader{ - cfg: cfg, - chainParams: chainParams, - recoveryWindow: recoveryWindow, - localDB: false, - walletExists: walletExists, - db: db, - }, nil -} - -// onLoaded executes each added callback and prevents loader from loading any -// additional wallets. Requires mutex to be locked. -func (l *Loader) onLoaded(w *Wallet) { - for _, fn := range l.callbacks { - fn(w) - } - - l.wallet = w - l.callbacks = nil // not needed anymore -} - -// RunAfterLoad adds a function to be executed when the loader creates or opens -// a wallet. Functions are executed in a single goroutine in the order they are -// added. -func (l *Loader) RunAfterLoad(fn func(*Wallet)) { - l.mu.Lock() - if l.wallet != nil { - w := l.wallet - l.mu.Unlock() - fn(w) - } else { - l.callbacks = append(l.callbacks, fn) - l.mu.Unlock() - } -} - -// OnWalletCreated adds a function that will be executed the wallet structure -// is initialized in the wallet database. This is useful if users want to add -// extra fields in the same transaction (eg. to flag wallet existence). -func (l *Loader) OnWalletCreated(fn func(walletdb.ReadWriteTx) error) { - l.mu.Lock() - defer l.mu.Unlock() - l.walletCreated = fn -} - -// CreateNewWallet creates a new wallet using the provided public and private -// passphrases. The seed is optional. If non-nil, addresses are derived from -// this seed. If nil, a secure random seed is generated. -func (l *Loader) CreateNewWallet(pubPassphrase, privPassphrase, seed []byte, - bday time.Time) (*Wallet, error) { - - var ( - rootKey *hdkeychain.ExtendedKey - err error - ) - - // If a seed was specified, we check its length now. If no seed is - // passed, the wallet will create a new random one. - if seed != nil { - if len(seed) < hdkeychain.MinSeedBytes || - len(seed) > hdkeychain.MaxSeedBytes { - - return nil, hdkeychain.ErrInvalidSeedLen - } - - // Derive the master extended key from the seed. - rootKey, err = hdkeychain.NewMaster(seed, l.chainParams) - if err != nil { - return nil, fmt.Errorf("failed to derive master " + - "extended key") - } - } - - return l.createNewWallet( - pubPassphrase, privPassphrase, rootKey, bday, false, - ) -} - -// CreateNewWalletExtendedKey creates a new wallet from an extended master root -// key using the provided public and private passphrases. The root key is -// optional. If non-nil, addresses are derived from this root key. If nil, a -// secure random seed is generated and the root key is derived from that. -func (l *Loader) CreateNewWalletExtendedKey(pubPassphrase, privPassphrase []byte, - rootKey *hdkeychain.ExtendedKey, bday time.Time) (*Wallet, error) { - - return l.createNewWallet( - pubPassphrase, privPassphrase, rootKey, bday, false, - ) -} - -// CreateNewWatchingOnlyWallet creates a new wallet using the provided -// public passphrase. No seed or private passphrase may be provided -// since the wallet is watching-only. -func (l *Loader) CreateNewWatchingOnlyWallet(pubPassphrase []byte, - bday time.Time) (*Wallet, error) { - - return l.createNewWallet( - pubPassphrase, nil, nil, bday, true, - ) -} - -func (l *Loader) createNewWallet(pubPassphrase, privPassphrase []byte, - rootKey *hdkeychain.ExtendedKey, bday time.Time, - isWatchingOnly bool) (*Wallet, error) { - - defer l.mu.Unlock() - l.mu.Lock() - - if l.wallet != nil { - return nil, ErrLoaded - } - - exists, err := l.WalletExists() - if err != nil { - return nil, err - } - if exists { - return nil, ErrExists - } - - if l.localDB { - dbPath := filepath.Join(l.dbDirPath, WalletDBName) - - // Create the wallet database backed by bolt db. - err = os.MkdirAll(l.dbDirPath, 0700) - if err != nil { - return nil, err - } - l.db, err = walletdb.Create( - "bdb", dbPath, l.noFreelistSync, l.timeout, false, - ) - if err != nil { - return nil, err - } - } - - // Initialize the newly created database for the wallet before opening. - if isWatchingOnly { - err := CreateWatchingOnlyWithCallback( - l.db, pubPassphrase, l.chainParams, bday, - l.walletCreated, - ) - if err != nil { - return nil, err - } - } else { - err := CreateWithCallback( - l.db, pubPassphrase, privPassphrase, rootKey, - l.chainParams, bday, l.walletCreated, - ) - if err != nil { - return nil, err - } - } - - // Open the newly-created wallet. - w, err := OpenWithRetry( - l.db, pubPassphrase, nil, l.chainParams, l.recoveryWindow, - l.cfg.walletSyncRetryInterval, - ) - if err != nil { - return nil, err - } - - l.onLoaded(w) - return w, nil -} - -var errNoConsole = errors.New("db upgrade requires console access for additional input") - -func noConsole() ([]byte, error) { - return nil, errNoConsole -} - -// OpenExistingWallet opens the wallet from the loader's wallet database path -// and the public passphrase. If the loader is being called by a context where -// standard input prompts may be used during wallet upgrades, setting -// canConsolePrompt will enables these prompts. -func (l *Loader) OpenExistingWallet(pubPassphrase []byte, - canConsolePrompt bool) (*Wallet, error) { - - defer l.mu.Unlock() - l.mu.Lock() - - if l.wallet != nil { - return nil, ErrLoaded - } - - if l.localDB { - var err error - // Ensure that the network directory exists. - if err = checkCreateDir(l.dbDirPath); err != nil { - return nil, err - } - - // Open the database using the boltdb backend. - dbPath := filepath.Join(l.dbDirPath, WalletDBName) - l.db, err = walletdb.Open( - "bdb", dbPath, l.noFreelistSync, l.timeout, false, - ) - if err != nil { - log.Errorf("Failed to open database: %v", err) - return nil, err - } - } - - var cbs *waddrmgr.OpenCallbacks - if canConsolePrompt { - cbs = &waddrmgr.OpenCallbacks{ - ObtainSeed: prompt.ProvideSeed, - ObtainPrivatePass: prompt.ProvidePrivPassphrase, - } - } else { - cbs = &waddrmgr.OpenCallbacks{ - ObtainSeed: noConsole, - ObtainPrivatePass: noConsole, - } - } - w, err := OpenWithRetry( - l.db, pubPassphrase, cbs, l.chainParams, l.recoveryWindow, - l.cfg.walletSyncRetryInterval, - ) - if err != nil { - // If opening the wallet fails (e.g. because of wrong - // passphrase), we must close the backing database to - // allow future calls to walletdb.Open(). - if l.localDB { - e := l.db.Close() - if e != nil { - log.Warnf("Error closing database: %v", e) - } - } - - return nil, err - } - - w.StartDeprecated() - - l.onLoaded(w) - return w, nil -} - -// WalletExists returns whether a file exists at the loader's database path. -// This may return an error for unexpected I/O failures. -func (l *Loader) WalletExists() (bool, error) { - if l.localDB { - dbPath := filepath.Join(l.dbDirPath, WalletDBName) - return fileExists(dbPath) - } - - return l.walletExists() -} - -// LoadedWallet returns the loaded wallet, if any, and a bool for whether the -// wallet has been loaded or not. If true, the wallet pointer should be safe to -// dereference. -func (l *Loader) LoadedWallet() (*Wallet, bool) { - l.mu.Lock() - w := l.wallet - l.mu.Unlock() - return w, w != nil -} - -// UnloadWallet stops the loaded wallet, if any, and closes the wallet database. -// This returns ErrNotLoaded if the wallet has not been loaded with -// CreateNewWallet or LoadExistingWallet. The Loader may be reused if this -// function returns without error. -func (l *Loader) UnloadWallet() error { - defer l.mu.Unlock() - l.mu.Lock() - - if l.wallet == nil { - return ErrNotLoaded - } - - l.wallet.StopDeprecated() - l.wallet.WaitForShutdown() - if l.localDB { - err := l.db.Close() - if err != nil { - return err - } - } - - l.wallet = nil - l.db = nil - return nil -} - -func fileExists(filePath string) (bool, error) { - _, err := os.Stat(filePath) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - return true, nil -} diff --git a/wallet/recovery.go b/wallet/recovery.go index 355ee1da73..d568a09410 100644 --- a/wallet/recovery.go +++ b/wallet/recovery.go @@ -8,196 +8,12 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) -// RecoveryManager maintains the state required to recover previously used -// addresses, and coordinates batched processing of the blocks to search. -// -// TODO(yy): Deprecated, remove. -type RecoveryManager struct { - // recoveryWindow defines the key-derivation lookahead used when - // attempting to recover the set of used addresses. - recoveryWindow uint32 - - // started is true after the first block has been added to the batch. - started bool - - // blockBatch contains a list of blocks that have not yet been searched - // for recovered addresses. - blockBatch []wtxmgr.BlockMeta - - // state encapsulates and allocates the necessary recovery state for all - // key scopes and subsidiary derivation paths. - state *RecoveryState - - // chainParams are the parameters that describe the chain we're trying - // to recover funds on. - chainParams *chaincfg.Params -} - -// NewRecoveryManager initializes a new RecoveryManager with a derivation -// look-ahead of `recoveryWindow` child indexes, and pre-allocates a backing -// array for `batchSize` blocks to scan at once. -// -// TODO(yy): Deprecated, remove. -func NewRecoveryManager(recoveryWindow, batchSize uint32, - chainParams *chaincfg.Params) *RecoveryManager { - - return &RecoveryManager{ - recoveryWindow: recoveryWindow, - blockBatch: make([]wtxmgr.BlockMeta, 0, batchSize), - chainParams: chainParams, - state: NewRecoveryState( - recoveryWindow, chainParams, nil, - ), - } -} - -// Resurrect restores all known addresses for the provided scopes that can be -// found in the walletdb namespace, in addition to restoring all outpoints that -// have been previously found. This method ensures that the recovery state's -// horizons properly start from the last found address of a prior recovery -// attempt. -// -// TODO(yy): Deprecated, remove. -func (rm *RecoveryManager) Resurrect(ns walletdb.ReadBucket, - scopedMgrs map[waddrmgr.KeyScope]waddrmgr.AccountStore, - credits []wtxmgr.Credit) error { - - // First, for each scope that we are recovering, rederive all of the - // addresses up to the last found address known to each branch. - for keyScope, scopedMgr := range scopedMgrs { - // Load the current account properties for this scope, using the - // the default account number. - // TODO(conner): rescan for all created accounts if we allow - // users to use non-default address - scopeState := rm.state.StateForScope(keyScope) - acctProperties, err := scopedMgr.AccountProperties( - ns, waddrmgr.DefaultAccountNum, - ) - if err != nil { - return err - } - - // Fetch the external key count, which bounds the indexes we - // will need to rederive. - externalCount := acctProperties.ExternalKeyCount - - // Walk through all indexes through the last external key, - // deriving each address and adding it to the external branch - // recovery state's set of addresses to look for. - for i := uint32(0); i < externalCount; i++ { - keyPath := externalKeyPath(i) - addr, err := scopedMgr.DeriveFromKeyPath(ns, keyPath) - if err != nil && err != hdkeychain.ErrInvalidChild { - return err - } else if err == hdkeychain.ErrInvalidChild { - scopeState.ExternalBranch.MarkInvalidChild(i) - continue - } - - scopeState.ExternalBranch.AddAddr(i, addr.Address()) - } - - // Fetch the internal key count, which bounds the indexes we - // will need to rederive. - internalCount := acctProperties.InternalKeyCount - - // Walk through all indexes through the last internal key, - // deriving each address and adding it to the internal branch - // recovery state's set of addresses to look for. - for i := uint32(0); i < internalCount; i++ { - keyPath := internalKeyPath(i) - addr, err := scopedMgr.DeriveFromKeyPath(ns, keyPath) - if err != nil && err != hdkeychain.ErrInvalidChild { - return err - } else if err == hdkeychain.ErrInvalidChild { - scopeState.InternalBranch.MarkInvalidChild(i) - continue - } - - scopeState.InternalBranch.AddAddr(i, addr.Address()) - } - - // The key counts will point to the next key that can be - // derived, so we subtract one to point to last known key. If - // the key count is zero, then no addresses have been found. - if externalCount > 0 { - scopeState.ExternalBranch.ReportFound(externalCount - 1) - } - if internalCount > 0 { - scopeState.InternalBranch.ReportFound(internalCount - 1) - } - } - - // In addition, we will re-add any outpoints that are known the wallet - // to our global set of watched outpoints, so that we can watch them for - // spends. - for _, credit := range credits { - _, addrs, _, err := txscript.ExtractPkScriptAddrs( - credit.PkScript, rm.chainParams, - ) - if err != nil { - return err - } - - rm.state.AddWatchedOutPoint(&credit.OutPoint, addrs[0]) - } - - return nil -} - -// AddToBlockBatch appends the block information, consisting of hash and height, -// to the batch of blocks to be searched. -// -// TODO(yy): Deprecated, remove. -func (rm *RecoveryManager) AddToBlockBatch(hash *chainhash.Hash, height int32, - timestamp time.Time) { - - if !rm.started { - log.Infof("Seed birthday surpassed, starting recovery "+ - "of wallet from height=%d hash=%v with "+ - "recovery-window=%d", height, *hash, rm.recoveryWindow) - rm.started = true - } - - block := wtxmgr.BlockMeta{ - Block: wtxmgr.Block{ - Hash: *hash, - Height: height, - }, - Time: timestamp, - } - rm.blockBatch = append(rm.blockBatch, block) -} - -// BlockBatch returns a buffer of blocks that have not yet been searched. -// -// TODO(yy): Deprecated, remove. -func (rm *RecoveryManager) BlockBatch() []wtxmgr.BlockMeta { - return rm.blockBatch -} - -// ResetBlockBatch resets the internal block buffer to conserve memory. -// -// TODO(yy): Deprecated, remove. -func (rm *RecoveryManager) ResetBlockBatch() { - rm.blockBatch = rm.blockBatch[:0] -} - -// State returns the current RecoveryState. -// -// TODO(yy): Deprecated, remove. -func (rm *RecoveryManager) State() *RecoveryState { - return rm.state -} - // RecoveryState manages the initialization and lookup of ScopeRecoveryStates // for any actively used key scopes. // @@ -760,32 +576,6 @@ func (rs *RecoveryState) expandHorizons() (bool, error) { return expanded, nil } -// ScopeRecoveryState is used to manage the recovery of addresses generated -// under a particular BIP32 account. Each account tracks both an external and -// internal branch recovery state, both of which use the same recovery window. -// -// TODO(yy): Deprecated, remove. -type ScopeRecoveryState struct { - // ExternalBranch is the recovery state of addresses generated for - // external use, i.e. receiving addresses. - ExternalBranch *BranchRecoveryState - - // InternalBranch is the recovery state of addresses generated for - // internal use, i.e. change addresses. - InternalBranch *BranchRecoveryState -} - -// NewScopeRecoveryState initializes an ScopeRecoveryState with the chosen -// recovery window. -// -// TODO(yy): Deprecated, remove. -func NewScopeRecoveryState(recoveryWindow uint32) *ScopeRecoveryState { - return &ScopeRecoveryState{ - ExternalBranch: NewBranchRecoveryState(recoveryWindow, nil), - InternalBranch: NewBranchRecoveryState(recoveryWindow, nil), - } -} - // BranchRecoveryState maintains the required state in-order to properly // recover addresses derived from a particular account's internal or external // derivation branch. diff --git a/wallet/rescan.go b/wallet/rescan.go deleted file mode 100644 index b2e8775354..0000000000 --- a/wallet/rescan.go +++ /dev/null @@ -1,313 +0,0 @@ -// Copyright (c) 2013-2017 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/chain" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/wtxmgr" -) - -// RescanProgressMsg reports the current progress made by a rescan for a -// set of wallet addresses. -type RescanProgressMsg struct { - Addresses []btcutil.Address - Notification chain.RescanProgress -} - -// RescanFinishedMsg reports the addresses that were rescanned when a -// rescanfinished message was received rescanning a batch of addresses. -type RescanFinishedMsg struct { - Addresses []btcutil.Address - Notification *chain.RescanFinished -} - -// RescanJob is a job to be processed by the RescanManager. The job includes -// a set of wallet addresses, a starting height to begin the rescan, and -// outpoints spendable by the addresses thought to be unspent. After the -// rescan completes, the error result of the rescan RPC is sent on the Err -// channel. -type RescanJob struct { - InitialSync bool - Addrs []btcutil.Address - OutPoints map[wire.OutPoint]btcutil.Address - BlockStamp waddrmgr.BlockStamp - err chan error -} - -// rescanBatch is a collection of one or more RescanJobs that were merged -// together before a rescan is performed. -type rescanBatch struct { - initialSync bool - addrs []btcutil.Address - outpoints map[wire.OutPoint]btcutil.Address - bs waddrmgr.BlockStamp - errChans []chan error -} - -// SubmitRescan submits a RescanJob to the RescanManager. A channel is -// returned with the final error of the rescan. The channel is buffered -// and does not need to be read to prevent a deadlock. -func (w *Wallet) SubmitRescan(job *RescanJob) <-chan error { - errChan := make(chan error, 1) - job.err = errChan - select { - case w.rescanAddJob <- job: - case <-w.quitChan(): - errChan <- ErrWalletShuttingDown - } - return errChan -} - -// batch creates the rescanBatch for a single rescan job. -func (job *RescanJob) batch() *rescanBatch { - return &rescanBatch{ - initialSync: job.InitialSync, - addrs: job.Addrs, - outpoints: job.OutPoints, - bs: job.BlockStamp, - errChans: []chan error{job.err}, - } -} - -// merge merges the work from k into j, setting the starting height to -// the minimum of the two jobs. This method does not check for -// duplicate addresses or outpoints. -func (b *rescanBatch) merge(job *RescanJob) { - if job.InitialSync { - b.initialSync = true - } - b.addrs = append(b.addrs, job.Addrs...) - - for op, addr := range job.OutPoints { - b.outpoints[op] = addr - } - - if job.BlockStamp.Height < b.bs.Height { - b.bs = job.BlockStamp - } - b.errChans = append(b.errChans, job.err) -} - -// done iterates through all error channels, duplicating sending the error -// to inform callers that the rescan finished (or could not complete due -// to an error). -func (b *rescanBatch) done(err error) { - for _, c := range b.errChans { - c <- err - } -} - -// rescanBatchHandler handles incoming rescan request, serializing rescan -// submissions, and possibly batching many waiting requests together so they -// can be handled by a single rescan after the current one completes. -func (w *Wallet) rescanBatchHandler() { - defer w.wg.Done() - - var curBatch, nextBatch *rescanBatch - quit := w.quitChan() - - for { - select { - case job := <-w.rescanAddJob: - if curBatch == nil { - // Set current batch as this job and send - // request. - curBatch = job.batch() - select { - case w.rescanBatch <- curBatch: - case <-quit: - job.err <- ErrWalletShuttingDown - return - } - } else { - // Create next batch if it doesn't exist, or - // merge the job. - if nextBatch == nil { - nextBatch = job.batch() - } else { - nextBatch.merge(job) - } - } - - case n := <-w.rescanNotifications: - switch n := n.(type) { - case *chain.RescanProgress: - if curBatch == nil { - log.Warnf("Received rescan progress " + - "notification but no rescan " + - "currently running") - continue - } - select { - case w.rescanProgress <- &RescanProgressMsg{ - Addresses: curBatch.addrs, - Notification: *n, - }: - case <-quit: - for _, errChan := range curBatch.errChans { - errChan <- ErrWalletShuttingDown - } - return - } - - case *chain.RescanFinished: - if curBatch == nil { - log.Warnf("Received rescan finished " + - "notification but no rescan " + - "currently running") - continue - } - select { - case w.rescanFinished <- &RescanFinishedMsg{ - Addresses: curBatch.addrs, - Notification: n, - }: - case <-quit: - for _, errChan := range curBatch.errChans { - errChan <- ErrWalletShuttingDown - } - return - } - - curBatch, nextBatch = nextBatch, nil - - if curBatch != nil { - select { - case w.rescanBatch <- curBatch: - case <-quit: - for _, errChan := range curBatch.errChans { - errChan <- ErrWalletShuttingDown - } - return - } - } - - default: - // Unexpected message - panic(n) - } - - case <-quit: - return - } - } -} - -// rescanProgressHandler handles notifications for partially and fully completed -// rescans by marking each rescanned address as partially or fully synced. -func (w *Wallet) rescanProgressHandler() { - quit := w.quitChan() -out: - for { - // These can't be processed out of order since both chans are - // unbuffured and are sent from same context (the batch - // handler). - select { - case msg := <-w.rescanProgress: - n := msg.Notification - log.Infof("Rescanned through block %v (height %d)", - n.Hash, n.Height) - - case msg := <-w.rescanFinished: - n := msg.Notification - addrs := msg.Addresses - noun := pickNoun(len(addrs), "address", "addresses") - log.Infof("Finished rescan for %d %s (synced to block "+ - "%s, height %d)", len(addrs), noun, n.Hash, - n.Height) - - go w.resendUnminedTxs() - - case <-quit: - break out - } - } - w.wg.Done() -} - -// rescanRPCHandler reads batch jobs sent by rescanBatchHandler and sends the -// RPC requests to perform a rescan. New jobs are not read until a rescan -// finishes. -func (w *Wallet) rescanRPCHandler() { - chainClient, err := w.requireChainClient() - if err != nil { - log.Errorf("rescanRPCHandler called without an RPC client") - w.wg.Done() - return - } - - quit := w.quitChan() - -out: - for { - select { - case batch := <-w.rescanBatch: - // Log the newly-started rescan. - numAddrs := len(batch.addrs) - numOps := len(batch.outpoints) - - log.Infof("Started rescan from block %v (height %d) "+ - "for %d addrs, %d outpoints", batch.bs.Hash, - batch.bs.Height, numAddrs, numOps) - - err := chainClient.Rescan( - &batch.bs.Hash, batch.addrs, batch.outpoints, - ) - if err != nil { - log.Errorf("Rescan for %d addrs, %d outpoints "+ - "failed: %v", numAddrs, numOps, err) - } - batch.done(err) - case <-quit: - break out - } - } - - w.wg.Done() -} - -// rescanWithTarget performs a rescan starting at the optional startStamp. If -// none is provided, the rescan will begin from the manager's sync tip. -func (w *Wallet) rescanWithTarget(addrs []btcutil.Address, - unspent []wtxmgr.Credit, startStamp *waddrmgr.BlockStamp) error { - - outpoints := make(map[wire.OutPoint]btcutil.Address, len(unspent)) - for _, output := range unspent { - _, outputAddrs, _, err := txscript.ExtractPkScriptAddrs( - output.PkScript, w.chainParams, - ) - if err != nil { - return err - } - - outpoints[output.OutPoint] = outputAddrs[0] - } - - // If a start block stamp was provided, we will use that as the initial - // starting point for the rescan. - if startStamp == nil { - startStamp = &waddrmgr.BlockStamp{} - *startStamp = w.addrStore.SyncedTo() - } - - job := &RescanJob{ - InitialSync: true, - Addrs: addrs, - OutPoints: outpoints, - BlockStamp: *startStamp, - } - - // Submit merged job and block until rescan completes. - select { - case err := <-w.SubmitRescan(job): - return err - case <-w.quitChan(): - return ErrWalletShuttingDown - } -} diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index a38ffa34fa..c09267a907 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -549,14 +549,3 @@ func (w *Wallet) ListLeasedOutputs( return leasedOutputs, err } - -// LockedOutpoint returns whether an outpoint has been marked as locked and -// should not be used as an input for created transactions. -func (w *Wallet) LockedOutpoint(op wire.OutPoint) bool { - w.lockedOutpointsMtx.Lock() - defer w.lockedOutpointsMtx.Unlock() - - _, locked := w.lockedOutpoints[op] - - return locked -} diff --git a/wallet/wallet.go b/wallet/wallet.go index 50b2a43a29..e6bffe4767 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -21,12 +21,10 @@ import ( "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" - "github.com/btcsuite/btcwallet/walletdb/migration" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -345,22 +343,6 @@ type Wallet struct { birthdayBlock waddrmgr.BlockStamp } -// AccountAddresses returns the addresses for every created address for an -// account. - -// ChainParams returns the network parameters for the blockchain the wallet -// belongs to. -func (w *Wallet) ChainParams() *chaincfg.Params { - return w.chainParams -} - -// Database returns the underlying walletdb database. This method is provided -// in order to allow applications wrapping btcwallet to store app-specific data -// with the wallet's database. -func (w *Wallet) Database() walletdb.DB { - return w.db -} - // RemoveDescendants attempts to remove any transaction from the wallet's tx // store (that may be unconfirmed) that spends outputs created by the passed // transaction. This remove propagates recursively down the chain of descendent @@ -546,98 +528,3 @@ func calcConf(txHeight, curHeight int32) int32 { return curHeight - txHeight + 1 } } - -// Open loads an already-created wallet from the passed database and namespaces. -func Open(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, - params *chaincfg.Params, recoveryWindow uint32) (*Wallet, error) { - - return OpenWithRetry( - db, pubPass, cbs, params, recoveryWindow, - defaultSyncRetryInterval, - ) -} - -// OpenWithRetry loads an already-created wallet from the passed database and -// namespaces and re-tries on errors during initial sync. -func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, - params *chaincfg.Params, recoveryWindow uint32, - syncRetryInterval time.Duration) (*Wallet, error) { - - var ( - addrMgr *waddrmgr.Manager - txMgr *wtxmgr.Store - ) - - // Before attempting to open the wallet, we'll check if there are any - // database upgrades for us to proceed. We'll also create our references - // to the address and transaction managers, as they are backed by the - // database. - err := walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { - addrMgrBucket := tx.ReadWriteBucket(waddrmgrNamespaceKey) - if addrMgrBucket == nil { - return errors.New("missing address manager namespace") - } - txMgrBucket := tx.ReadWriteBucket(wtxmgrNamespaceKey) - if txMgrBucket == nil { - return errors.New("missing transaction manager namespace") - } - - addrMgrUpgrader := waddrmgr.NewMigrationManager(addrMgrBucket) - txMgrUpgrader := wtxmgr.NewMigrationManager(txMgrBucket) - err := migration.Upgrade(txMgrUpgrader, addrMgrUpgrader) - if err != nil { - return err - } - - addrMgr, err = waddrmgr.Open(addrMgrBucket, pubPass, params) - if err != nil { - return err - } - txMgr, err = wtxmgr.Open(txMgrBucket, params) - if err != nil { - return err - } - - return nil - }) - if err != nil { - return nil, err - } - - log.Infof("Opened wallet") // TODO: log balance? last sync height? - - deprecated := &walletDeprecated{ - lockedOutpoints: map[wire.OutPoint]struct{}{}, - publicPassphrase: pubPass, - db: db, - recoveryWindow: recoveryWindow, - rescanAddJob: make(chan *RescanJob), - rescanBatch: make(chan *rescanBatch), - rescanNotifications: make(chan interface{}), - rescanProgress: make(chan *RescanProgressMsg), - rescanFinished: make(chan *RescanFinishedMsg), - createTxRequests: make(chan createTxRequest), - unlockRequests: make(chan unlockRequest), - lockRequests: make(chan struct{}), - holdUnlockRequests: make(chan chan heldUnlock), - lockState: make(chan bool), - changePassphrase: make(chan changePassphraseRequest), - changePassphrases: make(chan changePassphrasesRequest), - chainParams: params, - quit: make(chan struct{}), - syncRetryInterval: syncRetryInterval, - } - - w := &Wallet{ - addrStore: addrMgr, - txStore: txMgr, - walletDeprecated: deprecated, - } - - w.NtfnServer = newNotificationServer(w) - txMgr.NotifyUnspent = func(hash *chainhash.Hash, index uint32) { - w.NtfnServer.notifyUnspentOutput(0, hash, index) - } - - return w, nil -} From d3524986793f8a0438a639de7262681b4edcd23c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 24 Jan 2026 15:15:13 +0800 Subject: [PATCH 290/691] wallet: remove the usage of `ImportAccountDeprecated` We now complete the implementation of `ImportAccount` by removing the usage of the deprecated methods. We also identified a few methods that should not be deprecated. --- wallet/account_manager.go | 205 ++++++++++++++++++++++++++++++++++++-- wallet/deprecated.go | 75 +------------- wallet/wallet.go | 5 + 3 files changed, 200 insertions(+), 85 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 89c5b0ee67..7f53f58756 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -11,12 +11,17 @@ package wallet import ( "context" + "encoding/binary" + "errors" + "fmt" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/netparams" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" @@ -596,10 +601,18 @@ func (w *Wallet) balanceForUTXO(addrmgrNs walletdb.ReadBucket, return 0 } -// ImportAccount imports an account from an extended public or private key. +// ImportAccount imports an account from an extended public or private key. The +// key scope is derived from the version bytes of the extended key. The account +// name must be unique within the derived scope. If dryRun is true, the import +// is validated but not persisted. // -// The time complexity of this method is dominated by the database lookup -// to ensure the account name is unique within the scope. +// The time complexity of this method is dominated by the database lookup to +// ensure the account name is unique within the scope. +// +// TODO(yy): we will move the db operation to a dedicated method, so we can +// ignore cyclop for now. +// +//nolint:cyclop func (w *Wallet) ImportAccount(_ context.Context, name string, accountKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, addrType waddrmgr.AddressType, @@ -610,19 +623,189 @@ func (w *Wallet) ImportAccount(_ context.Context, return nil, err } + // Ensure we have a valid account public key. We require an account-level + // key (depth 3) to properly manage the derivation path. + err = validateExtendedPubKey(accountKey, true, w.cfg.ChainParams) + if err != nil { + return nil, err + } + + // Determine what key scope the account public key should belong to and + // whether it should use a custom address schema. This is inferred from + // the key's HD version bytes. + keyScope, addrSchema, err := keyScopeFromPubKey(accountKey, &addrType) + if err != nil { + return nil, err + } + var props *waddrmgr.AccountProperties - if dryRun { - props, _, _, err = w.ImportAccountDryRun( - name, accountKey, masterKeyFingerprint, &addrType, 0, - ) - } else { - props, err = w.ImportAccountDeprecated( - name, accountKey, masterKeyFingerprint, &addrType, + // We'll perform the import within a database update transaction to ensure + // atomicity. If dryRun is enabled, we'll return a special error at the end + // to trigger a rollback. + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + // Check if a manager for this key scope already exists. If not, we'll + // create a new one using the inferred schema. + scopedMgr, err := w.addrStore.FetchScopedKeyManager(keyScope) + if err != nil { + scopedMgr, err = w.addrStore.NewScopedKeyManager( + ns, keyScope, *addrSchema, + ) + if err != nil { + return err + } + } + + // Create the new watching-only account using the provided key. Since we + // only have the public key, the wallet won't be able to sign for this + // account unless the private key is also provided later. + account, err := scopedMgr.NewAccountWatchingOnly( + ns, name, accountKey, masterKeyFingerprint, addrSchema, ) + if err != nil { + return err + } + + // Retrieve the properties for the newly created account. + props, err = scopedMgr.AccountProperties(ns, account) + if !dryRun { + return err + } + + // If this is a dry-run, we'll generate a few addresses to simulate the + // import process and then roll back. + props, err = importAccountDryRun(ns, props, scopedMgr) + if err != nil { + return err + } + + // Make sure we always roll back the dry-run transaction by returning an + // error here. + return walletdb.ErrDryRunRollBack + }) + + // If this was a dry-run, we ignore the rollback error. + if err != nil && !dryRun && !errors.Is(err, walletdb.ErrDryRunRollBack) { + return nil, err } - return props, err + return props, nil +} + +// importAccountDryRun simulates an account import by generating a single +// address for both the internal and external derivation branches. This ensures +// that the provided account key is valid and can be used to derive addresses. +// The changes made during this simulation are rolled back by the caller. +func importAccountDryRun(ns walletdb.ReadWriteBucket, + props *waddrmgr.AccountProperties, scopedMgr waddrmgr.AccountStore) ( + *waddrmgr.AccountProperties, error) { + + // The importAccount method above will cache the imported account within the + // scoped manager. Since this is a dry-run attempt, we'll want to invalidate + // the cache for it. + defer scopedMgr.InvalidateAccountCache(props.AccountNumber) + + _, err := scopedMgr.NextExternalAddresses(ns, props.AccountNumber, 1) + if err != nil { + return nil, err + } + + _, err = scopedMgr.NextInternalAddresses(ns, props.AccountNumber, 1) + if err != nil { + return nil, err + } + + // Refresh the account's properties after generating the addresses. + props, err = scopedMgr.AccountProperties(ns, props.AccountNumber) + if err != nil { + return nil, err + } + + return props, nil +} + +// validateExtendedPubKey ensures a sane derived public key is provided. +func validateExtendedPubKey(pubKey *hdkeychain.ExtendedKey, + isAccountKey bool, chainParams *chaincfg.Params) error { + + // Private keys are not allowed. + if pubKey.IsPrivate() { + return fmt.Errorf("%w: private keys cannot be imported", + ErrInvalidAccountKey) + } + + // The public key must have a version corresponding to the current + // chain. + if !isPubKeyForNet(pubKey, chainParams) { + return fmt.Errorf("%w: expected extended public key for current "+ + "network %v", ErrInvalidAccountKey, chainParams.Name) + } + + // Verify the extended public key's depth and child index based on + // whether it's an account key or not. + if isAccountKey { + if pubKey.Depth() != accountPubKeyDepth { + return fmt.Errorf("%w: must be of the form "+ + "m/purpose'/coin_type'/account'", ErrInvalidAccountKey) + } + + if pubKey.ChildIndex() < hdkeychain.HardenedKeyStart { + return fmt.Errorf("%w: must be hardened", ErrInvalidAccountKey) + } + + return nil + } + + if pubKey.Depth() != pubKeyDepth { + return fmt.Errorf("%w: must be of the form "+ + "m/purpose'/coin_type'/account'/change/address_index", + ErrInvalidAccountKey) + } + + if pubKey.ChildIndex() >= hdkeychain.HardenedKeyStart { + return fmt.Errorf("%w: must not be hardened", ErrInvalidAccountKey) + } + + return nil +} + +// isPubKeyForNet determines if the given public key is for the current network +// the wallet is operating under. +// +// Ignore exhaustive linter as the `wire.SigNet` is covered by `SigNetWire`. +// +//nolint:exhaustive,cyclop +func isPubKeyForNet(pubKey *hdkeychain.ExtendedKey, + chainParams *chaincfg.Params) bool { + + version := waddrmgr.HDVersion(binary.BigEndian.Uint32(pubKey.Version())) + switch chainParams.Net { + case wire.MainNet: + return version == waddrmgr.HDVersionMainNetBIP0044 || + version == waddrmgr.HDVersionMainNetBIP0049 || + version == waddrmgr.HDVersionMainNetBIP0084 + + case wire.TestNet, wire.TestNet3, wire.TestNet4, + netparams.SigNetWire(chainParams): + + return version == waddrmgr.HDVersionTestNetBIP0044 || + version == waddrmgr.HDVersionTestNetBIP0049 || + version == waddrmgr.HDVersionTestNetBIP0084 + + // For simnet, we'll also allow the mainnet versions since simnet + // doesn't have defined versions for some of our key scopes, and the + // mainnet versions are usually used as the default regardless of the + // network/key scope. + case wire.SimNet: + return version == waddrmgr.HDVersionSimNetBIP0044 || + version == waddrmgr.HDVersionMainNetBIP0049 || + version == waddrmgr.HDVersionMainNetBIP0084 + + default: + return false + } } // extractAddrFromPKScript extracts an address from a public key script. If the diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 1f412aae04..9c9ef15fce 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -26,7 +26,6 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/internal/prompt" - "github.com/btcsuite/btcwallet/netparams" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" @@ -5745,78 +5744,6 @@ func keyScopeFromPubKey(pubKey *hdkeychain.ExtendedKey, } } -// isPubKeyForNet determines if the given public key is for the current network -// the wallet is operating under. -func (w *Wallet) isPubKeyForNet(pubKey *hdkeychain.ExtendedKey) bool { - version := waddrmgr.HDVersion(binary.BigEndian.Uint32(pubKey.Version())) - switch w.chainParams.Net { - case wire.MainNet: - return version == waddrmgr.HDVersionMainNetBIP0044 || - version == waddrmgr.HDVersionMainNetBIP0049 || - version == waddrmgr.HDVersionMainNetBIP0084 - - case wire.TestNet, wire.TestNet3, wire.TestNet4, - netparams.SigNetWire(w.chainParams): - - return version == waddrmgr.HDVersionTestNetBIP0044 || - version == waddrmgr.HDVersionTestNetBIP0049 || - version == waddrmgr.HDVersionTestNetBIP0084 - - // For simnet, we'll also allow the mainnet versions since simnet - // doesn't have defined versions for some of our key scopes, and the - // mainnet versions are usually used as the default regardless of the - // network/key scope. - case wire.SimNet: - return version == waddrmgr.HDVersionSimNetBIP0044 || - version == waddrmgr.HDVersionMainNetBIP0049 || - version == waddrmgr.HDVersionMainNetBIP0084 - - default: - return false - } -} - -// validateExtendedPubKey ensures a sane derived public key is provided. -func (w *Wallet) validateExtendedPubKey(pubKey *hdkeychain.ExtendedKey, - isAccountKey bool) error { - - // Private keys are not allowed. - if pubKey.IsPrivate() { - return errors.New("private keys cannot be imported") - } - - // The public key must have a version corresponding to the current - // chain. - if !w.isPubKeyForNet(pubKey) { - return fmt.Errorf("expected extended public key for current "+ - "network %v", w.chainParams.Name) - } - - // Verify the extended public key's depth and child index based on - // whether it's an account key or not. - if isAccountKey { - if pubKey.Depth() != accountPubKeyDepth { - return errors.New("invalid account key, must be of the " + - "form m/purpose'/coin_type'/account'") - } - if pubKey.ChildIndex() < hdkeychain.HardenedKeyStart { - return errors.New("invalid account key, must be hardened") - } - } else { - if pubKey.Depth() != pubKeyDepth { - return errors.New("invalid account key, must be of the " + - "form m/purpose'/coin_type'/account'/change/" + - "address_index") - } - if pubKey.ChildIndex() >= hdkeychain.HardenedKeyStart { - return errors.New("invalid pulic key, must not be " + - "hardened") - } - } - - return nil -} - // ImportAccountDeprecated imports an account backed by an account extended // public key. // The master key fingerprint denotes the fingerprint of the root key @@ -5885,7 +5812,7 @@ func (w *Wallet) importAccount(ns walletdb.ReadWriteBucket, name string, addrType *waddrmgr.AddressType) (*waddrmgr.AccountProperties, error) { // Ensure we have a valid account public key. - if err := w.validateExtendedPubKey(accountPubKey, true); err != nil { + if err := validateExtendedPubKey(accountPubKey, true, w.chainParams); err != nil { return nil, err } diff --git a/wallet/wallet.go b/wallet/wallet.go index e6bffe4767..12c4b507d4 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -82,6 +82,11 @@ var ( ErrNoAssocPrivateKey = errors.New("address does not have an " + "associated private key") + // ErrInvalidAccountKey is returned when the provided extended public key + // does not meet the requirements for an account key (e.g., wrong depth + // or not hardened). + ErrInvalidAccountKey = errors.New("invalid account key") + // Namespace bucket keys. waddrmgrNamespaceKey = []byte("waddrmgr") wtxmgrNamespaceKey = []byte("wtxmgr") From 6824f81f17b86485b2eb9741bc2d878fc33f81d7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 23 Jan 2026 21:13:30 +0800 Subject: [PATCH 291/691] wallet: update unit tests to use the new wallet We now remove the usage of the old `testWallet`. --- wallet/account_manager_test.go | 872 +++++++++++++++++++++++++-------- wallet/address_manager_test.go | 469 +++++++++++++++--- wallet/common_test.go | 106 +++- wallet/mock_test.go | 34 ++ wallet/psbt_manager_test.go | 257 +++++----- wallet/signer_test.go | 91 ++-- wallet/tx_creator_test.go | 59 ++- wallet/tx_publisher_test.go | 31 +- wallet/tx_reader_test.go | 27 +- wallet/tx_writer_test.go | 4 +- wallet/utxo_manager_test.go | 32 +- 11 files changed, 1449 insertions(+), 533 deletions(-) diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 26e82bf489..92d01c0ff0 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -6,16 +6,15 @@ package wallet import ( "testing" - "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -29,14 +28,25 @@ func TestNewAccount(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // We'll start by creating a new account under the BIP0084 scope. We // expect this to succeed. scope := waddrmgr.KeyScopeBIP0084 - account, err := w.NewAccount( - t.Context(), scope, testAccountName, - ) + + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, testAccountName). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: testAccountName, + }, nil).Once() + + account, err := w.NewAccount(t.Context(), scope, testAccountName) require.NoError(t, err, "unable to create new account") // The new account should be the first account created, so it should @@ -44,18 +54,57 @@ func TestNewAccount(t *testing.T) { require.Equal(t, uint32(1), account.AccountNumber, "expected account 1") // We should be able to retrieve the account by its name. - _, err = w.AccountName(scope, account.AccountNumber) + deps.txStore.On("UnspentOutputs", mock.Anything). + Return([]wtxmgr.Credit(nil), nil).Once() + + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LookupAccount", mock.Anything, testAccountName). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: testAccountName, + }, nil).Once() + + account2, err := w.GetAccount(t.Context(), scope, testAccountName) require.NoError(t, err, "unable to retrieve account") + require.Equal(t, uint32(1), account2.AccountNumber) + require.Equal(t, testAccountName, account2.AccountName) // We should not be able to create a new account with the same name. + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, testAccountName). + Return(uint32(0), waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrDuplicateAccount, + }).Once() + _, err = w.NewAccount(t.Context(), scope, testAccountName) require.Error(t, err, "expected error when creating duplicate account") + require.True( + t, waddrmgr.IsError(err, waddrmgr.ErrDuplicateAccount), + "expected ErrDuplicateAccount", + ) // We should not be able to create a new account when the wallet is // locked. - err = w.addrStore.Lock() + deps.addrStore.On("Lock").Return(nil).Once() + + err = w.Lock(t.Context()) require.NoError(t, err) + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("CanAddAccount"). + Return(waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrLocked, + }).Once() + _, err = w.NewAccount(t.Context(), scope, "test2") require.Error( t, err, "expected error when creating account while wallet is "+ @@ -68,21 +117,53 @@ func TestListAccounts(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // We'll start by creating a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, testAccountName). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: testAccountName, + }, nil).Once() + _, err := w.NewAccount(t.Context(), scope, testAccountName) require.NoError(t, err, "unable to create new account") + // Setup expectations for ListAccounts. + deps.addrStore.On("ActiveScopedKeyManagers"). + Return([]waddrmgr.AccountStore{deps.accountManager}).Once() + + deps.accountManager.On("Scope").Return(scope).Once() + deps.accountManager.On("LastAccount", mock.Anything). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(0)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + }, nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: testAccountName, + }, nil).Once() + + deps.txStore.On("UnspentOutputs", mock.Anything). + Return([]wtxmgr.Credit(nil), nil).Once() + // Now, we'll list all accounts and check that we have the default // account and the new account. accounts, err := w.ListAccounts(t.Context()) require.NoError(t, err, "unable to list accounts") - // We should have five accounts, the four default accounts and the new - // account. - require.Len(t, accounts.Accounts, 5, "expected five accounts") + // We should have two accounts. + require.Len(t, accounts.Accounts, 2, "expected two accounts") // The first account should be the default account. require.Equal( @@ -99,23 +180,14 @@ func TestListAccounts(t *testing.T) { ) // The new account should also be present. - var found bool - for _, acc := range accounts.Accounts { - if acc.AccountName == testAccountName { - found = true - - require.Equal( - t, uint32(1), acc.AccountNumber, - "expected new account number", - ) - require.Equal( - t, btcutil.Amount(0), acc.TotalBalance, - "expected zero balance for new account", - ) - } - } - - require.True(t, found, "expected to find new account") + require.Equal( + t, testAccountName, accounts.Accounts[1].AccountName, + "expected new account", + ) + require.Equal( + t, uint32(1), accounts.Accounts[1].AccountNumber, + "expected new account number", + ) } // TestListAccountsByScope tests that the ListAccountsByScope method works as @@ -124,26 +196,70 @@ func TestListAccountsByScope(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // We'll create two new accounts, one under the BIP0084 scope and one // under the BIP0049 scope. scopeBIP84 := waddrmgr.KeyScopeBIP0084 accBIP84Name := "test bip84" + + deps.addrStore.On("FetchScopedKeyManager", scopeBIP84). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, accBIP84Name). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: accBIP84Name, + }, nil).Once() + _, err := w.NewAccount(t.Context(), scopeBIP84, accBIP84Name) require.NoError(t, err) scopeBIP49 := waddrmgr.KeyScopeBIP0049Plus accBIP49Name := "test bip49" + + deps.addrStore.On("FetchScopedKeyManager", scopeBIP49). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, accBIP49Name). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: accBIP49Name, + }, nil).Once() + _, err = w.NewAccount(t.Context(), scopeBIP49, accBIP49Name) require.NoError(t, err) + // Mock expectations for ListAccountsByScope (BIP84). + deps.addrStore.On("FetchScopedKeyManager", scopeBIP84). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LastAccount", mock.Anything). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(0)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + }, nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: accBIP84Name, + }, nil).Once() + + deps.txStore.On("UnspentOutputs", mock.Anything). + Return([]wtxmgr.Credit(nil), nil).Once() + // Now, we'll list the accounts for the BIP0084 scope and check that // we only get the default account for that scope and the new account we // created. - accountsBIP84, err := w.ListAccountsByScope( - t.Context(), scopeBIP84, - ) + accountsBIP84, err := w.ListAccountsByScope(t.Context(), scopeBIP84) require.NoError(t, err) // We should have two accounts, the default account and the new account. @@ -157,10 +273,28 @@ func TestListAccountsByScope(t *testing.T) { require.Equal(t, accBIP84Name, accountsBIP84.Accounts[1].AccountName) require.Equal(t, uint32(1), accountsBIP84.Accounts[1].AccountNumber) + // Mock expectations for ListAccountsByScope (BIP49). + deps.addrStore.On("FetchScopedKeyManager", scopeBIP49). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LastAccount", mock.Anything). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(0)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + }, nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: accBIP49Name, + }, nil).Once() + + deps.txStore.On("UnspentOutputs", mock.Anything). + Return([]wtxmgr.Credit(nil), nil).Once() + // Now, we'll do the same for the BIP0049 scope. - accountsBIP49, err := w.ListAccountsByScope( - t.Context(), scopeBIP49, - ) + accountsBIP49, err := w.ListAccountsByScope(t.Context(), scopeBIP49) require.NoError(t, err) // We should have two accounts, the default account and the new account. @@ -181,26 +315,63 @@ func TestListAccountsByName(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // We'll create two new accounts, one under the BIP0084 scope and one // under the BIP0049 scope. scopeBIP84 := waddrmgr.KeyScopeBIP0084 accBIP84Name := "test bip84" + + deps.addrStore.On("FetchScopedKeyManager", scopeBIP84). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, accBIP84Name). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: accBIP84Name, + }, nil).Once() + _, err := w.NewAccount(t.Context(), scopeBIP84, accBIP84Name) require.NoError(t, err) scopeBIP49 := waddrmgr.KeyScopeBIP0049Plus accBIP49Name := "test bip49" + + deps.addrStore.On("FetchScopedKeyManager", scopeBIP49). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, accBIP49Name). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: accBIP49Name, + }, nil).Once() + _, err = w.NewAccount(t.Context(), scopeBIP49, accBIP49Name) require.NoError(t, err) + // Mock expectations for ListAccountsByName (BIP84 name). + deps.addrStore.On("ActiveScopedKeyManagers"). + Return([]waddrmgr.AccountStore{deps.accountManager}).Once() + deps.accountManager.On("Scope").Return(scopeBIP84).Maybe() + deps.accountManager.On("LookupAccount", mock.Anything, accBIP84Name). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: accBIP84Name, + }, nil).Once() + + deps.txStore.On("UnspentOutputs", mock.Anything). + Return([]wtxmgr.Credit(nil), nil).Times(3) + // Now, we'll list the accounts for the BIP0084 scope and check that // we only get the default account for that scope and the new account we // created. - accountsBIP84, err := w.ListAccountsByName( - t.Context(), accBIP84Name, - ) + accountsBIP84, err := w.ListAccountsByName(t.Context(), accBIP84Name) require.NoError(t, err) // We should have one account. @@ -210,10 +381,20 @@ func TestListAccountsByName(t *testing.T) { require.Equal(t, accBIP84Name, accountsBIP84.Accounts[0].AccountName) require.Equal(t, uint32(1), accountsBIP84.Accounts[0].AccountNumber) + // Mock expectations for ListAccountsByName (BIP49 name). + deps.addrStore.On("ActiveScopedKeyManagers"). + Return([]waddrmgr.AccountStore{deps.accountManager}).Once() + deps.accountManager.On("Scope").Return(scopeBIP49).Maybe() + deps.accountManager.On("LookupAccount", mock.Anything, accBIP49Name). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: accBIP49Name, + }, nil).Once() + // Now, we'll do the same for the BIP0049 scope. - accountsBIP49, err := w.ListAccountsByName( - t.Context(), accBIP49Name, - ) + accountsBIP49, err := w.ListAccountsByName(t.Context(), accBIP49Name) require.NoError(t, err) // We should have one account. @@ -223,11 +404,18 @@ func TestListAccountsByName(t *testing.T) { require.Equal(t, accBIP49Name, accountsBIP49.Accounts[0].AccountName) require.Equal(t, uint32(1), accountsBIP49.Accounts[0].AccountNumber) + // Mock expectations for non-existent account. + deps.addrStore.On("ActiveScopedKeyManagers"). + Return([]waddrmgr.AccountStore{deps.accountManager}).Once() + deps.accountManager.On("Scope").Return(scopeBIP84).Maybe() + deps.accountManager.On("LookupAccount", mock.Anything, "non-existent"). + Return(uint32(0), waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAccountNotFound, + }).Once() + // We should get an empty result if we query for a non-existent // account. - accounts, err := w.ListAccountsByName( - t.Context(), "non-existent", - ) + accounts, err := w.ListAccountsByName(t.Context(), "non-existent") require.NoError(t, err) require.Empty(t, accounts.Accounts) } @@ -237,13 +425,38 @@ func TestGetAccount(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, testAccountName). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: testAccountName, + }, nil).Once() + _, err := w.NewAccount(t.Context(), scope, testAccountName) require.NoError(t, err) + // Mock expectations for GetAccount (success). + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("LookupAccount", mock.Anything, testAccountName). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: testAccountName, + }, nil).Once() + + deps.txStore.On("UnspentOutputs", mock.Anything). + Return([]wtxmgr.Credit(nil), nil).Twice() + // We should be able to get the new account. account, err := w.GetAccount(t.Context(), scope, testAccountName) require.NoError(t, err) @@ -251,6 +464,17 @@ func TestGetAccount(t *testing.T) { require.Equal(t, uint32(1), account.AccountNumber) require.Equal(t, btcutil.Amount(0), account.TotalBalance) + // Mock expectations for GetAccount (default account). + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("LookupAccount", mock.Anything, "default"). + Return(uint32(0), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(0)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + }, nil).Once() + // We should also be able to get the default account. account, err = w.GetAccount(t.Context(), scope, "default") require.NoError(t, err) @@ -258,6 +482,14 @@ func TestGetAccount(t *testing.T) { require.Equal(t, uint32(0), account.AccountNumber) require.Equal(t, btcutil.Amount(0), account.TotalBalance) + // Mock expectations for GetAccount (error path). + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("LookupAccount", mock.Anything, "non-existent"). + Return(uint32(0), waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAccountNotFound, + }).Once() + // We should get an error when trying to get a non-existent account. _, err = w.GetAccount(t.Context(), scope, "non-existent") require.Error(t, err) @@ -272,24 +504,66 @@ func TestRenameAccount(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 oldName := "old name" newName := "new name" + + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, oldName). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: oldName, + }, nil).Once() + _, err := w.NewAccount(t.Context(), scope, oldName) require.NoError(t, err) + // Mock expectations for RenameAccount. + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("LookupAccount", mock.Anything, oldName). + Return(uint32(1), nil).Once() + deps.accountManager.On("RenameAccount", mock.Anything, uint32(1), newName). + Return(nil).Once() + // We should be able to rename the account. err = w.RenameAccount(t.Context(), scope, oldName, newName) require.NoError(t, err) + // Mock expectations for GetAccount (new name). + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("LookupAccount", mock.Anything, newName). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: newName, + }, nil).Once() + + deps.txStore.On("UnspentOutputs", mock.Anything). + Return([]wtxmgr.Credit(nil), nil).Once() + // We should be able to get the account by its new name. account, err := w.GetAccount(t.Context(), scope, newName) require.NoError(t, err) require.Equal(t, newName, account.AccountName) + // Mock expectations for GetAccount (old name - fail). + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("LookupAccount", mock.Anything, oldName). + Return(uint32(0), waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAccountNotFound, + }).Once() + // We should not be able to get the account by its old name. _, err = w.GetAccount(t.Context(), scope, oldName) require.Error(t, err) @@ -298,6 +572,18 @@ func TestRenameAccount(t *testing.T) { "expected ErrAccountNotFound", ) + // Mock expectations for RenameAccount (duplicate name). + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LookupAccount", mock.Anything, newName). + Return(uint32(1), nil).Once() + deps.accountManager.On( + "RenameAccount", mock.Anything, uint32(1), "default", + ).Return(waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrDuplicateAccount, + }).Once() + // We should not be able to rename an account to an existing name. err = w.RenameAccount(t.Context(), scope, newName, "default") require.Error(t, err) @@ -306,10 +592,16 @@ func TestRenameAccount(t *testing.T) { "expected ErrDuplicateAccount", ) + // Mock expectations for RenameAccount (non-existent account). + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("LookupAccount", mock.Anything, "non-existent"). + Return(uint32(0), waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAccountNotFound, + }).Once() + // We should not be able to rename a non-existent account. - err = w.RenameAccount( - t.Context(), scope, "non-existent", "new name 2", - ) + err = w.RenameAccount(t.Context(), scope, "non-existent", "new name 2") require.Error(t, err) require.True( t, waddrmgr.IsError(err, waddrmgr.ErrAccountNotFound), @@ -322,77 +614,85 @@ func TestBalance(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // We'll create a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 + + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil). + Once() + + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, testAccountName). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: testAccountName, + }, nil).Once() + _, err := w.NewAccount(t.Context(), scope, testAccountName) require.NoError(t, err) + // Mock expectations for initial balance (0). + deps.txStore.On("UnspentOutputs", mock.Anything). + Return([]wtxmgr.Credit(nil), nil).Once() + + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LookupAccount", mock.Anything, testAccountName). + Return(uint32(1), nil).Once() + // The balance should be zero initially. - balance, err := w.Balance( - t.Context(), 1, scope, testAccountName, - ) + balance, err := w.Balance(t.Context(), 1, scope, testAccountName) require.NoError(t, err) require.Equal(t, btcutil.Amount(0), balance) // Now, we'll add a UTXO to the account. - addr, err := w.NewAddress( - t.Context(), testAccountName, waddrmgr.WitnessPubKey, false, + mockAddr, _ := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.cfg.ChainParams, ) + pkScript, err := txscript.PayToAddrScript(mockAddr) require.NoError(t, err) - pkScript, err := txscript.PayToAddrScript(addr) - require.NoError(t, err) - rec, err := wtxmgr.NewTxRecordFromMsgTx(&wire.MsgTx{ - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - { - Value: 100, - PkScript: pkScript, - }, - }, - }, time.Now()) - require.NoError(t, err) - - err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) - - err := w.txStore.InsertTx(ns, rec, &wtxmgr.BlockMeta{ - Block: wtxmgr.Block{ - Height: 1, - }, - }) - if err != nil { - return err - } - return w.txStore.AddCredit(ns, rec, &wtxmgr.BlockMeta{ - Block: wtxmgr.Block{ - Height: 1, + // Mock expectations for balance with UTXO. + deps.txStore.On("UnspentOutputs", mock.Anything).Return([]wtxmgr.Credit{ + { + Amount: 100, + PkScript: pkScript, + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: 1, + }, }, - }, 0, false) - }) - require.NoError(t, err) + }, + }, nil).Once() - // Now, we'll update the wallet's sync state. - err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket(waddrmgrNamespaceKey) + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.addrStore.On("AddrAccount", mock.Anything, mockAddr). + Return(deps.accountManager, uint32(1), nil).Once() - return w.addrStore.SetSyncedTo(addrmgrNs, &waddrmgr.BlockStamp{ - Height: 1, - }) - }) - require.NoError(t, err) + deps.accountManager.On("LookupAccount", mock.Anything, testAccountName). + Return(uint32(1), nil).Once() + deps.accountManager.On("Scope").Return(scope).Once() // The balance should now be 100. - balance, err = w.Balance( - t.Context(), 1, scope, testAccountName, - ) + balance, err = w.Balance(t.Context(), 1, scope, testAccountName) require.NoError(t, err) require.Equal(t, btcutil.Amount(100), balance) + // Mock expectations for balance of non-existent account. + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LookupAccount", mock.Anything, "non-existent"). + Return(uint32(0), waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAccountNotFound, + }).Once() + // We should get an error when trying to get the balance of a // non-existent account. _, err = w.Balance(t.Context(), 1, scope, "non-existent") @@ -408,7 +708,7 @@ func TestImportAccount(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // We'll start by creating a new account under the BIP0084 scope. scope := waddrmgr.KeyScopeBIP0084 @@ -420,6 +720,19 @@ func TestImportAccount(t *testing.T) { require.NoError(t, err) acctPubKey := deriveAcctPubKey(t, root, scope, hardenedKey(0)) + // Mock expectations for ImportAccount. + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("NewAccountWatchingOnly", mock.Anything, + testAccountName, acctPubKey, root.ParentFingerprint(), + mock.Anything).Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: testAccountName, + }, nil).Once() + // We should be able to import the account. props, err := w.ImportAccount( t.Context(), testAccountName, acctPubKey, @@ -428,10 +741,34 @@ func TestImportAccount(t *testing.T) { require.NoError(t, err) require.Equal(t, testAccountName, props.AccountName) + // Mock expectations for GetAccount. + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LookupAccount", mock.Anything, testAccountName). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: testAccountName, + }, nil).Once() + + deps.txStore.On("UnspentOutputs", mock.Anything). + Return([]wtxmgr.Credit(nil), nil).Once() + // We should be able to get the account by its name. _, err = w.GetAccount(t.Context(), scope, testAccountName) require.NoError(t, err) + // Mock expectations for duplicate ImportAccount. + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("NewAccountWatchingOnly", mock.Anything, + testAccountName, acctPubKey, root.ParentFingerprint(), + mock.Anything).Return(uint32(0), waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrDuplicateAccount, + }).Once() + // We should not be able to import an account with the same name. _, err = w.ImportAccount( t.Context(), testAccountName, acctPubKey, @@ -443,14 +780,41 @@ func TestImportAccount(t *testing.T) { "expected ErrDuplicateAccount", ) - // We should be able to do a dry run of the import. + // Mock expectations for dry-run. dryRunName := "dry run" + + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("NewAccountWatchingOnly", mock.Anything, + dryRunName, acctPubKey, root.ParentFingerprint(), + mock.Anything).Return(uint32(2), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(2)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 2, + AccountName: dryRunName, + }, nil).Twice() + deps.accountManager.On("InvalidateAccountCache", uint32(2)).Return().Once() + deps.accountManager.On("NextExternalAddresses", mock.Anything, uint32(2), + uint32(1)).Return([]waddrmgr.ManagedAddress(nil), nil).Once() + deps.accountManager.On("NextInternalAddresses", mock.Anything, uint32(2), + uint32(1)).Return([]waddrmgr.ManagedAddress(nil), nil).Once() + + // We should be able to do a dry run of the import. _, err = w.ImportAccount( t.Context(), dryRunName, acctPubKey, root.ParentFingerprint(), addrType, true, ) require.NoError(t, err) + // Mock expectations for GetAccount (fail). + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("LookupAccount", mock.Anything, dryRunName). + Return(uint32(0), waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrAccountNotFound, + }).Once() + // The account should not have been imported. _, err = w.GetAccount(t.Context(), scope, dryRunName) require.Error(t, err) @@ -561,142 +925,142 @@ func TestExtractAddrFromPKScript(t *testing.T) { } } -// addTestUTXOForBalance is a helper function to add a UTXO to the wallet. -func addTestUTXOForBalance(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, - accountName string, amount btcutil.Amount) { - - t.Helper() - - // This is a hack to ensure that we generate the correct address type - // for the given scope. A better solution would be to pass the - // address type as a parameter to this function. - addrType := waddrmgr.WitnessPubKey - if scope == waddrmgr.KeyScopeBIP0049Plus { - addrType = waddrmgr.NestedWitnessPubKey - } - - addr, err := w.NewAddress( - t.Context(), accountName, addrType, false, - ) - require.NoError(t, err) - - pkScript, err := txscript.PayToAddrScript(addr) - require.NoError(t, err) - - rec, err := wtxmgr.NewTxRecordFromMsgTx(&wire.MsgTx{ - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - { - Value: int64(amount), - PkScript: pkScript, - }, - }, - }, time.Now()) - require.NoError(t, err) - - err = walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) - block := &wtxmgr.BlockMeta{ - Block: wtxmgr.Block{Height: 1}, - } - - err := w.txStore.InsertTx(ns, rec, block) - if err != nil { - return err - } - - return w.txStore.AddCredit(ns, rec, block, 0, false) - }) - require.NoError(t, err) -} - // TestFetchAccountBalances tests that the fetchAccountBalances helper function // works as expected. func TestFetchAccountBalances(t *testing.T) { t.Parallel() // setupTestCase is a helper closure to set up a test case. - // - // This function initializes a new test wallet and populates it with a - // predictable set of accounts and unspent transaction outputs (UTXOs). - // This provides a consistent starting state for each test case, - // ensuring that tests are isolated and repeatable. - // - // The initial state of the wallet is as follows: - // - // Accounts: - // - The default account (number 0) is implicitly created for each key - // scope. - // - "acc1-bip84": A named account (number 1) under the BIP0084 key - // scope. - // - "acc1-bip49": A named account (number 1) under the BIP0049 key - // scope. - // - // UTXOs: - // - 100 satoshis are credited to the default account in the BIP0084 - // scope. - // - 200 satoshis are credited to the "acc1-bip84" account. - // - 300 satoshis are credited to the "acc1-bip49" account. - // - // Sync State: - // - The wallet is marked as synced up to block height 1. This is - // necessary for the UTXOs to be considered confirmed and spendable. - // - // The function returns the fully initialized wallet and a cleanup - // function that should be deferred by the caller to ensure that the - // wallet's resources are properly released after the test completes. - setupTestCase := func(t *testing.T) *Wallet { + setupTestCase := func(t *testing.T) (*Wallet, *mockWalletDeps) { t.Helper() - w := testWallet(t) - ctx := t.Context() + w, deps := createStartedWalletWithMocks(t) // Create accounts. + deps.addrStore.On("FetchScopedKeyManager", waddrmgr.KeyScopeBIP0084). + Return(deps.accountManager, nil). + Once() + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, "acc1-bip84"). + Return(uint32(1), nil). + Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: "acc1-bip84", + }, nil). + Once() + _, err := w.NewAccount( - ctx, waddrmgr.KeyScopeBIP0084, "acc1-bip84", + t.Context(), waddrmgr.KeyScopeBIP0084, "acc1-bip84", ) require.NoError(t, err) + + deps.addrStore.On( + "FetchScopedKeyManager", waddrmgr.KeyScopeBIP0049Plus, + ).Return(deps.accountManager, nil).Once() + + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, "acc1-bip49"). + Return(uint32(1), nil). + Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: "acc1-bip49", + }, nil). + Once() + _, err = w.NewAccount( t.Context(), waddrmgr.KeyScopeBIP0049Plus, "acc1-bip49", ) require.NoError(t, err) - // Add UTXOs. - addTestUTXOForBalance( - t, w, waddrmgr.KeyScopeBIP0084, "default", 100, + // Create mock addresses for balance mapping. + addr84def, _ := btcutil.NewAddressWitnessPubKeyHash( + []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + w.cfg.ChainParams, ) - addTestUTXOForBalance( - t, w, waddrmgr.KeyScopeBIP0084, "acc1-bip84", 200, + addr84acc1, _ := btcutil.NewAddressWitnessPubKeyHash( + []byte{2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + w.cfg.ChainParams, ) - addTestUTXOForBalance( - t, w, waddrmgr.KeyScopeBIP0049Plus, "acc1-bip49", 300, + addr49acc1, _ := btcutil.NewAddressWitnessPubKeyHash( + []byte{3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + w.cfg.ChainParams, ) - // Update sync state. - err = walletdb.Update( - w.cfg.DB, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket( - waddrmgrNamespaceKey, - ) - bs := &waddrmgr.BlockStamp{Height: 1} - - return w.addrStore.SetSyncedTo(addrmgrNs, bs) - }) - require.NoError(t, err) - - return w + // Setup persistent mocks for balance calculation. + deps.txStore.On("UnspentOutputs", mock.Anything).Return([]wtxmgr.Credit{ + { + Amount: 100, + PkScript: mustPayToAddr(addr84def), + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: 1, + }, + }, + }, + { + Amount: 200, + PkScript: mustPayToAddr(addr84acc1), + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: 1, + }, + }, + }, + { + Amount: 300, + PkScript: mustPayToAddr(addr49acc1), + BlockMeta: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Height: 1, + }, + }, + }, + }, nil).Once() + + // addr84def -> Default Account (0) + deps.addrStore.On("AddrAccount", mock.Anything, addr84def). + Return(deps.accountManager, uint32(0), nil). + Once() + + // addr84acc1 -> Account 1 (BIP84) + deps.addrStore.On("AddrAccount", mock.Anything, addr84acc1). + Return(deps.accountManager, uint32(1), nil). + Once() + + // addr49acc1 -> Account 1 (BIP49) + // We use a different mock account manager to simulate the + // different scope. + mockAccountStore49 := &mockAccountStore{} + mockAccountStore49.On("Scope"). + Return(waddrmgr.KeyScopeBIP0049Plus). + Maybe() // Called varying times depending on filter + + deps.addrStore.On("AddrAccount", mock.Anything, addr49acc1). + Return(mockAccountStore49, uint32(1), nil). + Once() + + return w, deps } testCases := []struct { name string - setup func(t *testing.T, w *Wallet) + setup func(t *testing.T, w *Wallet, deps *mockWalletDeps) filters []filterOption expectedBalances scopedBalances }{ { - name: "no filters", + name: "no filters", + setup: func(t *testing.T, w *Wallet, deps *mockWalletDeps) { + t.Helper() + // Called twice: once for default, once for acc1. + deps.accountManager.On("Scope"). + Return(waddrmgr.KeyScopeBIP0084). + Times(2) + }, filters: nil, expectedBalances: scopedBalances{ waddrmgr.KeyScopeBIP0084: {0: 100, 1: 200}, @@ -705,6 +1069,17 @@ func TestFetchAccountBalances(t *testing.T) { }, { name: "filter by scope", + setup: func(t *testing.T, w *Wallet, deps *mockWalletDeps) { + t.Helper() + // Called 4 times: + // 1. Filter check (def) -> Match + // 2. Map key (def) + // 3. Filter check (acc1) -> Match + // 4. Map key (acc1) + deps.accountManager.On("Scope"). + Return(waddrmgr.KeyScopeBIP0084). + Times(4) + }, filters: []filterOption{ withScope(waddrmgr.KeyScopeBIP0084), }, @@ -714,9 +1089,28 @@ func TestFetchAccountBalances(t *testing.T) { }, { name: "account with no balance", - setup: func(t *testing.T, w *Wallet) { + setup: func(t *testing.T, w *Wallet, deps *mockWalletDeps) { t.Helper() + // Expect 2 Scope calls for the existing accounts with UTXOs. + deps.accountManager.On("Scope"). + Return(waddrmgr.KeyScopeBIP0084). + Times(2) + + deps.addrStore.On( + "FetchScopedKeyManager", waddrmgr.KeyScopeBIP0084, + ).Return(deps.accountManager, nil).Once() + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On( + "NewAccount", mock.Anything, "no-balance", + ).Return(uint32(2), nil).Once() + deps.accountManager.On( + "AccountProperties", mock.Anything, uint32(2), + ).Return(&waddrmgr.AccountProperties{ + AccountNumber: 2, + AccountName: "no-balance", + }, nil).Once() + _, err := w.NewAccount( t.Context(), waddrmgr.KeyScopeBIP0084, "no-balance", @@ -735,10 +1129,10 @@ func TestFetchAccountBalances(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w := setupTestCase(t) + w, deps := setupTestCase(t) if tc.setup != nil { - tc.setup(t, w) + tc.setup(t, w, deps) } var balances scopedBalances @@ -760,22 +1154,53 @@ func TestFetchAccountBalances(t *testing.T) { } } +func mustPayToAddr(addr btcutil.Address) []byte { + script, _ := txscript.PayToAddrScript(addr) + return script +} + // TestListAccountsWithBalances tests that the listAccountsWithBalances helper // function works as expected. func TestListAccountsWithBalances(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // We'll create two new accounts under the BIP0084 scope to have a // predictable state. scope := waddrmgr.KeyScopeBIP0084 acc1Name := "test account" + + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, acc1Name). + Return(uint32(1), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: acc1Name, + }, nil).Once() + _, err := w.NewAccount(t.Context(), scope, acc1Name) require.NoError(t, err) acc2Name := "no balance account" + + deps.addrStore.On("FetchScopedKeyManager", scope). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("CanAddAccount").Return(nil).Once() + deps.accountManager.On("NewAccount", mock.Anything, acc2Name). + Return(uint32(2), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(2)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 2, + AccountName: acc2Name, + }, nil).Once() + _, err = w.NewAccount(t.Context(), scope, acc2Name) require.NoError(t, err) @@ -791,12 +1216,29 @@ func TestListAccountsWithBalances(t *testing.T) { // and verify the results. err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - scopedMgr, err := w.addrStore.FetchScopedKeyManager(scope) - require.NoError(t, err) + + // Setup mock expectations for listAccountsWithBalances. + deps.accountManager.On("LastAccount", mock.Anything). + Return(uint32(2), nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(0)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + }, nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(1)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 1, + AccountName: acc1Name, + }, nil).Once() + deps.accountManager.On("AccountProperties", mock.Anything, uint32(2)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 2, + AccountName: acc2Name, + }, nil).Once() // Call the function under test. results, err := listAccountsWithBalances( - scopedMgr, addrmgrNs, balances, + deps.accountManager, addrmgrNs, balances, ) require.NoError(t, err) diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index c934821c28..11f4c85f6a 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -17,6 +17,7 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -62,7 +63,9 @@ func TestKeyScopeFromAddrType(t *testing.T) { }, } - w := &Wallet{} + w := &Wallet{ + cfg: Config{}, + } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -81,9 +84,6 @@ func TestKeyScopeFromAddrType(t *testing.T) { func TestNewAddress(t *testing.T) { t.Parallel() - // Create a new test wallet. - w := testWallet(t) - // Define a set of test cases to cover different address types and // scenarios. testCases := []struct { @@ -139,22 +139,70 @@ func TestNewAddress(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - // Attempt to generate a new address with the specified - // parameters. - addr, err := w.NewAddress( - t.Context(), tc.accountName, - tc.addrType, tc.change, - ) - // If an error is expected, assert that it occurs. + // Create a new test wallet for each test case. + w, deps := createStartedWalletWithMocks(t) + + // Setup mock expectations. if tc.expectErr { + // Attempt to generate a new address with the specified + // parameters. + _, err := w.NewAddress( + t.Context(), tc.accountName, + tc.addrType, tc.change, + ) require.Error(t, err) + return } - // If no error is expected, assert that the address is - // generated successfully and perform any - // additional checks. + var addr btcutil.Address + switch tc.addrType { + case waddrmgr.WitnessPubKey: + addr, _ = btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.cfg.ChainParams, + ) + case waddrmgr.NestedWitnessPubKey: + addr, _ = btcutil.NewAddressScriptHash( + make([]byte, 20), w.cfg.ChainParams, + ) + case waddrmgr.TaprootPubKey: + addr, _ = btcutil.NewAddressTaproot( + make([]byte, 32), w.cfg.ChainParams, + ) + case waddrmgr.PubKeyHash, waddrmgr.Script, + waddrmgr.RawPubKey, waddrmgr.WitnessScript, + waddrmgr.TaprootScript: + + require.FailNow(t, "unhandled address type", tc.addrType) + + default: + require.FailNow(t, "unknown address type", tc.addrType) + } + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil). + Once() + deps.addrStore.On("Address", mock.Anything, addr). + Return(deps.addr, nil). + Once() + + deps.accountManager.On( + "NewAddress", mock.Anything, tc.accountName, tc.change, + ).Return(addr, nil).Once() + + deps.chain.On("NotifyReceived", []btcutil.Address{addr}). + Return(nil). + Once() + + deps.addr.On("Internal").Return(tc.change).Once() + + // Attempt to generate a new address with the specified + // parameters. + addr, err := w.NewAddress( + t.Context(), tc.accountName, + tc.addrType, tc.change, + ) require.NoError(t, err) require.NotNil(t, addr) @@ -176,15 +224,48 @@ func TestGetUnusedAddress(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // Get a new address to start with. + mockAddr, _ := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.cfg.ChainParams, + ) + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("NewAddress", mock.Anything, "default", false). + Return(mockAddr, nil).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{mockAddr}). + Return(nil).Once() + addr, err := w.NewAddress( t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) // The first unused address should be the one we just created. + // GetUnusedAddress calls: + // - w.keyScopeFromAddrType + // - w.addrStore.FetchScopedKeyManager + // - w.findUnusedAddress (calls manager.LookupAccount and + // ForEachAccountAddress) + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LookupAccount", mock.Anything, "default"). + Return(uint32(0), nil).Once() + + deps.accountManager.On("ForEachAccountAddress", mock.Anything, uint32(0), + mock.Anything).Run(func(args mock.Arguments) { + f, ok := args.Get(2).(func(waddrmgr.ManagedAddress) error) + require.True(t, ok) + mockAddr1 := &mockManagedAddress{} + mockAddr1.On("Internal").Return(false).Once() + mockAddr1.On("Used", mock.Anything).Return(false).Once() + mockAddr1.On("Address").Return(addr).Once() + _ = f(mockAddr1) + }).Return(errStopIteration).Once() + unusedAddr, err := w.GetUnusedAddress( t.Context(), "default", waddrmgr.WitnessPubKey, false, ) @@ -195,6 +276,13 @@ func TestGetUnusedAddress(t *testing.T) { pkScript, err := txscript.PayToAddrScript(addr) require.NoError(t, err) + // Setup expectations for using the address. + deps.txStore.On("InsertTx", mock.Anything, mock.Anything, + mock.Anything).Return(nil).Once() + deps.txStore.On("AddCredit", mock.Anything, mock.Anything, + mock.Anything, uint32(0), false).Return(nil).Once() + deps.addrStore.On("MarkUsed", mock.Anything, addr).Return(nil).Once() + // We need to create a realistic transaction that has at least one // input. err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { @@ -230,6 +318,38 @@ func TestGetUnusedAddress(t *testing.T) { require.NoError(t, err) // Get the next unused address. + // This time findUnusedAddress will find the first address as used, and + // then we mock it returning nil for any more existing addresses, + // triggering a NewAddress call. + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Twice() + + deps.accountManager.On("LookupAccount", mock.Anything, "default"). + Return(uint32(0), nil).Once() + + deps.accountManager.On("ForEachAccountAddress", mock.Anything, uint32(0), + mock.Anything).Run(func(args mock.Arguments) { + f, ok := args.Get(2).(func(waddrmgr.ManagedAddress) error) + require.True(t, ok) + + // First addr is used. + mockAddr1 := &mockManagedAddress{} + mockAddr1.On("Internal").Return(false).Once() + mockAddr1.On("Used", mock.Anything).Return(true).Once() + _ = f(mockAddr1) + }).Return(nil).Once() + + nextAddrVal, _ := btcutil.NewAddressWitnessPubKeyHash( + []byte{ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + }, w.cfg.ChainParams, + ) + deps.accountManager.On("NewAddress", mock.Anything, "default", false). + Return(nextAddrVal, nil).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{nextAddrVal}). + Return(nil).Once() + nextAddr, err := w.GetUnusedAddress( t.Context(), "default", waddrmgr.WitnessPubKey, false, ) @@ -239,12 +359,49 @@ func TestGetUnusedAddress(t *testing.T) { require.NotEqual(t, addr.String(), nextAddr.String()) // Now, let's test the change address. + changeAddrVal, _ := btcutil.NewAddressWitnessPubKeyHash( + []byte{ + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + }, w.cfg.ChainParams, + ) + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("NewAddress", mock.Anything, "default", true). + Return(changeAddrVal, nil).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{changeAddrVal}). + Return(nil).Once() + changeAddr, err := w.NewAddress( t.Context(), "default", waddrmgr.WitnessPubKey, true, ) require.NoError(t, err) // The first unused change address should be the one we just created. + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LookupAccount", mock.Anything, "default"). + Return(uint32(0), nil).Once() + + deps.accountManager.On("ForEachAccountAddress", mock.Anything, uint32(0), + mock.Anything).Run(func(args mock.Arguments) { + f, ok := args.Get(2).(func(waddrmgr.ManagedAddress) error) + require.True(t, ok) + + // First external addr (used). + deps.addr.On("Internal").Return(false).Once() + _ = f(deps.addr) + + // First internal addr (unused). + mockAddr2 := &mockManagedAddress{} + mockAddr2.On("Internal").Return(true).Once() + mockAddr2.On("Used", mock.Anything).Return(false).Once() + mockAddr2.On("Address").Return(changeAddrVal).Once() + _ = f(mockAddr2) + }).Return(errStopIteration).Once() + unusedChangeAddr, err := w.GetUnusedAddress( t.Context(), "default", waddrmgr.WitnessPubKey, true, ) @@ -258,15 +415,36 @@ func TestAddressInfo(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // Get a new external address to test with. + var addr btcutil.Address + + addr, _ = btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.cfg.ChainParams, + ) + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("NewAddress", mock.Anything, "default", false). + Return(addr, nil).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{addr}). + Return(nil).Once() + extAddr, err := w.NewAddress( t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) // Get the address info for the external address. + deps.addrStore.On("Address", mock.Anything, extAddr). + Return(deps.addr, nil).Once() + deps.addr.On("Address").Return(extAddr).Once() + deps.addr.On("Internal").Return(false).Once() + deps.addr.On("Compressed").Return(true).Once() + deps.addr.On("Imported").Return(false).Once() + deps.addr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() + extInfo, err := w.AddressInfo(t.Context(), extAddr) require.NoError(t, err) @@ -278,12 +456,31 @@ func TestAddressInfo(t *testing.T) { require.Equal(t, waddrmgr.WitnessPubKey, extInfo.AddrType()) // Get a new internal address to test with. + addr, _ = btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.cfg.ChainParams, + ) + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("NewAddress", mock.Anything, "default", true). + Return(addr, nil).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{addr}). + Return(nil).Once() + intAddr, err := w.NewAddress( t.Context(), "default", waddrmgr.WitnessPubKey, true, ) require.NoError(t, err) // Get the address info for the internal address. + deps.addrStore.On("Address", mock.Anything, intAddr). + Return(deps.addr, nil).Once() + deps.addr.On("Address").Return(intAddr).Once() + deps.addr.On("Internal").Return(true).Once() + deps.addr.On("Compressed").Return(true).Once() + deps.addr.On("Imported").Return(false).Once() + deps.addr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() + intInfo, err := w.AddressInfo(t.Context(), intAddr) require.NoError(t, err) @@ -302,42 +499,58 @@ func TestGetDerivationInfoExternalAddressSuccess(t *testing.T) { // Arrange: Create a new test wallet and a new p2wkh address to test // with. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) + mockAddr, _ := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.cfg.ChainParams, + ) + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("NewAddress", mock.Anything, "default", false). + Return(mockAddr, nil).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{mockAddr}). + Return(nil).Once() + addr, err := w.NewAddress( t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) // Act: Get the derivation info for the address. + deps.addrStore.On("Address", mock.Anything, addr). + Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Imported").Return(false).Once() + + privKey, _ := btcec.NewPrivateKey() + pubKey := privKey.PubKey() + deps.pubKeyAddr.On("PubKey").Return(pubKey).Once() + + scope := waddrmgr.KeyScopeBIP0084 + path := waddrmgr.DerivationPath{ + Account: 0, + Branch: 0, + Index: 0, + MasterKeyFingerprint: 123, + } + deps.pubKeyAddr.On("DerivationInfo").Return(scope, path, true).Once() + derivationInfo, err := w.GetDerivationInfo(t.Context(), addr) // Assert: Check that the correct derivation info is returned. require.NoError(t, err) require.NotNil(t, derivationInfo) - addrInfo, err := w.AddressInfo(t.Context(), addr) - require.NoError(t, err) - - pubKeyAddr, ok := addrInfo.(waddrmgr.ManagedPubKeyAddress) - require.True(t, ok) - - pubKey := pubKeyAddr.PubKey() - keyScope, derivPath, ok := pubKeyAddr.DerivationInfo() - require.True(t, ok) - expectedPath := []uint32{ - keyScope.Purpose + hdkeychain.HardenedKeyStart, - keyScope.Coin + hdkeychain.HardenedKeyStart, - derivPath.Account, - derivPath.Branch, - derivPath.Index, + scope.Purpose + hdkeychain.HardenedKeyStart, + scope.Coin + hdkeychain.HardenedKeyStart, + path.Account, + path.Branch, + path.Index, } require.Equal(t, pubKey.SerializeCompressed(), derivationInfo.PubKey) - require.Equal( - t, derivPath.MasterKeyFingerprint, - derivationInfo.MasterKeyFingerprint, - ) + require.Equal(t, path.MasterKeyFingerprint, + derivationInfo.MasterKeyFingerprint) require.Equal(t, expectedPath, derivationInfo.Bip32Path) } @@ -348,36 +561,56 @@ func TestGetDerivationInfoInternalAddressSuccess(t *testing.T) { // Arrange: Create a new test wallet and a new p2wkh change address to // test with. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) + mockAddr, _ := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.cfg.ChainParams, + ) + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("NewAddress", mock.Anything, "default", true). + Return(mockAddr, nil).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{mockAddr}). + Return(nil).Once() + addr, err := w.NewAddress( t.Context(), "default", waddrmgr.WitnessPubKey, true, ) require.NoError(t, err) // Act: Get the derivation info for the address. + deps.addrStore.On("Address", mock.Anything, addr). + Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Imported").Return(false).Once() + + privKey, _ := btcec.NewPrivateKey() + pubKey := privKey.PubKey() + deps.pubKeyAddr.On("PubKey").Return(pubKey).Once() + + scope := waddrmgr.KeyScopeBIP0084 + path := waddrmgr.DerivationPath{ + Account: 0, + Branch: 1, + Index: 0, + MasterKeyFingerprint: 123, + } + deps.pubKeyAddr.On("DerivationInfo").Return(scope, path, true).Once() + derivationInfo, err := w.GetDerivationInfo(t.Context(), addr) // Assert: Check that the correct derivation info is returned. require.NoError(t, err) require.NotNil(t, derivationInfo) - addrInfo, err := w.AddressInfo(t.Context(), addr) - require.NoError(t, err) - - pubKeyAddr, ok := addrInfo.(waddrmgr.ManagedPubKeyAddress) - require.True(t, ok) - keyScope, derivPath, ok := pubKeyAddr.DerivationInfo() - require.True(t, ok) - expectedPath := []uint32{ - keyScope.Purpose + hdkeychain.HardenedKeyStart, - keyScope.Coin + hdkeychain.HardenedKeyStart, - derivPath.Account, - derivPath.Branch, - derivPath.Index, + scope.Purpose + hdkeychain.HardenedKeyStart, + scope.Coin + hdkeychain.HardenedKeyStart, + path.Account, + path.Branch, + path.Index, } require.Equal(t, expectedPath, derivationInfo.Bip32Path) - require.Equal(t, uint32(1), derivPath.Branch) + require.Equal(t, uint32(1), path.Branch) } // TestGetDerivationInfoNoDerivationInfo tests that we get an error when trying @@ -388,7 +621,7 @@ func TestGetDerivationInfoNoDerivationInfo(t *testing.T) { // Arrange: Create a new test wallet and a key and address that is not // in the wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) privKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -401,15 +634,30 @@ func TestGetDerivationInfoNoDerivationInfo(t *testing.T) { // Act & Assert: Check that we get an error for an address not in the // wallet. + deps.addrStore.On("Address", mock.Anything, addr).Return( + nil, errDBMock).Once() + _, err = w.GetDerivationInfo(t.Context(), addr) require.Error(t, err) // Arrange: Import the key as a watch-only address. + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("ImportPublicKey", mock.Anything, pubKey, + mock.Anything).Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Address").Return(addr).Maybe() + deps.chain.On("NotifyReceived", []btcutil.Address{addr}). + Return(nil).Once() + err = w.ImportPublicKey(t.Context(), pubKey, waddrmgr.WitnessPubKey) require.NoError(t, err) // Act & Assert: Check that we still get an error because it's an // imported key. + deps.addrStore.On("Address", mock.Anything, addr). + Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Imported").Return(true).Once() + _, err = w.GetDerivationInfo(t.Context(), addr) require.ErrorIs(t, err, ErrDerivationPathNotFound) } @@ -420,20 +668,36 @@ func TestListAddresses(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // Get a new address and give it a balance. + mockAddr, _ := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.cfg.ChainParams, + ) + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("NewAddress", mock.Anything, "default", false). + Return(mockAddr, nil).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{mockAddr}). + Return(nil).Once() + addr, err := w.NewAddress( t.Context(), "default", waddrmgr.WitnessPubKey, false, ) require.NoError(t, err) - // "Use" the address by creating a fake UTXO for it. pkScript, err := txscript.PayToAddrScript(addr) require.NoError(t, err) // We need to create a realistic transaction that has at least one // input. + deps.txStore.On("InsertTx", mock.Anything, mock.Anything, + mock.Anything).Return(nil).Once() + deps.txStore.On("AddCredit", mock.Anything, mock.Anything, + mock.Anything, uint32(0), false).Return(nil).Once() + deps.addrStore.On("MarkUsed", mock.Anything, addr).Return(nil).Once() + err = walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) @@ -467,6 +731,27 @@ func TestListAddresses(t *testing.T) { require.NoError(t, err) // List the addresses for the default account. + deps.txStore.On("UnspentOutputs", mock.Anything).Return([]wtxmgr.Credit{ + { + Amount: 1000, + PkScript: pkScript, + }, + }, nil).Once() + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + + deps.accountManager.On("LookupAccount", mock.Anything, "default"). + Return(uint32(0), nil).Once() + + deps.accountManager.On("ForEachAccountAddress", mock.Anything, uint32(0), + mock.Anything).Run(func(args mock.Arguments) { + f, ok := args.Get(2).(func(waddrmgr.ManagedAddress) error) + require.True(t, ok) + deps.addr.On("Address").Return(addr).Once() + _ = f(deps.addr) + }).Return(nil).Once() + addrs, err := w.ListAddresses( t.Context(), "default", waddrmgr.WitnessPubKey, ) @@ -484,7 +769,7 @@ func TestImportPublicKey(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // Create a new public key to import. privKey, err := btcec.NewPrivateKey() @@ -493,20 +778,29 @@ func TestImportPublicKey(t *testing.T) { pubKey := privKey.PubKey() // Import the public key. - err = w.ImportPublicKey( - t.Context(), pubKey, waddrmgr.WitnessPubKey, - ) - require.NoError(t, err) - - // Check that the address is now managed by the wallet. - addr, err := btcutil.NewAddressWitnessPubKeyHash( + addr, _ := btcutil.NewAddressWitnessPubKeyHash( btcutil.Hash160(pubKey.SerializeCompressed()), w.cfg.ChainParams, ) + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("ImportPublicKey", mock.Anything, pubKey, + mock.Anything).Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Address").Return(addr).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{addr}). + Return(nil).Once() + + err = w.ImportPublicKey(t.Context(), pubKey, waddrmgr.WitnessPubKey) require.NoError(t, err) - managed, err := w.HaveAddress(addr) + + // Check that the address is now managed by the wallet. + deps.addrStore.On("Address", mock.Anything, addr). + Return(deps.pubKeyAddr, nil).Once() + + info, err := w.AddressInfo(t.Context(), addr) require.NoError(t, err) - require.True(t, managed) + require.NotNil(t, info) } // TestImportTaprootScript tests the ImportTaprootScript method to ensure it can @@ -515,7 +809,7 @@ func TestImportTaprootScript(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // Create a new tapscript to import. privKey, err := btcec.NewPrivateKey() @@ -540,19 +834,33 @@ func TestImportTaprootScript(t *testing.T) { } // Import the tapscript. - _, err = w.ImportTaprootScript(t.Context(), tapscript) - require.NoError(t, err) - - // Check that the address is now managed by the wallet. - addr, err := btcutil.NewAddressTaproot( + addr, _ := btcutil.NewAddressTaproot( schnorr.SerializePubKey(txscript.ComputeTaprootOutputKey( pubKey, rootHash[:], )), w.cfg.ChainParams, ) + + deps.addrStore.On("FetchScopedKeyManager", waddrmgr.KeyScopeBIP0086). + Return(deps.accountManager, nil).Once() + + // SyncedTo is mocked in createStartedWalletWithMocks (height 1). + deps.accountManager.On("ImportTaprootScript", mock.Anything, + mock.Anything, mock.Anything, uint8(1), false). + Return(deps.taprootAddr, nil).Once() + deps.taprootAddr.On("Address").Return(addr).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{addr}). + Return(nil).Once() + + _, err = w.ImportTaprootScript(t.Context(), tapscript) require.NoError(t, err) - managed, err := w.HaveAddress(addr) + + // Check that the address is now managed by the wallet. + deps.addrStore.On("Address", mock.Anything, addr). + Return(deps.taprootAddr, nil).Once() + + info, err := w.AddressInfo(t.Context(), addr) require.NoError(t, err) - require.True(t, managed) + require.NotNil(t, info) } // TestScriptForOutput tests the ScriptForOutput method to ensure it returns the @@ -561,9 +869,20 @@ func TestScriptForOutput(t *testing.T) { t.Parallel() // Create a new test wallet. - w := testWallet(t) + w, deps := createStartedWalletWithMocks(t) // Create a new p2wkh address and output. + mockAddr, _ := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 20), w.cfg.ChainParams, + ) + + deps.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(deps.accountManager, nil).Once() + deps.accountManager.On("NewAddress", mock.Anything, "default", false). + Return(mockAddr, nil).Once() + deps.chain.On("NotifyReceived", []btcutil.Address{mockAddr}). + Return(nil).Once() + addr, err := w.NewAddress( t.Context(), "default", waddrmgr.WitnessPubKey, false, ) @@ -577,6 +896,10 @@ func TestScriptForOutput(t *testing.T) { } // Get the script for the output. + deps.addrStore.On("Address", mock.Anything, addr). + Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() + script, err := w.ScriptForOutput(t.Context(), output) require.NoError(t, err) diff --git a/wallet/common_test.go b/wallet/common_test.go index cb7542c2f4..629f3daa6e 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" _ "github.com/btcsuite/btcwallet/walletdb/bdb" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -86,10 +86,14 @@ func setupTestDB(t *testing.T) (walletdb.DB, func()) { // mockWalletDeps holds the mocked dependencies for the Wallet. type mockWalletDeps struct { - addrStore *mockAddrStore - txStore *mockTxStore - syncer *mockChainSyncer - chain *mockChain + addrStore *mockAddrStore + txStore *mockTxStore + syncer *mockChainSyncer + chain *mockChain + addr *mockManagedAddress + accountManager *mockAccountStore + pubKeyAddr *mockManagedPubKeyAddr + taprootAddr *mockManagedTaprootScriptAddress } // createTestWalletWithMocks creates a Wallet instance with mocked @@ -105,6 +109,10 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { mockTxStore := &mockTxStore{} mockSyncer := &mockChainSyncer{} mockChain := &mockChain{} + mockAddr := &mockManagedAddress{} + mockAccountManager := &mockAccountStore{} + mockPubKeyAddr := &mockManagedPubKeyAddr{} + mockTaprootAddr := &mockManagedTaprootScriptAddress{} ctx, cancel := context.WithCancel(t.Context()) @@ -120,11 +128,10 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { birthdayBlock: waddrmgr.BlockStamp{ Height: 100, }, - cfg: Config{ DB: db, Chain: mockChain, - ChainParams: &chaincfg.MainNetParams, + ChainParams: &chainParams, }, } @@ -132,10 +139,14 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { w.lockTimer.Stop() deps := &mockWalletDeps{ - addrStore: mockAddrStore, - txStore: mockTxStore, - syncer: mockSyncer, - chain: mockChain, + addrStore: mockAddrStore, + txStore: mockTxStore, + syncer: mockSyncer, + chain: mockChain, + addr: mockAddr, + accountManager: mockAccountManager, + pubKeyAddr: mockPubKeyAddr, + taprootAddr: mockTaprootAddr, } t.Cleanup(func() { @@ -143,7 +154,80 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { mockTxStore.AssertExpectations(t) mockSyncer.AssertExpectations(t) mockChain.AssertExpectations(t) + mockAddr.AssertExpectations(t) + mockAccountManager.AssertExpectations(t) + mockPubKeyAddr.AssertExpectations(t) + mockTaprootAddr.AssertExpectations(t) + }) + + return w, deps +} + +// createStartedWalletWithMocks creates a fully started and unlocked Wallet +// instance with mocked dependencies. +func createStartedWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { + t.Helper() + + w, deps := createTestWalletWithMocks(t) + + // Mock the birthday block to be present. + deps.addrStore.On("BirthdayBlock", mock.Anything). + Return(waddrmgr.BlockStamp{}, true, nil). + Once() + + // Allow SyncedTo to be called any number of times (background sync). + deps.addrStore.On("SyncedTo"). + Return(waddrmgr.BlockStamp{Height: 1}). + Maybe() + + // Mock account loading. + deps.addrStore.On("ActiveScopedKeyManagers"). + Return([]waddrmgr.AccountStore{deps.accountManager}). + Once() + + deps.accountManager.On("LastAccount", mock.Anything). + Return(uint32(0), nil). + Once() + + deps.accountManager.On("AccountProperties", mock.Anything, uint32(0)). + Return(&waddrmgr.AccountProperties{ + AccountNumber: 0, + AccountName: "default", + }, nil). + Once() + + // Mock expired lock deletion. + deps.txStore.On("DeleteExpiredLockedOutputs", mock.Anything). + Return(nil). + Once() + + // Mock the syncer run. + deps.syncer.On("run", mock.Anything).Return(nil).Once() + + // Start the wallet. + require.NoError(t, w.Start(t.Context())) + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout( + context.Background(), 5*time.Second, + ) + defer cancel() + + require.NoError(t, w.Stop(ctx)) }) return w, deps } + +// createUnlockedWalletWithMocks creates a fully started and unlocked Wallet +// instance with mocked dependencies. +func createUnlockedWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { + t.Helper() + + w, deps := createStartedWalletWithMocks(t) + + // Transition to Unlocked. + w.state.toUnlocked() + + return w, deps +} diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 6d3cba8478..5eccf20462 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -1480,3 +1480,37 @@ func (m *mockAddress) String() string { args := m.Called() return args.String(0) } + +// mockManagedTaprootScriptAddress is a mock implementation of the +// waddrmgr.ManagedTaprootScriptAddress interface. +type mockManagedTaprootScriptAddress struct { + mockManagedAddress +} + +// A compile-time assertion to ensure that mockManagedTaprootScriptAddress +// implements the waddrmgr.ManagedTaprootScriptAddress interface. +var _ waddrmgr.ManagedTaprootScriptAddress = (*mockManagedTaprootScriptAddress)( + nil, +) + +// Script implements the waddrmgr.ManagedScriptAddress interface. +func (m *mockManagedTaprootScriptAddress) Script() ([]byte, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).([]byte), args.Error(1) +} + +// TaprootScript implements the waddrmgr.ManagedTaprootScriptAddress interface. +func (m *mockManagedTaprootScriptAddress) TaprootScript() ( + *waddrmgr.Tapscript, error) { + + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*waddrmgr.Tapscript), args.Error(1) +} diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index f25fed2216..c2b6117019 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -81,11 +81,16 @@ func TestFindCredit(t *testing.T) { // Act: Call findCredit with the configured TxDetails // and index. - found := findCredit(txDetails, tc.index) + cred := findCredit(txDetails, tc.index) - // Assert: Verify that the returned boolean matches the - // expected outcome. - require.Equal(t, tc.expectedFound, found) + // Assert: Verify that the returned credit record + // matches the expected outcome. + if tc.expectedFound { + require.NotNil(t, cred) + require.Equal(t, tc.index, cred.Index) + } else { + require.Nil(t, cred) + } }) } } @@ -115,7 +120,7 @@ func TestFetchAndValidateUtxoSuccess(t *testing.T) { }, } - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Mock the transaction store to return the details for our txHash. mocks.txStore.On( @@ -177,6 +182,14 @@ func TestFetchAndValidateUtxoError(t *testing.T) { Credits: []wtxmgr.CreditRecord{}, } + lockedDetails := &wtxmgr.TxDetails{ + TxRecord: txDetails.TxRecord, + Credits: []wtxmgr.CreditRecord{ + {Index: 0}, + {Index: 1, Locked: true}, + }, + } + testCases := []struct { name string txIn *wire.TxIn @@ -208,7 +221,7 @@ func TestFetchAndValidateUtxoError(t *testing.T) { { name: "utxo locked", txIn: txInLocked, - mockTxDetails: txDetails, + mockTxDetails: lockedDetails, mockErr: nil, expectedErr: ErrUtxoLocked, }, @@ -218,11 +231,7 @@ func TestFetchAndValidateUtxoError(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) - - // Arrange: Lock the "locked" outpoint to simulate a - // locked UTXO scenario. - w.LockOutpoint(txInLocked.PreviousOutPoint) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock the transaction store to return the // configured details or error for the specific test @@ -277,7 +286,7 @@ func TestDecorateInputSegWitV0(t *testing.T) { Index: 0, } - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock the address manager to return our P2WKH address as a // ManagedPubKeyAddress when `Address` is called with the P2WKH @@ -352,7 +361,7 @@ func TestDecorateInputTaproot(t *testing.T) { Index: 0, } - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock the address manager to return our Taproot address as a // ManagedPubKeyAddress when `Address` is called with the Taproot @@ -405,7 +414,7 @@ func TestDecorateInputTaproot(t *testing.T) { func TestDecorateInputErrExtractAddr(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Arrange: Create a UTXO with an OP_RETURN script, which cannot be // parsed into a valid address. @@ -442,7 +451,7 @@ func TestDecorateInputErrAddrInfo(t *testing.T) { p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock AddressInfo to return an error. mocks.addrStore.On( @@ -482,7 +491,7 @@ func TestDecorateInputErrNotPubKey(t *testing.T) { p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock AddressInfo to return a generic ManagedAddress // (mocks.addr) instead of a ManagedPubKeyAddress (mocks.pubKeyAddr). @@ -525,7 +534,7 @@ func TestDecorateInputErrImported(t *testing.T) { p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock AddressInfo to return a ManagedPubKeyAddress that is // marked as imported. @@ -569,7 +578,7 @@ func TestDecorateInputErrDerivationMissing(t *testing.T) { p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock AddressInfo to return a ManagedPubKeyAddress that has // no derivation info. @@ -662,7 +671,7 @@ func TestDecorateInputsSuccess(t *testing.T) { Credits: []wtxmgr.CreditRecord{{Index: 0}}, } - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock TxDetails lookups. // Input 0 -> Found @@ -742,7 +751,7 @@ func TestDecorateInputsErrUnknownRequired(t *testing.T) { packet, err := psbt.NewFromUnsignedTx(unsignedTx) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock TxDetails to return ErrTxNotFound. mocks.txStore.On( @@ -773,7 +782,7 @@ func TestDecorateInputsErrFetchFailed(t *testing.T) { packet, err := psbt.NewFromUnsignedTx(unsignedTx) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock TxDetails to return a database error. mocks.txStore.On( @@ -828,7 +837,7 @@ func TestDecorateInputsErrDecorationFailed(t *testing.T) { Credits: []wtxmgr.CreditRecord{{Index: 0}}, } - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock TxDetails success. mocks.txStore.On( @@ -852,7 +861,7 @@ func TestDecorateInputsErrDecorationFailed(t *testing.T) { func TestValidateFundIntentSuccess(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Arrange: Create a valid PSBT packet with one output (for auto // selection). @@ -895,7 +904,7 @@ func TestValidateFundIntentSuccess(t *testing.T) { func TestValidateFundIntentError(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Arrange: Helper function to create a PSBT packet with specified // inputs and outputs. @@ -965,7 +974,7 @@ func TestValidateFundIntentError(t *testing.T) { func TestCreateTxIntentAuto(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Arrange: Create a PSBT packet with two outputs and no inputs, // which signals automatic coin selection. @@ -1015,7 +1024,7 @@ func TestCreateTxIntentAuto(t *testing.T) { func TestCreateTxIntentManual(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Arrange: Create a PSBT packet with two inputs and one output, // which signals manual coin selection. @@ -1146,7 +1155,7 @@ func TestAddChangeOutputInfoSuccess(t *testing.T) { packet, err := psbt.NewFromUnsignedTx(authoredTx.Tx) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock Address lookup. mocks.addrStore.On( @@ -1206,7 +1215,7 @@ func TestAddChangeOutputInfoErrScriptFail(t *testing.T) { packet, err := psbt.NewFromUnsignedTx(authoredTx.Tx) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock Address lookup to fail. mocks.addrStore.On( @@ -1248,7 +1257,7 @@ func TestAddChangeOutputInfoErrNotPubKey(t *testing.T) { packet, err := psbt.NewFromUnsignedTx(authoredTx.Tx) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock Address lookup to return a generic address. mocks.addrStore.On( @@ -1292,7 +1301,7 @@ func TestAddChangeOutputInfoErrDerivationUnknown(t *testing.T) { packet, err := psbt.NewFromUnsignedTx(authoredTx.Tx) require.NoError(t, err) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock Address lookup. mocks.addrStore.On( @@ -1360,7 +1369,7 @@ func TestPopulatePsbtPacketErrors(t *testing.T) { t.Run("DecorateInputs fails", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) packet := &psbt.Packet{} // Mock TxDetails failure (DecorateInputs -> @@ -1376,7 +1385,7 @@ func TestPopulatePsbtPacketErrors(t *testing.T) { t.Run("addChangeOutputInfo fails", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) packet := &psbt.Packet{} // Mock TxDetails success (DecorateInputs) @@ -1472,7 +1481,7 @@ func TestPopulatePsbtPacketSuccess(t *testing.T) { // Arrange: Create empty packet (will be overwritten). packet := &psbt.Packet{} - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock TxDetails for input decoration. txDetails := &wtxmgr.TxDetails{ @@ -1608,86 +1617,67 @@ func TestFundPsbtWorkflow(t *testing.T) { }, } - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) + mocks.syncer.On("syncState").Return(syncStateSynced).Times(2) // Arrange: Mock the internal dependencies for the FundPsbt workflow. + + // --- Mock txStore --- // 1. Mock `txStore.GetUtxo` for `createManualInputSource`: - // Expect a call with any context and the specified outpoint, - // returning our predefined credit and no error. - mocks.txStore.On("GetUtxo", mock.Anything, outPoint).Return(credit, nil) + mocks.txStore.On("GetUtxo", mock.Anything, outPoint). + Return(credit, nil). + Once() + + // 7. Mock `txStore.TxDetails` for `fetchAndValidateUtxo` during + // `DecorateInputs`: + mocks.txStore.On("TxDetails", mock.Anything, + mock.MatchedBy(func(h *chainhash.Hash) bool { + return h.IsEqual(&txHash) + }), + ).Return(txDetails, nil).Once() + // --- Mock addrStore --- // 2. Mock `addrStore.FetchScopedKeyManager` to retrieve the account // manager: - // Expect a call with the BIP0084 key scope, returning the mock - // account manager and no error. - mocks.addrStore.On( - "FetchScopedKeyManager", waddrmgr.KeyScopeBIP0084, - ).Return(mocks.accountManager, nil) + mocks.addrStore.On("FetchScopedKeyManager", waddrmgr.KeyScopeBIP0084). + Return(mocks.accountManager, nil).Times(3) + // 8. Mock `addrStore.Address` for `decorateInput` during + // `DecorateInputs`: + mocks.addrStore.On("Address", mock.Anything, + mock.MatchedBy(func(addr btcutil.Address) bool { + return addr.String() == p2wkhAddr.String() + }), + ).Return(mocks.pubKeyAddr, nil).Times(3) + + // --- Mock accountManager --- // 3. Mock `accountManager.LookupAccount` for the default account: - // Expect a call with any context and "default" account name, - // returning the default account number and no error. mocks.accountManager.On("LookupAccount", mock.Anything, "default"). - Return(uint32(waddrmgr.DefaultAccountNum), nil) + Return(uint32(waddrmgr.DefaultAccountNum), nil). + Once() // 4. Mock `accountManager.AccountProperties` to return properties for // the default account: - // Expect a call with any context and the default account number, - // returning predefined account properties and no error. - mocks.accountManager.On( - "AccountProperties", - mock.Anything, + mocks.accountManager.On("AccountProperties", mock.Anything, uint32(waddrmgr.DefaultAccountNum), ).Return(&waddrmgr.AccountProperties{ AccountName: "default", KeyScope: waddrmgr.KeyScopeBIP0084, - }, nil) + }, nil).Once() // 5. Mock `accountManager.NextInternalAddresses` to generate a change // address: - // Expect a call to generate one internal address for the default - // account, returning our mock managed address and no error. changeAddr := p2wkhAddr // Reusing p2wkhAddr for simplicity as change mockManagedAddr := mocks.pubKeyAddr - mocks.accountManager.On( - "NextInternalAddresses", - mock.Anything, - uint32(waddrmgr.DefaultAccountNum), - uint32(1), - ).Return([]waddrmgr.ManagedAddress{mockManagedAddr}, nil) + mocks.accountManager.On("NextInternalAddresses", mock.Anything, + uint32(waddrmgr.DefaultAccountNum), uint32(1), + ).Return([]waddrmgr.ManagedAddress{mockManagedAddr}, nil).Once() + // --- Mock pubKeyAddr (and ManagedAddress) --- // 6. Mock `mockManagedAddr.Address` to return the change address: - // Expect a call to get the address from the mock managed address, - // returning our predefined P2WKH address. mockManagedAddr.On("Address").Return(changeAddr) - // 7. Mock `txStore.TxDetails` for `fetchAndValidateUtxo` during - // `DecorateInputs`: - // Expect a call to retrieve transaction details for the input's - // hash, returning our predefined `txDetails` and no error. - mocks.txStore.On( - "TxDetails", - mock.Anything, - mock.MatchedBy(func(h *chainhash.Hash) bool { - return h.IsEqual(&txHash) - }), - ).Return(txDetails, nil) - - // 8. Mock `addrStore.Address` for `decorateInput` during - // `DecorateInputs`: - // Expect a call to look up the address by script, returning our - // mock managed public key address and no error. - mocks.addrStore.On( - "Address", - mock.Anything, - mock.MatchedBy(func(addr btcutil.Address) bool { - return addr.String() == p2wkhAddr.String() - }), - ).Return(mocks.pubKeyAddr, nil) - // 9. Mock `ManagedPubKeyAddress` methods for `decorateInput`: - // Expect calls to get imported status, derivation info, public key, - // and address type, returning predefined values. mocks.pubKeyAddr.On("Imported").Return(false) mocks.pubKeyAddr.On("DerivationInfo").Return( waddrmgr.KeyScopeBIP0084, waddrmgr.DerivationPath{}, true, @@ -1738,9 +1728,11 @@ func TestFundPsbtDecorateFailure(t *testing.T) { }, } - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) + mocks.syncer.On("syncState").Return(syncStateSynced).Times(2) // Arrange: Mock GetUtxo for CreateTransaction (Success). + credit := &wtxmgr.Credit{ OutPoint: outPoint, Amount: 100000, @@ -1750,8 +1742,7 @@ func TestFundPsbtDecorateFailure(t *testing.T) { // Arrange: Mock TxDetails for DecorateInputs (Failure). // This triggers the error in populatePsbtPacket -> DecorateInputs. - mocks.txStore.On( - "TxDetails", mock.Anything, + mocks.txStore.On("TxDetails", mock.Anything, mock.MatchedBy(func(h *chainhash.Hash) bool { return h.IsEqual(&txHash) }), @@ -1761,15 +1752,16 @@ func TestFundPsbtDecorateFailure(t *testing.T) { // required because the input (100k) exceeds the output (90k) + fees. mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). Return(mocks.accountManager, nil) + mocks.accountManager.On("LookupAccount", mock.Anything, "default"). Return(uint32(waddrmgr.DefaultAccountNum), nil) - mocks.accountManager.On( - "AccountProperties", mock.Anything, + mocks.accountManager.On("AccountProperties", mock.Anything, uint32(waddrmgr.DefaultAccountNum), ).Return(&waddrmgr.AccountProperties{ AccountName: "default", KeyScope: waddrmgr.KeyScopeBIP0084, }, nil) + // Change address generation. mocks.accountManager.On( "NextInternalAddresses", mock.Anything, mock.Anything, @@ -1808,7 +1800,8 @@ func TestFundPsbtErrors(t *testing.T) { t.Run("validate intent fails", func(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) + mocks.syncer.On("syncState").Return(syncStateSynced).Once() // Invalid intent (nil packet) _, _, err := w.FundPsbt(t.Context(), &FundIntent{}) require.ErrorIs(t, err, ErrNilTxIntent) @@ -1816,13 +1809,15 @@ func TestFundPsbtErrors(t *testing.T) { t.Run("CreateTransaction fails", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) + mocks.syncer.On("syncState").Return(syncStateSynced).Times(2) // Mock CreateTransaction failure via Account lookup failure mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). Return(nil, errDb) _, _, err := w.FundPsbt(t.Context(), intent) + // AccountNumber failure is wrapped in ErrAccountNotFound by // prepareTxAuthSources. require.ErrorIs(t, err, ErrAccountNotFound) @@ -1838,6 +1833,9 @@ func TestParseBip32Path(t *testing.T) { // Use mainnet params for testing (HDCoinType = 0). chainParams := &chaincfg.MainNetParams w := &Wallet{ + cfg: Config{ + ChainParams: chainParams, + }, walletDeprecated: &walletDeprecated{ chainParams: chainParams, }, @@ -2917,7 +2915,7 @@ func TestSignTaprootPsbtInput(t *testing.T) { packet.Inputs[0].TaprootBip32Derivation = tapDerivations packet.Inputs[0].SighashType = txscript.SigHashDefault - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Arrange: Mock address lookup flow. // 1. FetchScopedKeyManager @@ -2999,7 +2997,7 @@ func TestSignBip32PsbtInput(t *testing.T) { packet.Inputs[0].SighashType = txscript.SigHashAll packet.Inputs[0].WitnessScript = p2wkhScript - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Arrange: Mock address lookup flow. mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). @@ -3033,7 +3031,7 @@ func TestSignPsbtFailNilParams(t *testing.T) { t.Parallel() // Arrange: Create a mock wallet. - w, _ := testWalletWithMocks(t) + w, _ := createUnlockedWalletWithMocks(t) // Act: Call SignPsbt with nil params. _, err := w.SignPsbt(t.Context(), nil) @@ -3094,12 +3092,9 @@ func TestSignPsbt(t *testing.T) { packet.Inputs[0].Bip32Derivation = []*psbt.Bip32Derivation{derivation} packet.Inputs[0].SighashType = txscript.SigHashAll + signParams := &SignPsbtParams{Packet: packet} // Arrange: Wrap the packet in SignPsbtParams. - signParams := &SignPsbtParams{ - Packet: packet, - } - - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Arrange: Configure mock expectations for key derivation and private // key retrieval. @@ -3135,7 +3130,7 @@ func TestSignPsbtInputsNotReady(t *testing.T) { require.NoError(t, err) signParams := &SignPsbtParams{Packet: packet} - w, _ := testWalletWithMocks(t) + w, _ := createUnlockedWalletWithMocks(t) // Act. _, err = w.SignPsbt(t.Context(), signParams) @@ -3167,7 +3162,7 @@ func TestSignPsbtInvalidDerivationPath(t *testing.T) { }} signParams := &SignPsbtParams{Packet: packet} - w, _ := testWalletWithMocks(t) + w, _ := createUnlockedWalletWithMocks(t) // Act. _, err = w.SignPsbt(t.Context(), signParams) @@ -3208,7 +3203,7 @@ func TestSignPsbtSignErrorSkippable(t *testing.T) { packet.Inputs[0].SighashType = txscript.SigHashAll signParams := &SignPsbtParams{Packet: packet} - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Arrange: Mocks to simulate signing failure. mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). @@ -3233,7 +3228,7 @@ func TestSignPsbtSignErrorSkippable(t *testing.T) { func TestSignTaprootPsbtInputErrors(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createUnlockedWalletWithMocks(t) tx := wire.NewMsgTx(2) tx.AddTxIn(&wire.TxIn{}) packet, err := psbt.NewFromUnsignedTx(tx) @@ -3267,7 +3262,7 @@ func TestSignTaprootPsbtInputErrors(t *testing.T) { func TestSignBip32PsbtInputErrors(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createUnlockedWalletWithMocks(t) tx := wire.NewMsgTx(2) tx.AddTxIn(&wire.TxIn{}) packet, err := psbt.NewFromUnsignedTx(tx) @@ -3403,7 +3398,7 @@ func TestFinalizeInput(t *testing.T) { } packet.Inputs[0].SighashType = txscript.SigHashAll - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Arrange: Mock dependencies. mocks.addrStore.On( @@ -3437,7 +3432,7 @@ func TestFinalizeInput(t *testing.T) { packet.Inputs[0].FinalScriptWitness = []byte{0x01} - w, _ := testWalletWithMocks(t) + w, _ := createUnlockedWalletWithMocks(t) // Act. err = w.finalizeInput(t.Context(), packet, 0, nil) @@ -3454,7 +3449,7 @@ func TestFinalizeInput(t *testing.T) { packet, err := psbt.NewFromUnsignedTx(tx) require.NoError(t, err) - w, _ := testWalletWithMocks(t) + w, _ := createUnlockedWalletWithMocks(t) // Act. err = w.finalizeInput(t.Context(), packet, 0, nil) @@ -3477,7 +3472,7 @@ func TestFinalizeInput(t *testing.T) { PkScript: []byte{0x6a}, } - w, _ := testWalletWithMocks(t) + w, _ := createUnlockedWalletWithMocks(t) // Act. err = w.finalizeInput(t.Context(), packet, 0, nil) @@ -3550,7 +3545,7 @@ func TestFinalizePsbtSuccess(t *testing.T) { } packet.Inputs[0].SighashType = txscript.SigHashDefault - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Arrange: Mock address lookup. mocks.addrStore.On( @@ -3596,7 +3591,7 @@ func TestFinalizePsbtErrors(t *testing.T) { packet, err := psbt.NewFromUnsignedTx(tx) require.NoError(t, err) - w, _ := testWalletWithMocks(t) + w, _ := createUnlockedWalletWithMocks(t) // Act. err = w.FinalizePsbt(t.Context(), packet) @@ -3628,7 +3623,7 @@ func TestFinalizePsbtErrors(t *testing.T) { PkScript: dummyScript, } - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Arrange: Mock Address lookup to return error (or watch only). // Simulating "Address not found" or "Key not found". @@ -4550,7 +4545,7 @@ func TestCombinePsbt(t *testing.T) { t.Run("success", func(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Arrange: Create a base transaction with 1 input and 1 output. tx := wire.NewMsgTx(2) @@ -4589,7 +4584,7 @@ func TestCombinePsbt(t *testing.T) { t.Run("empty inputs", func(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Act: Attempt to combine with no packets. _, err := w.CombinePsbt(t.Context()) @@ -4600,7 +4595,7 @@ func TestCombinePsbt(t *testing.T) { t.Run("mismatch tx", func(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Arrange: Create two packets with DIFFERENT transactions. tx1 := wire.NewMsgTx(2) @@ -4693,3 +4688,31 @@ func TestDeduplicateUnknowns(t *testing.T) { }) } } + +// TestSignPsbtLocked tests that SignPsbt fails when the wallet is locked. +func TestSignPsbtLocked(t *testing.T) { + t.Parallel() + + w, _ := createStartedWalletWithMocks(t) + // Minimal packet. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, _ := psbt.NewFromUnsignedTx(tx) + + _, err := w.SignPsbt(t.Context(), &SignPsbtParams{Packet: packet}) + require.ErrorIs(t, err, ErrStateForbidden) +} + +// TestFinalizePsbtLocked tests that FinalizePsbt fails when the wallet is +// locked. +func TestFinalizePsbtLocked(t *testing.T) { + t.Parallel() + + w, _ := createStartedWalletWithMocks(t) + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + packet, _ := psbt.NewFromUnsignedTx(tx) + + err := w.FinalizePsbt(t.Context(), packet) + require.ErrorIs(t, err, ErrStateForbidden) +} diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 453442319b..148b6aea06 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -138,7 +138,7 @@ func TestDerivePubKeySuccess(t *testing.T) { // Arrange: Set up the wallet with mocks, a test key, and a // derivation path. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -177,7 +177,7 @@ func TestDerivePubKeyFetchManagerFails(t *testing.T) { // Arrange: Set up the wallet and a test path. Configure the mock // addrStore to return an error when fetching the key manager. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). @@ -198,7 +198,7 @@ func TestDerivePubKeyDeriveFails(t *testing.T) { // Arrange: Set up the wallet, mocks, and a test path. Configure the // mock account manager to return an error on derivation. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) path := BIP32Path{ KeyScope: waddrmgr.KeyScopeBIP0084, DerivationPath: waddrmgr.DerivationPath{ @@ -228,7 +228,7 @@ func TestDerivePubKeyNotPubKeyAddr(t *testing.T) { // Arrange: Set up the wallet and mocks. Configure the mock derivation // to return a managed address that is NOT a ManagedPubKeyAddress. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} // We need a valid address for the error message. @@ -257,7 +257,7 @@ func TestECDHSuccess(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, mocks, and test keys. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Use a hardcoded private key for deterministic test results. privKey, _ := deterministicPrivKey(t) @@ -313,7 +313,7 @@ func TestECDHFails(t *testing.T) { // Arrange: Set up the wallet and configure the mock addrStore to return // an error. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} remoteKey, err := btcec.NewPrivateKey() @@ -483,7 +483,7 @@ func TestSignDigest(t *testing.T) { // deterministic private key for the specified // derivation path. This allows us to test the signing // logic in isolation. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Configure the full mock chain to return the test // private key. @@ -523,7 +523,7 @@ func TestSignDigest(t *testing.T) { func TestSignDigestFail(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} digest := make([]byte, 32) @@ -636,7 +636,7 @@ func TestComputeUnlockingScriptP2PKH(t *testing.T) { // Arrange: Set up the wallet, keys, and a dummy transaction that will // be used to spend the P2PKH output. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, pubKey := deterministicPrivKey(t) // Create a P2PKH address and the corresponding previous output script. @@ -710,7 +710,7 @@ func TestComputeUnlockingScriptP2WKH(t *testing.T) { // Arrange: Set up the wallet, keys, and a dummy transaction that will // be used to spend the P2WKH output. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, pubKey := deterministicPrivKey(t) // Create a P2WKH address and the corresponding previous output script. @@ -779,7 +779,7 @@ func TestComputeUnlockingScriptNP2WKH(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, keys, and a dummy transaction. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, pubKey := deterministicPrivKey(t) // Create a NP2WKH address. This is a P2WKH output nested within a @@ -858,7 +858,7 @@ func TestComputeUnlockingScriptP2TR(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, keys, and a dummy transaction. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, pubKey := deterministicPrivKey(t) // Create a P2TR address for a key-path spend. This involves computing @@ -949,7 +949,7 @@ func TestComputeUnlockingScriptFail_ScriptForOutput(t *testing.T) { sigHashes := txscript.NewTxSigHashes(tx, fetcher) // Arrange: Set up the wallet and mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Mock the address store to return an error. mocks.addrStore.On("Address", mock.Anything, addr). @@ -991,7 +991,7 @@ func TestComputeUnlockingScriptFail_PrivKey(t *testing.T) { sigHashes := txscript.NewTxSigHashes(tx, fetcher) // Arrange: Set up the wallet and mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Mock address store and managed address. mocks.addrStore.On("Address", mock.Anything, addr). @@ -1037,7 +1037,7 @@ func TestComputeUnlockingScriptFail_Tweak(t *testing.T) { sigHashes := txscript.NewTxSigHashes(tx, fetcher) // Arrange: Set up the wallet and mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Mock address store and managed address. mocks.addrStore.On("Address", mock.Anything, addr). @@ -1087,7 +1087,7 @@ func TestComputeUnlockingScriptFail_UnsupportedAddr(t *testing.T) { sigHashes := txscript.NewTxSigHashes(tx, fetcher) // Arrange: Set up the wallet and mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) // Mock address store and managed address. mocks.addrStore.On("Address", mock.Anything, addr). @@ -1119,7 +1119,7 @@ func TestComputeUnlockingScriptUnknownAddrType(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, mocks, keys, and transaction. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, pubKey := deterministicPrivKey(t) privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) @@ -1177,7 +1177,7 @@ func TestComputeRawSigLegacyP2PKH(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, mocks, and a deterministic private key. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, pubKey := deterministicPrivKey(t) // Create a P2PKH address from the public key. @@ -1255,7 +1255,7 @@ func TestComputeRawSigLegacyP2SH(t *testing.T) { // Arrange: Set up the wallet with mocks and a deterministic private // key for testing. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, pubKey := deterministicPrivKey(t) privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) @@ -1324,7 +1324,7 @@ func TestComputeRawSigSegwitV0(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, mocks, and a deterministic private key. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, pubKey := deterministicPrivKey(t) // Create a P2WKH address from the public key. @@ -1404,7 +1404,7 @@ func TestComputeRawSigTaprootKeySpendPath(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, mocks, and a deterministic private key. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, internalKey := deterministicPrivKey(t) // Create a P2TR address from the public key. @@ -1479,7 +1479,7 @@ func TestComputeRawSigTaprootScriptPath(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, mocks, and a deterministic private key. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, internalKey := deterministicPrivKey(t) // Create a script to spend. @@ -1585,7 +1585,7 @@ func TestComputeRawSigFail(t *testing.T) { // the raw signature computation, the error is correctly propagated. t.Run("Fetch Address Fail", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). Return((*mockAccountStore)(nil), errManagerNotFound).Once() @@ -1608,7 +1608,7 @@ func TestComputeRawSigFail(t *testing.T) { // correctly propagated. t.Run("PrivKey Fail", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). Return(mocks.accountManager, nil).Once() @@ -1637,7 +1637,7 @@ func TestComputeRawSigFail(t *testing.T) { // that error. t.Run("Tweak Fail", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). Return(mocks.accountManager, nil).Once() @@ -1671,7 +1671,7 @@ func TestComputeRawSigFail(t *testing.T) { // correctly propagates that error. t.Run("Sign Fail", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). Return(mocks.accountManager, nil).Once() @@ -1706,7 +1706,7 @@ func TestComputeRawSigFail(t *testing.T) { // invalid configurations. t.Run("Invalid Taproot Path", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0086} mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). @@ -1736,7 +1736,7 @@ func TestComputeRawSigFail(t *testing.T) { // error is correctly propagated. t.Run("Segwit Sign Fail", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). Return(mocks.accountManager, nil).Once() mocks.accountManager.On("DeriveFromKeyPath", @@ -1766,7 +1766,7 @@ func TestComputeRawSigFail(t *testing.T) { // propagated. t.Run("Taproot KeyPath Sign Fail", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). Return(mocks.accountManager, nil).Once() mocks.accountManager.On("DeriveFromKeyPath", @@ -1794,7 +1794,7 @@ func TestComputeRawSigFail(t *testing.T) { // propagated. t.Run("Taproot ScriptPath Sign Fail", func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). Return(mocks.accountManager, nil).Once() mocks.accountManager.On("DeriveFromKeyPath", @@ -1827,7 +1827,7 @@ func TestDerivePrivKeySuccess(t *testing.T) { // Arrange: Set up the wallet with mocks, a test key, and a // derivation path. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -1865,7 +1865,7 @@ func TestDerivePrivKeyFails(t *testing.T) { // Arrange: Set up the wallet and a test path. Configure the mock // addrStore to return an error when fetching the key manager. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} mocks.addrStore.On("FetchScopedKeyManager", path.KeyScope). @@ -1884,7 +1884,7 @@ func TestGetPrivKeyForAddressSuccess(t *testing.T) { t.Parallel() // Arrange: Set up the wallet, mocks, and a deterministic private key. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) privKey, pubKey := deterministicPrivKey(t) // Create a P2PKH address from the public key. @@ -1917,7 +1917,7 @@ func TestGetPrivKeyForAddressFail(t *testing.T) { t.Parallel() // Arrange: Set up the wallet and mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) addr, err := btcutil.NewAddressPubKeyHash( make([]byte, 20), w.cfg.ChainParams, ) @@ -1948,7 +1948,7 @@ func TestGetPrivKeyForAddressFail(t *testing.T) { func TestDerivePrivKeyFail(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} // Test Case 1: Fetching key manager fails. @@ -1982,7 +1982,7 @@ func TestDerivePrivKeyFail(t *testing.T) { func TestECDHFail(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createUnlockedWalletWithMocks(t) path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} privKey, _ := btcec.NewPrivateKey() @@ -2012,3 +2012,22 @@ func TestECDHFail(t *testing.T) { _, err = w.ECDH(t.Context(), path, privKey.PubKey()) require.ErrorContains(t, err, "cannot get private key") } + +// TestSignDigestLocked tests that SignDigest fails when the wallet is locked. +func TestSignDigestLocked(t *testing.T) { + t.Parallel() + + // Arrange: Create a locked wallet. + w, _ := createStartedWalletWithMocks(t) + path := BIP32Path{KeyScope: waddrmgr.KeyScopeBIP0084} + intent := &SignDigestIntent{ + Digest: make([]byte, 32), + SigType: SigTypeECDSA, + } + + // Act: Call SignDigest. + _, err := w.SignDigest(t.Context(), path, intent) + + // Assert: Check for forbidden/locked error. + require.ErrorIs(t, err, ErrStateForbidden) +} diff --git a/wallet/tx_creator_test.go b/wallet/tx_creator_test.go index 7c53b969f1..00c1929976 100644 --- a/wallet/tx_creator_test.go +++ b/wallet/tx_creator_test.go @@ -341,7 +341,7 @@ func (u *unsupportedCoinSource) isCoinSource() {} func TestDetermineChangeSource(t *testing.T) { t.Parallel() - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Define a set of accounts to be reused across test cases. explicitChangeSource := &ScopedAccount{ @@ -430,7 +430,7 @@ func (m *mockReadTx) ReadBucket(key []byte) walletdb.ReadBucket { func TestGetEligibleUTXOsFromList(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Define a block stamp for the current chain height. currentHeight := int32(100) @@ -574,7 +574,7 @@ func TestGetEligibleUTXOsFromAccount(t *testing.T) { keyScope := waddrmgr.KeyScopeBIP0086 minconf := uint32(1) - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) accountStore := &mockAccountStore{} mocks.addrStore.On("FetchScopedKeyManager", keyScope). Return(accountStore, nil) @@ -617,14 +617,14 @@ func TestGetEligibleUTXOs(t *testing.T) { testCases := []struct { name string source CoinSource - setupMocks func(m *mockers, source CoinSource) + setupMocks func(m *mockWalletDeps, source CoinSource) expectedErr error }{ { name: "scoped account", source: scopedAccount, setupMocks: func( - m *mockers, source CoinSource, + m *mockWalletDeps, source CoinSource, ) { m.chain.On("BlockStamp").Return( @@ -652,7 +652,7 @@ func TestGetEligibleUTXOs(t *testing.T) { source: &CoinSourceUTXOs{ UTXOs: []wire.OutPoint{utxo}, }, - setupMocks: func(m *mockers, source CoinSource) { + setupMocks: func(m *mockWalletDeps, source CoinSource) { m.chain.On("BlockStamp").Return( &waddrmgr.BlockStamp{}, nil, ) @@ -663,7 +663,7 @@ func TestGetEligibleUTXOs(t *testing.T) { { name: "nil source", source: nil, - setupMocks: func(m *mockers, source CoinSource) { + setupMocks: func(m *mockWalletDeps, source CoinSource) { m.chain.On("BlockStamp").Return( &waddrmgr.BlockStamp{}, nil, ) @@ -675,7 +675,7 @@ func TestGetEligibleUTXOs(t *testing.T) { { name: "unsupported source", source: &unsupportedCoinSource{}, - setupMocks: func(m *mockers, source CoinSource) { + setupMocks: func(m *mockWalletDeps, source CoinSource) { m.chain.On("BlockStamp").Return( &waddrmgr.BlockStamp{}, nil, ) @@ -688,7 +688,7 @@ func TestGetEligibleUTXOs(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) tc.setupMocks(mocks, tc.source) _, err := w.getEligibleUTXOs( @@ -708,7 +708,7 @@ func TestGetEligibleUTXOs(t *testing.T) { func TestCreateManualInputSource(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) dbtx := &mockReadTx{} utxo1 := wire.OutPoint{Hash: [32]byte{1}, Index: 0} @@ -802,7 +802,7 @@ func TestCreatePolicyInputSource(t *testing.T) { testCases := []struct { name string policy *InputsPolicy - setupMocks func(m *mockers) + setupMocks func(m *mockWalletDeps) expectedErr error }{ { @@ -812,7 +812,7 @@ func TestCreatePolicyInputSource(t *testing.T) { Source: nil, MinConfs: 1, }, - setupMocks: func(m *mockers) { + setupMocks: func(m *mockWalletDeps) { m.chain.On("BlockStamp").Return( &waddrmgr.BlockStamp{}, nil, ).Once() @@ -827,7 +827,7 @@ func TestCreatePolicyInputSource(t *testing.T) { Source: nil, MinConfs: 1, }, - setupMocks: func(m *mockers) { + setupMocks: func(m *mockWalletDeps) { m.chain.On("BlockStamp").Return( &waddrmgr.BlockStamp{}, nil, ).Once() @@ -841,7 +841,7 @@ func TestCreatePolicyInputSource(t *testing.T) { Source: nil, MinConfs: 1, }, - setupMocks: func(m *mockers) { + setupMocks: func(m *mockWalletDeps) { m.chain.On("BlockStamp").Return( &waddrmgr.BlockStamp{}, nil, ).Once() @@ -858,7 +858,7 @@ func TestCreatePolicyInputSource(t *testing.T) { Source: nil, MinConfs: 1, }, - setupMocks: func(m *mockers) { + setupMocks: func(m *mockWalletDeps) { m.chain.On("BlockStamp").Return( &waddrmgr.BlockStamp{}, nil, ).Once() @@ -873,7 +873,7 @@ func TestCreatePolicyInputSource(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) tc.setupMocks(mocks) source, err := w.createPolicyInputSource( @@ -921,13 +921,13 @@ func TestCreateInputSource(t *testing.T) { testCases := []struct { name string intent *TxIntent - setupMocks func(m *mockers) + setupMocks func(m *mockWalletDeps) expectedErr error }{ { name: "manual inputs", intent: intentManual, - setupMocks: func(m *mockers) { + setupMocks: func(m *mockWalletDeps) { m.txStore.On("GetUtxo", mock.Anything, utxo). Return(credit, nil).Once() }, @@ -935,7 +935,7 @@ func TestCreateInputSource(t *testing.T) { { name: "policy inputs", intent: intentPolicy, - setupMocks: func(m *mockers) { + setupMocks: func(m *mockWalletDeps) { m.chain.On("BlockStamp").Return( &waddrmgr.BlockStamp{}, nil, ).Once() @@ -947,7 +947,7 @@ func TestCreateInputSource(t *testing.T) { { name: "unsupported inputs", intent: intentUnsupported, - setupMocks: func(m *mockers) {}, + setupMocks: func(m *mockWalletDeps) {}, expectedErr: ErrUnsupportedTxInputs, }, } @@ -956,7 +956,7 @@ func TestCreateInputSource(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) tc.setupMocks(mocks) source, err := w.createInputSource(dbtx, tc.intent) @@ -978,7 +978,8 @@ func TestCreateTransactionSuccessManualInputs(t *testing.T) { t.Parallel() // Arrange. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) + mocks.syncer.On("syncState").Return(syncStateSynced).Once() privKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -1071,7 +1072,8 @@ func TestCreateTransactionSuccessNilChangeSourceManualInputs(t *testing.T) { t.Parallel() // Arrange. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) + mocks.syncer.On("syncState").Return(syncStateSynced).Once() privKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -1163,7 +1165,8 @@ func TestCreateTransactionSuccessNilChangeSourcePolicyInputs(t *testing.T) { t.Parallel() // Arrange. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) + mocks.syncer.On("syncState").Return(syncStateSynced).Once() privKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -1266,7 +1269,7 @@ func TestCreateTransactionSuccessNilChangeSourcePolicyInputs(t *testing.T) { mock.Anything, testAddr, ).Return(mockAddr, nil) mocks.addrStore.On("AddrAccount", - mock.Anything, testAddr, + mock.Anything, mock.Anything, ).Return(accountStore, uint32(1), nil) accountStore.On("Scope").Return(waddrmgr.KeyScopeBIP0086) @@ -1288,7 +1291,8 @@ func TestCreateTransactionInvalidIntent(t *testing.T) { t.Parallel() // Arrange. - w, _ := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) + mocks.syncer.On("syncState").Return(syncStateSynced).Once() intent := &TxIntent{ Outputs: []wire.TxOut{}, // No outputs @@ -1308,7 +1312,8 @@ func TestCreateTransactionAccountNotFound(t *testing.T) { t.Parallel() // Arrange. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) + mocks.syncer.On("syncState").Return(syncStateSynced).Once() privKey, err := btcec.NewPrivateKey() require.NoError(t, err) diff --git a/wallet/tx_publisher_test.go b/wallet/tx_publisher_test.go index 575698252c..ab7d4fd13d 100644 --- a/wallet/tx_publisher_test.go +++ b/wallet/tx_publisher_test.go @@ -87,7 +87,7 @@ func TestCheckMempoolAcceptance(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, m := testWalletWithMocks(t) + w, m := createStartedWalletWithMocks(t) if tc.tx != nil { m.chain.On("TestMempoolAccept", @@ -220,7 +220,7 @@ func TestExtractTxAddrs(t *testing.T) { t.Parallel() // Create a new test wallet. - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) // Create the test transaction. testData := createTestTx(t, w) @@ -255,7 +255,7 @@ func TestFilterOwnedAddresses(t *testing.T) { t.Parallel() // Create a new test wallet with mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Create two addresses, one owned and one not. ownedPrivKey, err := btcec.NewPrivateKey() @@ -357,7 +357,8 @@ func TestRecordTxAndCredits(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + + w, mocks := createStartedWalletWithMocks(t) txid := tx.TxHash() label := "" @@ -436,7 +437,8 @@ func TestAddTxToWallet(t *testing.T) { t.Run("tx with owned outputs", func(t *testing.T) { t.Parallel() - w, m := testWalletWithMocks(t) + + w, m := createStartedWalletWithMocks(t) // This test case simulates the scenario where the // transaction has outputs owned by the wallet. We expect @@ -499,7 +501,8 @@ func TestAddTxToWallet(t *testing.T) { t.Run("tx with no owned outputs", func(t *testing.T) { t.Parallel() - w, m := testWalletWithMocks(t) + + w, m := createStartedWalletWithMocks(t) // This test case simulates the scenario where the // transaction has no outputs owned by the wallet. We @@ -544,7 +547,7 @@ func mustPayToAddrScript(addr btcutil.Address) []byte { func TestRemoveUnminedTx(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Create a sample transaction with one input and one output. tx := &wire.MsgTx{ @@ -620,7 +623,7 @@ func TestCheckMempool(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, m := testWalletWithMocks(t) + w, m := createStartedWalletWithMocks(t) // Setup the mock for TestMempoolAccept. if tc.mempoolAcceptErr == nil { @@ -683,7 +686,7 @@ func TestPublishTx(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - w, m := testWalletWithMocks(t) + w, m := createStartedWalletWithMocks(t) m.chain.On("NotifyReceived", mock.Anything, mock.Anything, @@ -708,7 +711,7 @@ func TestBroadcastSuccess(t *testing.T) { t.Parallel() label := testTxLabel - w, m := testWalletWithMocks(t) + w, m := createStartedWalletWithMocks(t) // Create a transaction with an owned output. ownedPrivKey, err := btcec.NewPrivateKey() @@ -765,7 +768,7 @@ func TestBroadcastAlreadyBroadcasted(t *testing.T) { t.Parallel() label := testTxLabel - w, m := testWalletWithMocks(t) + w, m := createStartedWalletWithMocks(t) tx := &wire.MsgTx{ TxIn: []*wire.TxIn{{}}, @@ -786,7 +789,7 @@ func TestBroadcastPublishFailsRemoveSucceeds(t *testing.T) { t.Parallel() label := testTxLabel - w, m := testWalletWithMocks(t) + w, m := createStartedWalletWithMocks(t) // Create a transaction with an owned output. ownedPrivKey, err := btcec.NewPrivateKey() @@ -848,7 +851,7 @@ func TestBroadcastPublishFailsRemoveFails(t *testing.T) { t.Parallel() label := testTxLabel - w, m := testWalletWithMocks(t) + w, m := createStartedWalletWithMocks(t) // Create a transaction with an owned output. ownedPrivKey, err := btcec.NewPrivateKey() @@ -916,7 +919,7 @@ func TestBroadcastNilTx(t *testing.T) { t.Parallel() label := testTxLabel - w, _ := testWalletWithMocks(t) + w, _ := createStartedWalletWithMocks(t) err := w.Broadcast(t.Context(), nil, label) require.ErrorIs(t, err, ErrTxCannotBeNil) diff --git a/wallet/tx_reader_test.go b/wallet/tx_reader_test.go index 4aba310f23..7196f5ea85 100644 --- a/wallet/tx_reader_test.go +++ b/wallet/tx_reader_test.go @@ -13,7 +13,6 @@ import ( "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcwallet/pkg/btcunit" - "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -60,8 +59,8 @@ func TestBuildTxDetail(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() // Arrange: Create a test wallet with mocks. - w, _ := testWalletWithMocks(t) - currentHeight := int32(100) + w, _ := createStartedWalletWithMocks(t) + currentHeight := int32(1) // Act: Build the TxDetail. result := w.buildTxDetail(tc.details, currentHeight) @@ -100,12 +99,9 @@ func TestGetTxSuccess(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() // Arrange: Create a test wallet with mocks. - w, mocks := testWalletWithMocks(t) - mocks.addrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{ - Height: 100, - }, - ) + w, mocks := createStartedWalletWithMocks(t) + // SyncedTo is mocked in createStartedWalletWithMocks (height 1). + mocks.txStore.On("TxDetails", mock.Anything, TstTxHash). Return(tc.mockDetails, nil).Once() @@ -126,7 +122,7 @@ func TestGetTxNotFound(t *testing.T) { // Arrange: Create a test wallet with mocks and mock the TxDetails call // to return nil, simulating a non-existing tx. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) mocks.txStore.On("TxDetails", mock.Anything, TstTxHash). Return(nil, nil).Once() @@ -142,12 +138,10 @@ func TestListTxnsSuccess(t *testing.T) { t.Parallel() // Arrange: Create a test wallet with mocks and a mock tx record. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) _, expectedTxDetail := createMinedTxDetail(t) - mocks.addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{ - Height: 100, - }) + // SyncedTo is mocked in createStartedWalletWithMocks (height 1). // Set up the mock for the tx store. We use .Run to execute the // callback function that's passed in as an argument to the mock. @@ -297,11 +291,12 @@ func createMinedTxDetail(t *testing.T) (*wtxmgr.TxDetails, *TxDetail) { t.Helper() minedDetails, minedTxDetail := createUnminedTxDetail(t) - minedDetails.Block.Height = 100 + // Set height to 1 to match the default SyncedTo mock (height 1). + minedDetails.Block.Height = 1 minedDetails.Block.Time = time.Unix(1616161617, 0) minedTxDetail.Confirmations = 1 minedTxDetail.Block = &BlockDetails{ - Height: 100, + Height: 1, Timestamp: minedDetails.Block.Time.Unix(), } diff --git a/wallet/tx_writer_test.go b/wallet/tx_writer_test.go index 0cdc144889..8545a82ae8 100644 --- a/wallet/tx_writer_test.go +++ b/wallet/tx_writer_test.go @@ -16,7 +16,7 @@ import ( func TestLabelTxSuccess(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock the TxDetails call to simulate a known transaction. // We return a non-nil TxDetails to pass the check. @@ -43,7 +43,7 @@ func TestLabelTxSuccess(t *testing.T) { func TestLabelTxNotFound(t *testing.T) { t.Parallel() - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Arrange: Mock the TxDetails call to return nil, simulating a tx // that is not known to the wallet. diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index 20f4344176..1205c187de 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -23,7 +23,7 @@ func TestListUnspent(t *testing.T) { t.Parallel() // Create a new test wallet with mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Define account names. account1 := defaultAccountName @@ -44,11 +44,8 @@ func TestListUnspent(t *testing.T) { ) require.NoError(t, err) - // Set the current block height to be 100. - currentHeight := int32(100) - mocks.addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{ - Height: currentHeight, - }) + // Set the current block height to match the default mock (1). + currentHeight := int32(1) mocks.addrStore.On("AddressDetails", mock.Anything, addrDefault).Return( false, account1, waddrmgr.WitnessPubKey, @@ -199,7 +196,7 @@ func TestGetUtxo(t *testing.T) { t.Parallel() // Create a new test wallet with mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Define account names. account1 := "default" @@ -212,11 +209,8 @@ func TestGetUtxo(t *testing.T) { ) require.NoError(t, err) - // Set the current block height to be 100. - currentHeight := int32(100) - mocks.addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{ - Height: currentHeight, - }) + // Set the current block height to match the default mock (1). + currentHeight := int32(1) mocks.addrStore.On("AddressDetails", mock.Anything, addrDefault).Return( false, account1, waddrmgr.WitnessPubKey, @@ -269,13 +263,7 @@ func TestGetUtxo_Err(t *testing.T) { t.Parallel() // Create a new test wallet with mocks. - w, mocks := testWalletWithMocks(t) - - // Set the current block height to be 100. - currentHeight := int32(100) - mocks.addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{ - Height: currentHeight, - }) + w, mocks := createStartedWalletWithMocks(t) // Test the case where the UTXO is not found. utxoNotFound := wire.OutPoint{ @@ -295,7 +283,7 @@ func TestLeaseOutput(t *testing.T) { t.Parallel() // Create a new test wallet with mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Create a UTXO. utxo := wire.OutPoint{ @@ -323,7 +311,7 @@ func TestReleaseOutput(t *testing.T) { t.Parallel() // Create a new test wallet with mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Create a UTXO. utxo := wire.OutPoint{ @@ -347,7 +335,7 @@ func TestListLeasedOutputs(t *testing.T) { t.Parallel() // Create a new test wallet with mocks. - w, mocks := testWalletWithMocks(t) + w, mocks := createStartedWalletWithMocks(t) // Create a leased output. leasedOutput := &wtxmgr.LockedOutput{ From 1e02e376fa1935c927367abff62a1c46299063d3 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 23 Jan 2026 18:08:42 +0800 Subject: [PATCH 292/691] wallet: move deprecated tests to deprecated_test.go --- wallet/account_manager_test.go | 53 + wallet/chainntfns_test.go | 310 ----- wallet/common_test.go | 13 + wallet/createtx_test.go | 593 --------- wallet/deprecated_test.go | 2291 ++++++++++++++++++++++++++++++++ wallet/example_test.go | 198 --- wallet/import_test.go | 295 ---- wallet/psbt_test.go | 516 ------- wallet/signer_test.go | 94 -- wallet/wallet_test.go | 634 ++------- wallet/watchingonly_test.go | 31 - 11 files changed, 2444 insertions(+), 2584 deletions(-) delete mode 100644 wallet/chainntfns_test.go delete mode 100644 wallet/createtx_test.go create mode 100644 wallet/deprecated_test.go delete mode 100644 wallet/example_test.go delete mode 100644 wallet/import_test.go delete mode 100644 wallet/psbt_test.go delete mode 100644 wallet/watchingonly_test.go diff --git a/wallet/account_manager_test.go b/wallet/account_manager_test.go index 92d01c0ff0..65a1a9e8ef 100644 --- a/wallet/account_manager_test.go +++ b/wallet/account_manager_test.go @@ -5,6 +5,8 @@ package wallet import ( + "encoding/binary" + "strings" "testing" "github.com/btcsuite/btcd/btcutil" @@ -18,6 +20,57 @@ import ( "github.com/stretchr/testify/require" ) +func hardenedKey(key uint32) uint32 { + return key + hdkeychain.HardenedKeyStart +} + +func deriveAcctPubKey(t *testing.T, root *hdkeychain.ExtendedKey, + scope waddrmgr.KeyScope, paths ...uint32) *hdkeychain.ExtendedKey { + + t.Helper() + + path := []uint32{hardenedKey(scope.Purpose), hardenedKey(scope.Coin)} + path = append(path, paths...) + + var ( + currentKey = root + err error + ) + for _, pathPart := range path { + currentKey, err = currentKey.Derive(pathPart) + require.NoError(t, err) + } + + // The Neuter() method checks the version and doesn't know any + // non-standard methods. We need to convert them to standard, neuter, + // then convert them back with the target extended public key version. + pubVersionBytes := make([]byte, 4) + copy(pubVersionBytes, chainParams.HDPublicKeyID[:]) + + switch { + case strings.HasPrefix(root.String(), "uprv"): + binary.BigEndian.PutUint32(pubVersionBytes, uint32( + waddrmgr.HDVersionTestNetBIP0049, + )) + + case strings.HasPrefix(root.String(), "vprv"): + binary.BigEndian.PutUint32(pubVersionBytes, uint32( + waddrmgr.HDVersionTestNetBIP0084, + )) + } + + currentKey, err = currentKey.CloneWithVersion( + chainParams.HDPrivateKeyID[:], + ) + require.NoError(t, err) + currentKey, err = currentKey.Neuter() + require.NoError(t, err) + currentKey, err = currentKey.CloneWithVersion(pubVersionBytes) + require.NoError(t, err) + + return currentKey +} + const ( // testAccountName is a constant for the account name used in the tests. testAccountName = "test" diff --git a/wallet/chainntfns_test.go b/wallet/chainntfns_test.go deleted file mode 100644 index 540cee2e95..0000000000 --- a/wallet/chainntfns_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package wallet - -import ( - "fmt" - "reflect" - "testing" - "time" - - "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" - _ "github.com/btcsuite/btcwallet/walletdb/bdb" -) - -const ( - // defaultBlockInterval is the default time interval between any two - // blocks in a mocked chain. - defaultBlockInterval = 10 * time.Minute -) - -var ( - // chainParams are the chain parameters used throughout the wallet - // tests. - chainParams = chaincfg.RegressionNetParams -) - -// mockChainConn is a mock in-memory implementation of the chainConn interface -// that will be used for the birthday block sanity check tests. The struct is -// capable of being backed by a chain in order to reproduce real-world -// scenarios. -type mockChainConn struct { - chainTip uint32 - blockHashes map[uint32]chainhash.Hash - blocks map[chainhash.Hash]*wire.MsgBlock -} - -var _ chainConn = (*mockChainConn)(nil) - -// createMockChainConn creates a new mock chain connection backed by a chain -// with N blocks. Each block has a timestamp that is exactly blockInterval after -// the previous block's timestamp. -func createMockChainConn(genesis *wire.MsgBlock, n uint32, - blockInterval time.Duration) *mockChainConn { - - c := &mockChainConn{ - chainTip: n, - blockHashes: make(map[uint32]chainhash.Hash), - blocks: make(map[chainhash.Hash]*wire.MsgBlock), - } - - genesisHash := genesis.BlockHash() - c.blockHashes[0] = genesisHash - c.blocks[genesisHash] = genesis - - for i := uint32(1); i <= n; i++ { - prevTimestamp := c.blocks[c.blockHashes[i-1]].Header.Timestamp - block := &wire.MsgBlock{ - Header: wire.BlockHeader{ - Timestamp: prevTimestamp.Add(blockInterval), - }, - } - - blockHash := block.BlockHash() - c.blockHashes[i] = blockHash - c.blocks[blockHash] = block - } - - return c -} - -// GetBestBlock returns the hash and height of the best block known to the -// backend. -func (c *mockChainConn) GetBestBlock() (*chainhash.Hash, int32, error) { - bestHash, ok := c.blockHashes[c.chainTip] - if !ok { - return nil, 0, fmt.Errorf("block with height %d not found", - c.chainTip) - } - - return &bestHash, int32(c.chainTip), nil -} - -// GetBlockHash returns the hash of the block with the given height. -func (c *mockChainConn) GetBlockHash(height int64) (*chainhash.Hash, error) { - hash, ok := c.blockHashes[uint32(height)] - if !ok { - return nil, fmt.Errorf("block with height %d not found", height) - } - - return &hash, nil -} - -// GetBlockHeader returns the header for the block with the given hash. -func (c *mockChainConn) GetBlockHeader(hash *chainhash.Hash) (*wire.BlockHeader, error) { - block, ok := c.blocks[*hash] - if !ok { - return nil, fmt.Errorf("header for block %v not found", hash) - } - - return &block.Header, nil -} - -// mockBirthdayStore is a mock in-memory implementation of the birthdayStore interface -// that will be used for the birthday block sanity check tests. -type mockBirthdayStore struct { - birthday time.Time - birthdayBlock *waddrmgr.BlockStamp - birthdayBlockVerified bool - syncedTo waddrmgr.BlockStamp -} - -var _ birthdayStore = (*mockBirthdayStore)(nil) - -// Birthday returns the birthday timestamp of the wallet. -func (s *mockBirthdayStore) Birthday() time.Time { - return s.birthday -} - -// BirthdayBlock returns the birthday block of the wallet. -func (s *mockBirthdayStore) BirthdayBlock() (waddrmgr.BlockStamp, bool, error) { - if s.birthdayBlock == nil { - err := waddrmgr.ManagerError{ - ErrorCode: waddrmgr.ErrBirthdayBlockNotSet, - } - return waddrmgr.BlockStamp{}, false, err - } - - return *s.birthdayBlock, s.birthdayBlockVerified, nil -} - -// SetBirthdayBlock updates the birthday block of the wallet to the given block. -// The boolean can be used to signal whether this block should be sanity checked -// the next time the wallet starts. -func (s *mockBirthdayStore) SetBirthdayBlock(block waddrmgr.BlockStamp) error { - s.birthdayBlock = &block - s.birthdayBlockVerified = true - s.syncedTo = block - return nil -} - -// TestBirthdaySanityCheckEmptyBirthdayBlock ensures that a sanity check is not -// done if the birthday block does not exist in the first place. -func TestBirthdaySanityCheckEmptyBirthdayBlock(t *testing.T) { - t.Parallel() - - chainConn := &mockChainConn{} - - // Our birthday store will reflect that we don't have a birthday block - // set, so we should not attempt a sanity check. - birthdayStore := &mockBirthdayStore{} - - birthdayBlock, err := birthdaySanityCheck(chainConn, birthdayStore) - if !waddrmgr.IsError(err, waddrmgr.ErrBirthdayBlockNotSet) { - t.Fatalf("expected ErrBirthdayBlockNotSet, got %v", err) - } - - if birthdayBlock != nil { - t.Fatalf("expected birthday block to be nil due to not being "+ - "set, got %v", *birthdayBlock) - } -} - -// TestBirthdaySanityCheckVerifiedBirthdayBlock ensures that a sanity check is -// not performed if the birthday block has already been verified. -func TestBirthdaySanityCheckVerifiedBirthdayBlock(t *testing.T) { - t.Parallel() - - const chainTip = 5000 - chainConn := createMockChainConn( - chainParams.GenesisBlock, chainTip, defaultBlockInterval, - ) - expectedBirthdayBlock := waddrmgr.BlockStamp{Height: 1337} - - // Our birthday store reflects that our birthday block has already been - // verified and should not require a sanity check. - birthdayStore := &mockBirthdayStore{ - birthdayBlock: &expectedBirthdayBlock, - birthdayBlockVerified: true, - syncedTo: waddrmgr.BlockStamp{ - Height: chainTip, - }, - } - - // Now, we'll run the sanity check. We should see that the birthday - // block hasn't changed. - birthdayBlock, err := birthdaySanityCheck(chainConn, birthdayStore) - if err != nil { - t.Fatalf("unable to sanity check birthday block: %v", err) - } - if !reflect.DeepEqual(*birthdayBlock, expectedBirthdayBlock) { - t.Fatalf("expected birthday block %v, got %v", - expectedBirthdayBlock, birthdayBlock) - } - - // To ensure the sanity check didn't proceed, we'll check our synced to - // height, as this value should have been modified if a new candidate - // was found. - if birthdayStore.syncedTo.Height != chainTip { - t.Fatalf("expected synced height remain the same (%d), got %d", - chainTip, birthdayStore.syncedTo.Height) - } -} - -// TestBirthdaySanityCheckLowerEstimate ensures that we can properly locate a -// better birthday block candidate if our estimate happens to be too far back in -// the chain. -func TestBirthdaySanityCheckLowerEstimate(t *testing.T) { - t.Parallel() - - // We'll start by defining our birthday timestamp to be around the - // timestamp of the 1337th block. - genesisTimestamp := chainParams.GenesisBlock.Header.Timestamp - birthday := genesisTimestamp.Add(1337 * defaultBlockInterval) - - // We'll establish a connection to a mock chain of 5000 blocks. - chainConn := createMockChainConn( - chainParams.GenesisBlock, 5000, defaultBlockInterval, - ) - - // Our birthday store will reflect that our birthday block is currently - // set as the genesis block. This value is too low and should be - // adjusted by the sanity check. - birthdayStore := &mockBirthdayStore{ - birthday: birthday, - birthdayBlock: &waddrmgr.BlockStamp{ - Hash: *chainParams.GenesisHash, - Height: 0, - Timestamp: genesisTimestamp, - }, - birthdayBlockVerified: false, - syncedTo: waddrmgr.BlockStamp{ - Height: 5000, - }, - } - - // We'll perform the sanity check and determine whether we were able to - // find a better birthday block candidate. - birthdayBlock, err := birthdaySanityCheck(chainConn, birthdayStore) - if err != nil { - t.Fatalf("unable to sanity check birthday block: %v", err) - } - if birthday.Sub(birthdayBlock.Timestamp) >= birthdayBlockDelta { - t.Fatalf("expected birthday block timestamp=%v to be within "+ - "%v of birthday timestamp=%v", birthdayBlock.Timestamp, - birthdayBlockDelta, birthday) - } - - // Finally, our synced to height should now reflect our new birthday - // block to ensure the wallet doesn't miss any events from this point - // forward. - if !reflect.DeepEqual(birthdayStore.syncedTo, *birthdayBlock) { - t.Fatalf("expected syncedTo and birthday block to match: "+ - "%v vs %v", birthdayStore.syncedTo, birthdayBlock) - } -} - -// TestBirthdaySanityCheckHigherEstimate ensures that we can properly locate a -// better birthday block candidate if our estimate happens to be too far in the -// chain. -func TestBirthdaySanityCheckHigherEstimate(t *testing.T) { - t.Parallel() - - // We'll start by defining our birthday timestamp to be around the - // timestamp of the 1337th block. - genesisTimestamp := chainParams.GenesisBlock.Header.Timestamp - birthday := genesisTimestamp.Add(1337 * defaultBlockInterval) - - // We'll establish a connection to a mock chain of 5000 blocks. - chainConn := createMockChainConn( - chainParams.GenesisBlock, 5000, defaultBlockInterval, - ) - - // Our birthday store will reflect that our birthday block is currently - // set as the chain tip. This value is too high and should be adjusted - // by the sanity check. - bestBlock := chainConn.blocks[chainConn.blockHashes[5000]] - birthdayStore := &mockBirthdayStore{ - birthday: birthday, - birthdayBlock: &waddrmgr.BlockStamp{ - Hash: bestBlock.BlockHash(), - Height: 5000, - Timestamp: bestBlock.Header.Timestamp, - }, - birthdayBlockVerified: false, - syncedTo: waddrmgr.BlockStamp{ - Height: 5000, - }, - } - - // We'll perform the sanity check and determine whether we were able to - // find a better birthday block candidate. - birthdayBlock, err := birthdaySanityCheck(chainConn, birthdayStore) - if err != nil { - t.Fatalf("unable to sanity check birthday block: %v", err) - } - if birthday.Sub(birthdayBlock.Timestamp) >= birthdayBlockDelta { - t.Fatalf("expected birthday block timestamp=%v to be within "+ - "%v of birthday timestamp=%v", birthdayBlock.Timestamp, - birthdayBlockDelta, birthday) - } - - // Finally, our synced to height should now reflect our new birthday - // block to ensure the wallet doesn't miss any events from this point - // forward. - if !reflect.DeepEqual(birthdayStore.syncedTo, *birthdayBlock) { - t.Fatalf("expected syncedTo and birthday block to match: "+ - "%v vs %v", birthdayStore.syncedTo, birthdayBlock) - } -} diff --git a/wallet/common_test.go b/wallet/common_test.go index 629f3daa6e..08b266a5d5 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" _ "github.com/btcsuite/btcwallet/walletdb/bdb" @@ -49,6 +50,12 @@ var ( errHeader = errors.New("header fail") ) +var ( + // chainParams are the chain parameters used throughout the wallet + // tests. + chainParams = chaincfg.RegressionNetParams +) + // setupTestDB creates a temporary database for testing. func setupTestDB(t *testing.T) (walletdb.DB, func()) { t.Helper() @@ -231,3 +238,9 @@ func createUnlockedWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { return w, deps } + +func init() { + // Use fast scrypt options for tests to avoid CPU exhaustion and + // timeouts, especially when running with -race. + waddrmgr.DefaultScryptOptions = waddrmgr.FastScryptOptions +} diff --git a/wallet/createtx_test.go b/wallet/createtx_test.go deleted file mode 100644 index ff38c32672..0000000000 --- a/wallet/createtx_test.go +++ /dev/null @@ -1,593 +0,0 @@ -// Copyright (c) 2018 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "bytes" - "testing" - "time" - - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/wallet/txauthor" - "github.com/btcsuite/btcwallet/walletdb" - _ "github.com/btcsuite/btcwallet/walletdb/bdb" - "github.com/btcsuite/btcwallet/wtxmgr" - "github.com/stretchr/testify/require" -) - -var ( - testBlockHash, _ = chainhash.NewHashFromStr( - "00000000000000017188b968a371bab95aa43522665353b646e41865abae" + - "02a4", - ) - testBlockHeight int32 = 276425 - - alwaysAllowUtxo = func(utxo wtxmgr.Credit) bool { return true } -) - -// TestTxToOutput checks that no new address is added to he database if we -// request a dry run of the txToOutputs call. It also makes sure a subsequent -// non-dry run call produces a similar transaction to the dry-run. -func TestTxToOutputsDryRun(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // Create an address we can use to send some coins to. - keyScope := waddrmgr.KeyScopeBIP0049Plus - addr, err := w.CurrentAddress(0, keyScope) - if err != nil { - t.Fatalf("unable to get current address: %v", addr) - } - p2shAddr, err := txscript.PayToAddrScript(addr) - if err != nil { - t.Fatalf("unable to convert wallet address to p2sh: %v", err) - } - - // Add an output paying to the wallet's address to the database. - txOut := wire.NewTxOut(100000, p2shAddr) - incomingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - txOut, - }, - } - addUtxo(t, w, incomingTx) - - // Now tell the wallet to create a transaction paying to the specified - // outputs. - txOuts := []*wire.TxOut{ - { - PkScript: p2shAddr, - Value: 10000, - }, - { - PkScript: p2shAddr, - Value: 20000, - }, - } - - // First do a few dry-runs, making sure the number of addresses in the - // database us not inflated. - dryRunTx, err := w.txToOutputs( - txOuts, nil, nil, 0, 1, 1000, CoinSelectionLargest, true, - nil, alwaysAllowUtxo, - ) - if err != nil { - t.Fatalf("unable to author tx: %v", err) - } - change := dryRunTx.Tx.TxOut[dryRunTx.ChangeIndex] - - addresses, err := w.AccountAddresses(0) - if err != nil { - t.Fatalf("unable to get addresses: %v", err) - } - - if len(addresses) != 1 { - t.Fatalf("expected 1 address, found %v", len(addresses)) - } - - dryRunTx2, err := w.txToOutputs( - txOuts, nil, nil, 0, 1, 1000, CoinSelectionLargest, true, - nil, alwaysAllowUtxo, - ) - if err != nil { - t.Fatalf("unable to author tx: %v", err) - } - change2 := dryRunTx2.Tx.TxOut[dryRunTx2.ChangeIndex] - - addresses, err = w.AccountAddresses(0) - if err != nil { - t.Fatalf("unable to get addresses: %v", err) - } - - if len(addresses) != 1 { - t.Fatalf("expected 1 address, found %v", len(addresses)) - } - - // The two dry-run TXs should be invalid, since they don't have - // signatures. - err = validateMsgTx( - dryRunTx.Tx, dryRunTx.PrevScripts, dryRunTx.PrevInputValues, - ) - if err == nil { - t.Fatalf("Expected tx to be invalid") - } - - err = validateMsgTx( - dryRunTx2.Tx, dryRunTx2.PrevScripts, dryRunTx2.PrevInputValues, - ) - if err == nil { - t.Fatalf("Expected tx to be invalid") - } - - // Now we do a proper, non-dry run. This should add a change address - // to the database. - tx, err := w.txToOutputs( - txOuts, nil, nil, 0, 1, 1000, CoinSelectionLargest, false, - nil, alwaysAllowUtxo, - ) - if err != nil { - t.Fatalf("unable to author tx: %v", err) - } - change3 := tx.Tx.TxOut[tx.ChangeIndex] - - addresses, err = w.AccountAddresses(0) - if err != nil { - t.Fatalf("unable to get addresses: %v", err) - } - - if len(addresses) != 2 { - t.Fatalf("expected 2 addresses, found %v", len(addresses)) - } - - err = validateMsgTx(tx.Tx, tx.PrevScripts, tx.PrevInputValues) - if err != nil { - t.Fatalf("Expected tx to be valid: %v", err) - } - - // Finally, we check that all the transaction were using the same - // change address. - if !bytes.Equal(change.PkScript, change2.PkScript) { - t.Fatalf("first dry-run using different change address " + - "than second") - } - if !bytes.Equal(change2.PkScript, change3.PkScript) { - t.Fatalf("dry-run using different change address " + - "than wet run") - } -} - -// addUtxo add the given transaction to the wallet's database marked as a -// confirmed UTXO . -func addUtxo(t *testing.T, w *Wallet, incomingTx *wire.MsgTx) { - var b bytes.Buffer - if err := incomingTx.Serialize(&b); err != nil { - t.Fatalf("unable to serialize tx: %v", err) - } - txBytes := b.Bytes() - - rec, err := wtxmgr.NewTxRecord(txBytes, time.Now()) - if err != nil { - t.Fatalf("unable to create tx record: %v", err) - } - - // The block meta will be inserted to tell the wallet this is a - // confirmed transaction. - block := &wtxmgr.BlockMeta{ - Block: wtxmgr.Block{ - Hash: *testBlockHash, - Height: testBlockHeight, - }, - Time: time.Unix(1387737310, 0), - } - - if err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) - err = w.txStore.InsertTx(ns, rec, block) - if err != nil { - return err - } - // Add all tx outputs as credits. - for i := 0; i < len(incomingTx.TxOut); i++ { - err = w.txStore.AddCredit( - ns, rec, block, uint32(i), false, - ) - if err != nil { - return err - } - } - return nil - }); err != nil { - t.Fatalf("failed inserting tx: %v", err) - } -} - -// addTxAndCredit adds the given transaction to the wallet's database marked as -// a confirmed UTXO specified by the creditIndex. -func addTxAndCredit(t *testing.T, w *Wallet, tx *wire.MsgTx, - creditIndex uint32) { - - var b bytes.Buffer - require.NoError(t, tx.Serialize(&b), "unable to serialize tx") - - txBytes := b.Bytes() - - rec, err := wtxmgr.NewTxRecord(txBytes, time.Now()) - require.NoError(t, err) - - // The block meta will be inserted to tell the wallet this is a - // confirmed transaction. - block := &wtxmgr.BlockMeta{ - Block: wtxmgr.Block{ - Hash: *testBlockHash, - Height: testBlockHeight, - }, - Time: time.Unix(1387737310, 0), - } - - err = walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { - ns := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) - - err = w.txStore.InsertTx(ns, rec, block) - if err != nil { - return err - } - - // Add the specified output as credit. - err = w.txStore.AddCredit(ns, rec, block, creditIndex, false) - if err != nil { - return err - } - - return nil - }) - require.NoError(t, err, "failed inserting tx") -} - -// TestInputYield verifies the functioning of the inputYieldsPositively. -func TestInputYield(t *testing.T) { - t.Parallel() - - addr, _ := btcutil.DecodeAddress("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", &chaincfg.MainNetParams) - pkScript, err := txscript.PayToAddrScript(addr) - require.NoError(t, err) - - credit := &wire.TxOut{ - Value: 1000, - PkScript: pkScript, - } - - // At 10 sat/b this input is yielding positively. - require.True(t, inputYieldsPositively(credit, 10000)) - - // At 20 sat/b this input is yielding negatively. - require.False(t, inputYieldsPositively(credit, 20000)) -} - -// TestTxToOutputsRandom tests random coin selection. -func TestTxToOutputsRandom(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // Create an address we can use to send some coins to. - keyScope := waddrmgr.KeyScopeBIP0049Plus - addr, err := w.CurrentAddress(0, keyScope) - if err != nil { - t.Fatalf("unable to get current address: %v", addr) - } - p2shAddr, err := txscript.PayToAddrScript(addr) - if err != nil { - t.Fatalf("unable to convert wallet address to p2sh: %v", err) - } - - // Add a set of utxos to the wallet. - incomingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{}, - } - for amt := int64(5000); amt <= 125000; amt += 10000 { - incomingTx.AddTxOut(wire.NewTxOut(amt, p2shAddr)) - } - - addUtxo(t, w, incomingTx) - - // Now tell the wallet to create a transaction paying to the specified - // outputs. - txOuts := []*wire.TxOut{ - { - PkScript: p2shAddr, - Value: 50000, - }, - { - PkScript: p2shAddr, - Value: 100000, - }, - } - - const ( - feeSatPerKb = 100000 - maxIterations = 100 - ) - - createTx := func() *txauthor.AuthoredTx { - tx, err := w.txToOutputs( - txOuts, nil, nil, 0, 1, feeSatPerKb, - CoinSelectionRandom, true, nil, alwaysAllowUtxo, - ) - require.NoError(t, err) - return tx - } - - firstTx := createTx() - var isRandom bool - for iteration := 0; iteration < maxIterations; iteration++ { - tx := createTx() - - // Check to see if we are getting a total input value. - // We consider this proof that the randomization works. - if tx.TotalInput != firstTx.TotalInput { - isRandom = true - } - - // At the used fee rate of 100 sat/b, the 5000 sat input is - // negatively yielding. We don't expect it to ever be selected. - for _, inputValue := range tx.PrevInputValues { - require.NotEqual(t, inputValue, btcutil.Amount(5000)) - } - } - - require.True(t, isRandom) -} - -// TestCreateSimpleCustomChange tests that it's possible to let the -// CreateSimpleTx use all coins for coin selection, but specify a custom scope -// that isn't the current default scope. -func TestCreateSimpleCustomChange(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // First, we'll make a P2TR and a P2WKH address to send some coins to - // (two different coin scopes). - p2wkhAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) - require.NoError(t, err) - - p2trAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0086) - require.NoError(t, err) - - // We'll now make a transaction that'll send coins to both outputs, - // then "credit" the wallet for that send. - p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) - require.NoError(t, err) - p2trScript, err := txscript.PayToAddrScript(p2trAddr) - require.NoError(t, err) - - const testAmt = 1_000_000 - - incomingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - wire.NewTxOut(testAmt, p2wkhScript), - wire.NewTxOut(testAmt, p2trScript), - }, - } - addUtxo(t, w, incomingTx) - - // With the amounts credited to the wallet, we'll now do a dry run coin - // selection w/o any default args. - targetTxOut := &wire.TxOut{ - Value: 1_500_000, - PkScript: p2trScript, - } - tx1, err := w.txToOutputs( - []*wire.TxOut{targetTxOut}, nil, nil, 0, 1, 1000, - CoinSelectionLargest, true, nil, alwaysAllowUtxo, - ) - require.NoError(t, err) - - // We expect that all inputs were used and also the change output is a - // taproot output (the current default). - require.Len(t, tx1.Tx.TxIn, 2) - require.Len(t, tx1.Tx.TxOut, 2) - for _, txOut := range tx1.Tx.TxOut { - scriptType, _, _, err := txscript.ExtractPkScriptAddrs( - txOut.PkScript, w.chainParams, - ) - require.NoError(t, err) - - require.Equal(t, scriptType, txscript.WitnessV1TaprootTy) - } - - // Next, we'll do another dry run, but this time, specify a custom - // change key scope. We'll also require that only inputs of P2TR are used. - targetTxOut = &wire.TxOut{ - Value: 500_000, - PkScript: p2trScript, - } - tx2, err := w.txToOutputs( - []*wire.TxOut{targetTxOut}, &waddrmgr.KeyScopeBIP0086, - &waddrmgr.KeyScopeBIP0084, 0, 1, 1000, CoinSelectionLargest, - true, nil, alwaysAllowUtxo, - ) - require.NoError(t, err) - - // The resulting transaction should spend a single input, and use P2WKH - // as the output script. - require.Len(t, tx2.Tx.TxIn, 1) - require.Len(t, tx2.Tx.TxOut, 2) - for i, txOut := range tx2.Tx.TxOut { - if i != tx2.ChangeIndex { - continue - } - - scriptType, _, _, err := txscript.ExtractPkScriptAddrs( - txOut.PkScript, w.chainParams, - ) - require.NoError(t, err) - - require.Equal(t, scriptType, txscript.WitnessV0PubKeyHashTy) - } -} - -// TestSelectUtxosTxoToOutpoint tests that it is possible to use passed -// selected utxos to craft a transaction in `txToOutpoint`. -func TestSelectUtxosTxoToOutpoint(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // First, we'll make a P2TR and a P2WKH address to send some coins to. - p2wkhAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) - require.NoError(t, err) - - p2trAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0086) - require.NoError(t, err) - - // We'll now make a transaction that'll send coins to both outputs, - // then "credit" the wallet for that send. - p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) - require.NoError(t, err) - - p2trScript, err := txscript.PayToAddrScript(p2trAddr) - require.NoError(t, err) - - incomingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - wire.NewTxOut(1_000_000, p2wkhScript), - wire.NewTxOut(2_000_000, p2trScript), - wire.NewTxOut(3_000_000, p2trScript), - wire.NewTxOut(7_000_000, p2trScript), - }, - } - addUtxo(t, w, incomingTx) - - // We expect 4 unspent UTXOs. - unspent, err := w.ListUnspentDeprecated(0, 80, "") - require.NoError(t, err) - require.Len(t, unspent, 4, "expected 4 unspent UTXOs") - - tCases := []struct { - name string - selectUTXOs []wire.OutPoint - errString string - }{ - { - name: "Duplicate utxo values", - selectUTXOs: []wire.OutPoint{ - { - Hash: incomingTx.TxHash(), - Index: 1, - }, - { - Hash: incomingTx.TxHash(), - Index: 1, - }, - }, - errString: "selected UTXOs contain duplicate values", - }, - { - name: "all selected UTXOs not eligible for spending", - selectUTXOs: []wire.OutPoint{ - { - Hash: chainhash.Hash([32]byte{1}), - Index: 1, - }, - { - Hash: chainhash.Hash([32]byte{3}), - Index: 1, - }, - }, - errString: "selected outpoint not eligible for " + - "spending", - }, - { - name: "some select UTXOs not eligible for spending", - selectUTXOs: []wire.OutPoint{ - { - Hash: chainhash.Hash([32]byte{1}), - Index: 1, - }, - { - Hash: incomingTx.TxHash(), - Index: 1, - }, - }, - errString: "selected outpoint not eligible for " + - "spending", - }, - { - name: "select utxo, no duplicates and all eligible " + - "for spending", - selectUTXOs: []wire.OutPoint{ - { - Hash: incomingTx.TxHash(), - Index: 1, - }, - { - Hash: incomingTx.TxHash(), - Index: 2, - }, - }, - }, - } - - for _, tc := range tCases { - t.Run(tc.name, func(t *testing.T) { - // Test by sending 200_000. - targetTxOut := &wire.TxOut{ - Value: 200_000, - PkScript: p2trScript, - } - tx1, err := w.txToOutputs( - []*wire.TxOut{targetTxOut}, nil, nil, 0, 1, - 1000, CoinSelectionLargest, true, - tc.selectUTXOs, alwaysAllowUtxo, - ) - if tc.errString != "" { - require.ErrorContains(t, err, tc.errString) - require.Nil(t, tx1) - - return - } - - require.NoError(t, err) - require.NotNil(t, tx1) - - // We expect all and only our select UTXOs to be input - // in this transaction. - require.Len(t, tx1.Tx.TxIn, len(tc.selectUTXOs)) - - lookupSelectUtxos := make(map[wire.OutPoint]struct{}) - for _, utxo := range tc.selectUTXOs { - lookupSelectUtxos[utxo] = struct{}{} - } - - for _, tx := range tx1.Tx.TxIn { - _, ok := lookupSelectUtxos[tx.PreviousOutPoint] - require.True(t, ok) - } - - // Expect two outputs, change and the actual payment to - // the address. - require.Len(t, tx1.Tx.TxOut, 2) - }) - } -} diff --git a/wallet/deprecated_test.go b/wallet/deprecated_test.go new file mode 100644 index 0000000000..0070b57056 --- /dev/null +++ b/wallet/deprecated_test.go @@ -0,0 +1,2291 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "bytes" + "encoding/hex" + "fmt" + "math" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet/txauthor" + "github.com/btcsuite/btcwallet/wallet/txrules" + "github.com/btcsuite/btcwallet/wallet/txsizes" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +// TestLabelTransaction tests labelling of transactions with invalid labels, +// and failure to label a transaction when it already has a label. +func TestLabelTransaction(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + + // Whether the transaction should be known to the wallet. + txKnown bool + + // Whether the test should write an existing label to disk. + existingLabel bool + + // The overwrite parameter to call label transaction with. + overwrite bool + + // The error we expect to be returned. + expectedErr error + }{ + { + name: "existing label, not overwrite", + txKnown: true, + existingLabel: true, + overwrite: false, + expectedErr: ErrTxLabelExists, + }, + { + name: "existing label, overwritten", + txKnown: true, + existingLabel: true, + overwrite: true, + expectedErr: nil, + }, + { + name: "no prexisting label, ok", + txKnown: true, + existingLabel: false, + overwrite: false, + expectedErr: nil, + }, + { + name: "transaction unknown", + txKnown: false, + existingLabel: false, + overwrite: false, + expectedErr: ErrUnknownTransaction, + }, + } + + for _, test := range tests { + test := test + + t.Run(test.name, func(t *testing.T) { + w := testWallet(t) + + // If the transaction should be known to the store, we + // write txdetail to disk. + if test.txKnown { + rec, err := wtxmgr.NewTxRecord( + TstSerializedTx, time.Now(), + ) + if err != nil { + t.Fatal(err) + } + + err = walletdb.Update(w.db, + func(tx walletdb.ReadWriteTx) error { + + ns := tx.ReadWriteBucket( + wtxmgrNamespaceKey, + ) + + return w.txStore.InsertTx( + ns, rec, nil, + ) + }) + if err != nil { + t.Fatalf("could not insert tx: %v", err) + } + } + + // If we want to setup an existing label for the purpose + // of the test, write one to disk. + if test.existingLabel { + err := w.LabelTransaction( + *TstTxHash, "existing label", false, + ) + if err != nil { + t.Fatalf("could not write label: %v", + err) + } + } + + newLabel := "new label" + err := w.LabelTransaction( + *TstTxHash, newLabel, test.overwrite, + ) + if err != test.expectedErr { + t.Fatalf("expected: %v, got: %v", + test.expectedErr, err) + } + }) + } +} + +// TestGetTransaction tests if we can fetch a mined, an existing +// and a non-existing transaction from the wallet like we expect. +func TestGetTransaction(t *testing.T) { + t.Parallel() + rec, err := wtxmgr.NewTxRecord(TstSerializedTx, time.Now()) + require.NoError(t, err) + + tests := []struct { + name string + + // Transaction id. + txid chainhash.Hash + + // Expected height. + expectedHeight int32 + + // Store function. + f func(wtxmgr.TxStore, + walletdb.ReadWriteBucket) (wtxmgr.TxStore, error) + + // The error we expect to be returned. + expectedErr error + }{ + { + name: "existing unmined transaction", + txid: *TstTxHash, + expectedHeight: -1, + // We write txdetail for the tx to disk. + f: func(s wtxmgr.TxStore, ns walletdb.ReadWriteBucket) ( + wtxmgr.TxStore, error) { + + err = s.InsertTx(ns, rec, nil) + return s, err + }, + expectedErr: nil, + }, + { + name: "existing mined transaction", + txid: *TstTxHash, + // We write txdetail for the tx to disk. + f: func(s wtxmgr.TxStore, ns walletdb.ReadWriteBucket) ( + wtxmgr.TxStore, error) { + + err = s.InsertTx(ns, rec, TstMinedSignedTxBlockDetails) + return s, err + }, + expectedHeight: TstMinedTxBlockHeight, + expectedErr: nil, + }, + { + name: "non-existing transaction", + txid: *TstTxHash, + // Write no txdetail to disk. + f: func(s wtxmgr.TxStore, _ walletdb.ReadWriteBucket) ( + wtxmgr.TxStore, error) { + + return s, nil + }, + expectedErr: ErrNoTx, + }, + } + for _, test := range tests { + test := test + + t.Run(test.name, func(t *testing.T) { + w := testWallet(t) + + err := walletdb.Update(w.db, func(rw walletdb.ReadWriteTx) error { + ns := rw.ReadWriteBucket(wtxmgrNamespaceKey) + _, err := test.f(w.txStore, ns) + return err + }) + require.NoError(t, err) + tx, err := w.GetTransaction(test.txid) + require.ErrorIs(t, err, test.expectedErr) + + // Discontinue if no transaction were found. + if err != nil { + return + } + + // Check if we get the expected hash. + require.Equal(t, &test.txid, tx.Summary.Hash) + + // Check the block height. + require.Equal(t, test.expectedHeight, tx.Height) + }) + } +} + +// TestGetTransactionConfirmations tests that GetTransaction correctly +// calculates confirmations for both confirmed and unconfirmed transactions. +// This is a regression test for a bug where confirmations were set to the +// block height instead of being calculated as currentHeight - blockHeight + 1. +// +// The bug had several negative impacts: +// - Unconfirmed transactions showed -1 confirmations instead of 0, breaking +// zero-conf (accepting transactions before block inclusion) +// - Confirmed transactions showed block height instead of actual confirmation +// count +// - LND and other consumers would make incorrect decisions based on wrong +// counts +func TestGetTransactionConfirmations(t *testing.T) { + t.Parallel() + + rec, err := wtxmgr.NewTxRecord(TstSerializedTx, time.Now()) + require.NoError(t, err) + + tests := []struct { + name string + + // Block height where transaction is mined (-1 for unmined). + txBlockHeight int32 + + // Current wallet sync height. + currentHeight int32 + + // Expected confirmations. + expectedConfirmations int32 + + // Expected height in result. + expectedHeight int32 + + // Whether to check for non-zero timestamp. + expectTimestamp bool + }{ + { + name: "unconfirmed tx", + txBlockHeight: -1, + currentHeight: 100, + expectedConfirmations: 0, + expectedHeight: -1, + expectTimestamp: false, + }, + { + name: "tx with 1 confirmation", + txBlockHeight: 100, + currentHeight: 100, + expectedConfirmations: 1, + expectedHeight: 100, + expectTimestamp: true, + }, + { + name: "tx with 3 confirmations", + txBlockHeight: 8, + currentHeight: 10, + expectedConfirmations: 3, + expectedHeight: 8, + expectTimestamp: true, + }, + { + name: "old tx with many confirmations", + txBlockHeight: 1, + currentHeight: 1000, + expectedConfirmations: 1000, + expectedHeight: 1, + expectTimestamp: true, + }, + { + name: "tx in future block", + txBlockHeight: 105, + currentHeight: 100, + expectedConfirmations: 0, + expectedHeight: 105, + expectTimestamp: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + w := testWallet(t) + + // Set the wallet's synced height. + err := walletdb.Update( + w.db, func(tx walletdb.ReadWriteTx) error { + addrmgrNs := tx.ReadWriteBucket( + waddrmgrNamespaceKey, + ) + bs := &waddrmgr.BlockStamp{ + Height: tt.currentHeight, + Hash: chainhash.Hash{}, + } + + return w.addrStore.SetSyncedTo( + addrmgrNs, bs, + ) + }, + ) + require.NoError(t, err) + + // Insert transaction into wallet. + err = walletdb.Update( + w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket( + wtxmgrNamespaceKey, + ) + + // Create block metadata if transaction + // is mined. + var blockMeta *wtxmgr.BlockMeta + if tt.txBlockHeight != -1 { + hash := chainhash.Hash{} + height := tt.txBlockHeight + block := wtxmgr.Block{ + Hash: hash, + Height: height, + } + blockMeta = &wtxmgr.BlockMeta{ + Block: block, + Time: time.Now(), + } + } + + return w.txStore.InsertTx( + ns, rec, blockMeta, + ) + }, + ) + require.NoError(t, err) + + result, err := w.GetTransaction(*TstTxHash) + require.NoError(t, err) + + require.Equal( + t, tt.expectedConfirmations, + result.Confirmations, + ) + + require.Equal(t, tt.expectedHeight, result.Height) + + if tt.expectTimestamp { + require.NotZero(t, result.Timestamp) + } else { + require.Zero(t, result.Timestamp) + } + + // Additional checks for unconfirmed transactions. + if tt.txBlockHeight == -1 { + require.Nil(t, result.BlockHash) + require.Equal(t, int32(0), result.Confirmations) + } else { + require.NotNil(t, result.BlockHash) + // Only expect positive confirmations when tx is + // not in a future block. + if tt.txBlockHeight <= tt.currentHeight { + require.Positive( + t, result.Confirmations, + ) + } else { + // Confirmed txns in future blocks for + // example due to reorg should be + // treated as unconfirmed and have 0 + // confirmations. + require.Equal( + t, int32(0), + result.Confirmations, + ) + } + } + }) + } +} + +// TestDuplicateAddressDerivation tests that duplicate addresses are not +// derived when multiple goroutines are concurrently requesting new addresses. +func TestDuplicateAddressDerivation(t *testing.T) { + w := testWallet(t) + var ( + m sync.Mutex + globalAddrs = make(map[string]btcutil.Address) + ) + + for o := 0; o < 10; o++ { + var eg errgroup.Group + + for n := 0; n < 10; n++ { + eg.Go(func() error { + addrs := make([]btcutil.Address, 10) + for i := 0; i < 10; i++ { + addr, err := w.NewAddressDeprecated( + 0, waddrmgr.KeyScopeBIP0084, + ) + if err != nil { + return err + } + + addrs[i] = addr + } + + m.Lock() + defer m.Unlock() + + for idx := range addrs { + addrStr := addrs[idx].String() + if a, ok := globalAddrs[addrStr]; ok { + return fmt.Errorf("duplicate "+ + "address! already "+ + "have %v, want to "+ + "add %v", a, addrs[idx]) + } + + globalAddrs[addrStr] = addrs[idx] + } + + return nil + }) + } + + require.NoError(t, eg.Wait()) + } +} + +func TestEndRecovery(t *testing.T) { + // This is an unconventional unit test, but I'm trying to keep things as + // succint as possible so that this test is readable without having to mock + // up literally everything. + // The unmonitored goroutine we're looking at is pretty deep: + // SynchronizeRPC -> handleChainNotifications -> syncWithChain -> recovery + // The "deadlock" we're addressing isn't actually a deadlock, but the wallet + // will hang on Stop() -> WaitForShutdown() until (*Wallet).recovery gets + // every single block, which could be hours depending on hardware and + // network factors. The WaitGroup is incremented in SynchronizeRPC, and + // WaitForShutdown will not return until handleChainNotifications returns, + // which is blocked by a running (*Wallet).recovery loop. + // It is noted that the conditions for long recovery are difficult to hit + // when using btcwallet with a fresh seed, because it requires an early + // birthday to be set or established. + + w := testWallet(t) + + blockHashCalled := make(chan struct{}) + + chainClient := &mockChainClient{ + // Force the loop to iterate about forever. + getBestBlockHeight: math.MaxInt32, + // Get control of when the loop iterates. + getBlockHashFunc: func() (*chainhash.Hash, error) { + blockHashCalled <- struct{}{} + return &chainhash.Hash{}, nil + }, + // Avoid a panic. + getBlockHeader: &wire.BlockHeader{}, + } + + recoveryDone := make(chan struct{}) + go func() { + defer close(recoveryDone) + w.recovery(chainClient, &waddrmgr.BlockStamp{}) + }() + + getBlockHashCalls := func(expCalls int) { + var i int + for { + select { + case <-blockHashCalled: + i++ + case <-time.After(time.Second): + t.Fatal("expected BlockHash to be called") + } + if i == expCalls { + break + } + } + } + + // Recovery is running. + getBlockHashCalls(3) + + // Closing the quit channel, e.g. Stop() without endRecovery, alone will not + // end the recovery loop. + w.quitMu.Lock() + close(w.quit) + w.quitMu.Unlock() + // Continues scanning. + getBlockHashCalls(3) + + // We're done with this one + atomic.StoreUint32(&w.recovering.Load().(*recoverySyncer).quit, 1) + select { + case <-blockHashCalled: + case <-recoveryDone: + } + + // Try again. + w = testWallet(t) + + // We'll catch the error to make sure we're hitting our desired path. The + // WaitGroup isn't required for the test, but does show how it completes + // shutdown at a higher level. + var err error + w.wg.Add(1) + recoveryDone = make(chan struct{}) + go func() { + defer w.wg.Done() + defer close(recoveryDone) + err = w.recovery(chainClient, &waddrmgr.BlockStamp{}) + }() + + waitedForShutdown := make(chan struct{}) + go func() { + w.WaitForShutdown() + close(waitedForShutdown) + }() + + // Recovery is running. + getBlockHashCalls(3) + + // endRecovery is required to exit the unmonitored goroutine. + end := w.endRecovery() + select { + case <-blockHashCalled: + case <-recoveryDone: + } + <-end + + // testWallet starts a couple of other unrelated goroutines that need to be + // killed, so we still need to close the quit channel. + w.quitMu.Lock() + close(w.quit) + w.quitMu.Unlock() + + select { + case <-waitedForShutdown: + case <-time.After(time.Second): + t.Fatal("WaitForShutdown never returned") + } + + if !strings.EqualFold(err.Error(), "recovery: forced shutdown") { + t.Fatal("wrong error") + } +} + +// mockBirthdayStore is a mock in-memory implementation of the birthdayStore interface +// that will be used for the birthday block sanity check tests. +type mockBirthdayStore struct { + birthday time.Time + birthdayBlock *waddrmgr.BlockStamp + birthdayBlockVerified bool + syncedTo waddrmgr.BlockStamp +} + +var _ birthdayStore = (*mockBirthdayStore)(nil) + +// Birthday returns the birthday timestamp of the wallet. +func (s *mockBirthdayStore) Birthday() time.Time { + return s.birthday +} + +// BirthdayBlock returns the birthday block of the wallet. +func (s *mockBirthdayStore) BirthdayBlock() (waddrmgr.BlockStamp, bool, error) { + if s.birthdayBlock == nil { + err := waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrBirthdayBlockNotSet, + } + return waddrmgr.BlockStamp{}, false, err + } + + return *s.birthdayBlock, s.birthdayBlockVerified, nil +} + +// SetBirthdayBlock updates the birthday block of the wallet to the given block. +// The boolean can be used to signal whether this block should be sanity checked +// the next time the wallet starts. +func (s *mockBirthdayStore) SetBirthdayBlock(block waddrmgr.BlockStamp) error { + s.birthdayBlock = &block + s.birthdayBlockVerified = true + s.syncedTo = block + return nil +} + +// TestBirthdaySanityCheckEmptyBirthdayBlock ensures that a sanity check is not +// done if the birthday block does not exist in the first place. +func TestBirthdaySanityCheckEmptyBirthdayBlock(t *testing.T) { + t.Parallel() + + chainConn := &mockChainConn{} + + // Our birthday store will reflect that we don't have a birthday block + // set, so we should not attempt a sanity check. + birthdayStore := &mockBirthdayStore{} + + birthdayBlock, err := birthdaySanityCheck(chainConn, birthdayStore) + if !waddrmgr.IsError(err, waddrmgr.ErrBirthdayBlockNotSet) { + t.Fatalf("expected ErrBirthdayBlockNotSet, got %v", err) + } + + if birthdayBlock != nil { + t.Fatalf("expected birthday block to be nil due to not being "+ + "set, got %v", *birthdayBlock) + } +} + +// TestBirthdaySanityCheckVerifiedBirthdayBlock ensures that a sanity check is +// not performed if the birthday block has already been verified. +func TestBirthdaySanityCheckVerifiedBirthdayBlock(t *testing.T) { + t.Parallel() + + const chainTip = 5000 + const defaultBlockInterval = 10 * time.Minute + chainConn := createMockChainConn( + chainParams.GenesisBlock, chainTip, defaultBlockInterval, + ) + expectedBirthdayBlock := waddrmgr.BlockStamp{Height: 1337} + + // Our birthday store reflects that our birthday block has already been + // verified and should not require a sanity check. + birthdayStore := &mockBirthdayStore{ + birthdayBlock: &expectedBirthdayBlock, + birthdayBlockVerified: true, + syncedTo: waddrmgr.BlockStamp{ + Height: chainTip, + }, + } + + // Now, we'll run the sanity check. We should see that the birthday + // block hasn't changed. + birthdayBlock, err := birthdaySanityCheck(chainConn, birthdayStore) + if err != nil { + t.Fatalf("unable to sanity check birthday block: %v", err) + } + if !reflect.DeepEqual(*birthdayBlock, expectedBirthdayBlock) { + t.Fatalf("expected birthday block %v, got %v", + expectedBirthdayBlock, birthdayBlock) + } + + // To ensure the sanity check didn't proceed, we'll check our synced to + // height, as this value should have been modified if a new candidate + // was found. + if birthdayStore.syncedTo.Height != chainTip { + t.Fatalf("expected synced height remain the same (%d), got %d", + chainTip, birthdayStore.syncedTo.Height) + } +} + +// TestBirthdaySanityCheckLowerEstimate ensures that we can properly locate a +// better birthday block candidate if our estimate happens to be too far back in +// the chain. +func TestBirthdaySanityCheckLowerEstimate(t *testing.T) { + t.Parallel() + + const defaultBlockInterval = 10 * time.Minute + + // We'll start by defining our birthday timestamp to be around the + // timestamp of the 1337th block. + genesisTimestamp := chainParams.GenesisBlock.Header.Timestamp + birthday := genesisTimestamp.Add(1337 * defaultBlockInterval) + + // We'll establish a connection to a mock chain of 5000 blocks. + chainConn := createMockChainConn( + chainParams.GenesisBlock, 5000, defaultBlockInterval, + ) + + // Our birthday store will reflect that our birthday block is currently + // set as the genesis block. This value is too low and should be + // adjusted by the sanity check. + birthdayStore := &mockBirthdayStore{ + birthday: birthday, + birthdayBlock: &waddrmgr.BlockStamp{ + Hash: *chainParams.GenesisHash, + Height: 0, + Timestamp: genesisTimestamp, + }, + birthdayBlockVerified: false, + syncedTo: waddrmgr.BlockStamp{ + Height: 5000, + }, + } + + // We'll perform the sanity check and determine whether we were able to + // find a better birthday block candidate. + birthdayBlock, err := birthdaySanityCheck(chainConn, birthdayStore) + if err != nil { + t.Fatalf("unable to sanity check birthday block: %v", err) + } + if birthday.Sub(birthdayBlock.Timestamp) >= birthdayBlockDelta { + t.Fatalf("expected birthday block timestamp=%v to be within "+ + "%v of birthday timestamp=%v", birthdayBlock.Timestamp, + birthdayBlockDelta, birthday) + } + + // Finally, our synced to height should now reflect our new birthday + // block to ensure the wallet doesn't miss any events from this point + // forward. + if !reflect.DeepEqual(birthdayStore.syncedTo, *birthdayBlock) { + t.Fatalf("expected syncedTo and birthday block to match: "+ + "%v vs %v", birthdayStore.syncedTo, birthdayBlock) + } +} + +// TestBirthdaySanityCheckHigherEstimate ensures that we can properly locate a +// better birthday block candidate if our estimate happens to be too far in the +// chain. +func TestBirthdaySanityCheckHigherEstimate(t *testing.T) { + t.Parallel() + + const defaultBlockInterval = 10 * time.Minute + + // We'll start by defining our birthday timestamp to be around the + // timestamp of the 1337th block. + genesisTimestamp := chainParams.GenesisBlock.Header.Timestamp + birthday := genesisTimestamp.Add(1337 * defaultBlockInterval) + + // We'll establish a connection to a mock chain of 5000 blocks. + chainConn := createMockChainConn( + chainParams.GenesisBlock, 5000, defaultBlockInterval, + ) + + // Our birthday store will reflect that our birthday block is currently + // set as the chain tip. This value is too high and should be adjusted + // by the sanity check. + bestBlock := chainConn.blocks[chainConn.blockHashes[5000]] + birthdayStore := &mockBirthdayStore{ + birthday: birthday, + birthdayBlock: &waddrmgr.BlockStamp{ + Hash: bestBlock.BlockHash(), + Height: 5000, + Timestamp: bestBlock.Header.Timestamp, + }, + birthdayBlockVerified: false, + syncedTo: waddrmgr.BlockStamp{ + Height: 5000, + }, + } + + // We'll perform the sanity check and determine whether we were able to + // find a better birthday block candidate. + birthdayBlock, err := birthdaySanityCheck(chainConn, birthdayStore) + if err != nil { + t.Fatalf("unable to sanity check birthday block: %v", err) + } + if birthday.Sub(birthdayBlock.Timestamp) >= birthdayBlockDelta { + t.Fatalf("expected birthday block timestamp=%v to be within "+ + "%v of birthday timestamp=%v", birthdayBlock.Timestamp, + birthdayBlockDelta, birthday) + } + + // Finally, our synced to height should now reflect our new birthday + // block to ensure the wallet doesn't miss any events from this point + // forward. + if !reflect.DeepEqual(birthdayStore.syncedTo, *birthdayBlock) { + t.Fatalf("expected syncedTo and birthday block to match: "+ + "%v vs %v", birthdayStore.syncedTo, birthdayBlock) + } +} + +type testCase struct { + name string + masterPriv string + accountIndex uint32 + addrType waddrmgr.AddressType + expectedScope waddrmgr.KeyScope + expectedAddr string + expectedChangeAddr string +} + +var ( + //nolint:lll + testCases = []*testCase{{ + name: "bip44 with nested witness address type", + masterPriv: "tprv8ZgxMBicQKsPeWwrFuNjEGTTDSY4mRLwd2KDJAPGa1AY" + + "quw38bZqNMSuB3V1Va3hqJBo9Pt8Sx7kBQer5cNMrb8SYquoWPt9" + + "Y3BZdhdtUcw", + accountIndex: 0, + addrType: waddrmgr.NestedWitnessPubKey, + expectedScope: waddrmgr.KeyScopeBIP0049Plus, + expectedAddr: "2N5YTxG9XtGXx1YyhZb7N2pwEjoZLLMHGKj", + expectedChangeAddr: "2N7wpz5Gy2zEJTvq2MAuU6BCTEBLXNQ8dUw", + }, { + name: "bip44 with witness address type", + masterPriv: "tprv8ZgxMBicQKsPeWwrFuNjEGTTDSY4mRLwd2KDJAPGa1AY" + + "quw38bZqNMSuB3V1Va3hqJBo9Pt8Sx7kBQer5cNMrb8SYquoWPt9" + + "Y3BZdhdtUcw", + accountIndex: 777, + addrType: waddrmgr.WitnessPubKey, + expectedScope: waddrmgr.KeyScopeBIP0084, + expectedAddr: "bcrt1qllxcutkzsukf8u8c8stkp464j0esu9xquft3s0", + expectedChangeAddr: "bcrt1qu6jmqglrthscptjqj3egx54wy8xqvzn54ex9eh", + }, { + name: "traditional bip49", + masterPriv: "uprv8tXDerPXZ1QsVp8y6GAMSMYxPQgWi3LSY8qS5ZH9x1YRu" + + "1kGPFjPzR73CFSbVUhdEwJbtsUgucUJ4hGQoJnNepp3RBcE6Jhdom" + + "FD2KeY6G9", + accountIndex: 9, + addrType: waddrmgr.NestedWitnessPubKey, + expectedScope: waddrmgr.KeyScopeBIP0049Plus, + expectedAddr: "2NBCJ9WzGXZqpLpXGq3Hacybj3c4eHRcqgh", + expectedChangeAddr: "2N3bankFu6F3ZNU41iVJQqyS9MXqp9dvn1M", + }, { + name: "bip49+", + masterPriv: "uprv8tXDerPXZ1QsVp8y6GAMSMYxPQgWi3LSY8qS5ZH9x1YRu" + + "1kGPFjPzR73CFSbVUhdEwJbtsUgucUJ4hGQoJnNepp3RBcE6Jhdom" + + "FD2KeY6G9", + accountIndex: 9, + addrType: waddrmgr.WitnessPubKey, + expectedScope: waddrmgr.KeyScopeBIP0049Plus, + expectedAddr: "2NBCJ9WzGXZqpLpXGq3Hacybj3c4eHRcqgh", + expectedChangeAddr: "bcrt1qeqn05w2hfq6axpdprhs4y7x65gxkkvfvx0emz4", + }, { + name: "bip84", + masterPriv: "vprv9DMUxX4ShgxMM7L5vcwyeSeTZNpxefKwTFMerxB3L1vJ" + + "x7ZVdutxcUmBDTQBVPMYeaRQeM5FNGpqwysyX1CPT4VeHXJegDX8" + + "5VJrQvaFaz3", + accountIndex: 1, + addrType: waddrmgr.WitnessPubKey, + expectedScope: waddrmgr.KeyScopeBIP0084, + expectedAddr: "bcrt1q5vepvcl0z8xj7kps4rsux722r4dvfwlh5ntexr", + expectedChangeAddr: "bcrt1qlwe2kgxcsa8x4huu79yff4rze0l5mwaf2apn3y", + }} +) + +// TestImportAccountDeprecated tests that extended public keys can successfully +// be imported into both watch only and normal wallets. +func TestImportAccountDeprecated(t *testing.T) { + t.Parallel() + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + w := testWallet(t) + + testImportAccount(t, w, tc, false, tc.name) + }) + + name := tc.name + " watch-only" + t.Run(name, func(t *testing.T) { + t.Parallel() + + w := testWalletWatchingOnly(t) + + testImportAccount(t, w, tc, true, name) + }) + } +} + +func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, + name string) { + + // First derive the master public key of the account we want to import. + root, err := hdkeychain.NewKeyFromString(tc.masterPriv) + require.NoError(t, err) + + // Derive the extended private and public key for our target account. + acct1Pub := deriveAcctPubKey( + t, root, tc.expectedScope, hardenedKey(tc.accountIndex), + ) + + // We want to make sure we can import and handle multiple accounts, so + // we create another one. + acct2Pub := deriveAcctPubKey( + t, root, tc.expectedScope, hardenedKey(tc.accountIndex+1), + ) + + // And we also want to be able to import loose extended public keys + // without needing to specify an explicit scope. + acct3ExternalExtPub := deriveAcctPubKey( + t, root, tc.expectedScope, hardenedKey(tc.accountIndex+2), 0, 0, + ) + acct3ExternalPub, err := acct3ExternalExtPub.ECPubKey() + require.NoError(t, err) + + // Do a dry run import first and check that it results in the expected + // addresses being derived. + _, extAddrs, intAddrs, err := w.ImportAccountDryRun( + name+"1", acct1Pub, root.ParentFingerprint(), &tc.addrType, 1, + ) + require.NoError(t, err) + require.Len(t, extAddrs, 1) + require.Equal(t, tc.expectedAddr, extAddrs[0].Address().String()) + require.Len(t, intAddrs, 1) + require.Equal(t, tc.expectedChangeAddr, intAddrs[0].Address().String()) + + // Import the extended public keys into new accounts. + acct1, err := w.ImportAccountDeprecated( + name+"1", acct1Pub, root.ParentFingerprint(), &tc.addrType, + ) + require.NoError(t, err) + require.Equal(t, tc.expectedScope, acct1.KeyScope) + + acct2, err := w.ImportAccountDeprecated( + name+"2", acct2Pub, root.ParentFingerprint(), &tc.addrType, + ) + require.NoError(t, err) + require.Equal(t, tc.expectedScope, acct2.KeyScope) + + err = w.ImportPublicKeyDeprecated(acct3ExternalPub, tc.addrType) + require.NoError(t, err) + + // If the wallet is watch only, there is no default account and our + // imported account will be index 0. + firstAccountIndex := uint32(1) + numAccounts := 2 + if watchOnly { + firstAccountIndex = 0 + numAccounts = 1 + } + + // We should have 2 additional accounts now. + acctResult, err := w.Accounts(tc.expectedScope) + require.NoError(t, err) + require.Len(t, acctResult.Accounts, numAccounts+2) + + // Validate the state of the accounts. + require.Equal(t, firstAccountIndex, acct1.AccountNumber) + require.Equal(t, name+"1", acct1.AccountName) + require.Equal(t, true, acct1.IsWatchOnly) + require.Equal(t, root.ParentFingerprint(), acct1.MasterKeyFingerprint) + require.NotNil(t, acct1.AccountPubKey) + require.Equal(t, acct1Pub.String(), acct1.AccountPubKey.String()) + require.Equal(t, uint32(0), acct1.InternalKeyCount) + require.Equal(t, uint32(0), acct1.ExternalKeyCount) + require.Equal(t, uint32(0), acct1.ImportedKeyCount) + + require.Equal(t, firstAccountIndex+1, acct2.AccountNumber) + require.Equal(t, name+"2", acct2.AccountName) + require.Equal(t, true, acct2.IsWatchOnly) + require.Equal(t, root.ParentFingerprint(), acct2.MasterKeyFingerprint) + require.NotNil(t, acct2.AccountPubKey) + require.Equal(t, acct2Pub.String(), acct2.AccountPubKey.String()) + require.Equal(t, uint32(0), acct2.InternalKeyCount) + require.Equal(t, uint32(0), acct2.ExternalKeyCount) + require.Equal(t, uint32(0), acct2.ImportedKeyCount) + + // Test address derivation. + extAddr, err := w.NewAddressDeprecated( + acct1.AccountNumber, tc.expectedScope, + ) + require.NoError(t, err) + require.Equal(t, tc.expectedAddr, extAddr.String()) + intAddr, err := w.NewChangeAddress(acct1.AccountNumber, tc.expectedScope) + require.NoError(t, err) + require.Equal(t, tc.expectedChangeAddr, intAddr.String()) + + // Make sure the key count was increased. + acct1, err = w.AccountProperties(tc.expectedScope, acct1.AccountNumber) + require.NoError(t, err) + require.Equal(t, uint32(1), acct1.InternalKeyCount) + require.Equal(t, uint32(1), acct1.ExternalKeyCount) + require.Equal(t, uint32(0), acct1.ImportedKeyCount) + + // Make sure we can't get private keys for the imported + // accounts. + _, err = w.DumpWIFPrivateKey(intAddr) + require.True(t, waddrmgr.IsError(err, waddrmgr.ErrWatchingOnly)) + + // Get the address info for the single key we imported. + switch tc.addrType { + case waddrmgr.NestedWitnessPubKey: + witnessAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(acct3ExternalPub.SerializeCompressed()), + w.chainParams, + ) + require.NoError(t, err) + + witnessProg, err := txscript.PayToAddrScript(witnessAddr) + require.NoError(t, err) + + intAddr, err = btcutil.NewAddressScriptHash( + witnessProg, w.chainParams, + ) + require.NoError(t, err) + + case waddrmgr.WitnessPubKey: + intAddr, err = btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(acct3ExternalPub.SerializeCompressed()), + w.chainParams, + ) + require.NoError(t, err) + + default: + t.Fatalf("unhandled address type %v", tc.addrType) + } + + addrManaged, err := w.AddressInfoDeprecated(intAddr) + require.NoError(t, err) + require.Equal(t, true, addrManaged.Imported()) +} + +// TestCreateWatchingOnly checks that we can construct a watching-only +// wallet. +func TestCreateWatchingOnly(t *testing.T) { + // Set up a wallet. + dir := t.TempDir() + + pubPass := []byte("hello") + + loader := NewLoader( + &chaincfg.TestNet3Params, dir, true, defaultDBTimeout, 250, + WithWalletSyncRetryInterval(10*time.Millisecond), + ) + _, err := loader.CreateNewWatchingOnlyWallet(pubPass, time.Now()) + if err != nil { + t.Fatalf("unable to create wallet: %v", err) + } +} + +// defaultDBTimeout specifies the timeout value when opening the wallet +// database. +var defaultDBTimeout = 10 * time.Second + +// testWallet creates a test wallet and unlocks it. +func testWallet(t *testing.T) *Wallet { + t.Helper() + // Set up a wallet. + dir := t.TempDir() + + seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes) + if err != nil { + t.Fatalf("unable to create seed: %v", err) + } + + pubPass := []byte("hello") + privPass := []byte("world") + + loader := NewLoader( + &chainParams, dir, true, defaultDBTimeout, 250, + WithWalletSyncRetryInterval(10*time.Millisecond), + ) + w, err := loader.CreateNewWallet(pubPass, privPass, seed, time.Now()) + if err != nil { + t.Fatalf("unable to create wallet: %v", err) + } + + chainClient := &mockChainClient{} + w.chainClient = chainClient + + // Start the wallet. + w.StartDeprecated() + + // Add the shutdown to the test's cleanup process. + t.Cleanup(func() { + w.StopDeprecated() + w.WaitForShutdown() + }) + + err = w.UnlockDeprecated(privPass, time.After(10*time.Minute)) + if err != nil { + t.Fatalf("unable to unlock wallet: %v", err) + } + + return w +} + +// testWalletWatchingOnly creates a test watch only wallet and unlocks it. +func testWalletWatchingOnly(t *testing.T) *Wallet { + t.Helper() + // Set up a wallet. + dir := t.TempDir() + + pubPass := []byte("hello") + loader := NewLoader( + &chainParams, dir, true, defaultDBTimeout, 250, + WithWalletSyncRetryInterval(10*time.Millisecond), + ) + w, err := loader.CreateNewWatchingOnlyWallet(pubPass, time.Now()) + if err != nil { + t.Fatalf("unable to create wallet: %v", err) + } + chainClient := &mockChainClient{} + w.chainClient = chainClient + + err = walletdb.Update(w.Database(), func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + for scope, schema := range waddrmgr.ScopeAddrMap { + _, err := w.addrStore.NewScopedKeyManager( + ns, scope, schema, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + t.Fatalf("unable to create default scopes: %v", err) + } + + w.StartDeprecated() + t.Cleanup(func() { + w.StopDeprecated() + w.WaitForShutdown() + }) + + return w +} + +var ( + testScriptP2WSH, _ = hex.DecodeString( + "0020d554616badeb46ccd4ce4b115e1c8d098e942d1387212d0af9ff93a1" + + "9c8f100e", + ) + testScriptP2WKH, _ = hex.DecodeString( + "0014e7a43aa41ef6d72dc6baeeaad8362cedf63b79a3", + ) +) + +// TestFundPsbt tests that a given PSBT packet is funded correctly. +func TestFundPsbt(t *testing.T) { + t.Parallel() + + w := testWallet(t) + + // Create a P2WKH address we can use to send some coins to. + addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) + require.NoError(t, err) + p2wkhAddr, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + // Also create a nested P2WKH address we can use to send some coins to. + addr, err = w.CurrentAddress(0, waddrmgr.KeyScopeBIP0049Plus) + require.NoError(t, err) + np2wkhAddr, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + // Register two big UTXO that will be used when funding the PSBT. + const utxo1Amount = 1000000 + incomingTx1 := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{wire.NewTxOut(utxo1Amount, p2wkhAddr)}, + } + addUtxo(t, w, incomingTx1) + utxo1 := wire.OutPoint{ + Hash: incomingTx1.TxHash(), + Index: 0, + } + + const utxo2Amount = 900000 + incomingTx2 := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{wire.NewTxOut(utxo2Amount, np2wkhAddr)}, + } + addUtxo(t, w, incomingTx2) + utxo2 := wire.OutPoint{ + Hash: incomingTx2.TxHash(), + Index: 0, + } + + testCases := []struct { + name string + packet *psbt.Packet + feeRateSatPerKB btcutil.Amount + changeKeyScope *waddrmgr.KeyScope + expectedErr string + validatePackage bool + expectedChangeBeforeFee int64 + expectedInputs []wire.OutPoint + additionalChecks func(*testing.T, *psbt.Packet, int32) + }{{ + name: "no outputs provided", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{}, + }, + feeRateSatPerKB: 0, + expectedErr: "PSBT packet must contain at least one " + + "input or output", + }, { + name: "single input, no outputs", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: utxo1, + }}, + }, + Inputs: []psbt.PInput{{}}, + }, + feeRateSatPerKB: 20000, + validatePackage: true, + expectedInputs: []wire.OutPoint{utxo1}, + expectedChangeBeforeFee: utxo1Amount, + }, { + name: "no dust outputs", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxOut: []*wire.TxOut{{ + PkScript: []byte("foo"), + Value: 100, + }}, + }, + Outputs: []psbt.POutput{{}}, + }, + feeRateSatPerKB: 0, + expectedErr: "transaction output is dust", + }, { + name: "two outputs, no inputs", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxOut: []*wire.TxOut{{ + PkScript: testScriptP2WSH, + Value: 100000, + }, { + PkScript: testScriptP2WKH, + Value: 50000, + }}, + }, + Outputs: []psbt.POutput{{}, {}}, + }, + feeRateSatPerKB: 2000, // 2 sat/byte + expectedErr: "", + validatePackage: true, + expectedChangeBeforeFee: utxo1Amount - 150000, + expectedInputs: []wire.OutPoint{utxo1}, + }, { + name: "large output, no inputs", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxOut: []*wire.TxOut{{ + PkScript: testScriptP2WSH, + Value: 1500000, + }}, + }, + Outputs: []psbt.POutput{{}}, + }, + feeRateSatPerKB: 4000, // 4 sat/byte + expectedErr: "", + validatePackage: true, + expectedChangeBeforeFee: (utxo1Amount + utxo2Amount) - 1500000, + expectedInputs: []wire.OutPoint{utxo1, utxo2}, + }, { + name: "two outputs, two inputs", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: utxo1, + }, { + PreviousOutPoint: utxo2, + }}, + TxOut: []*wire.TxOut{{ + PkScript: testScriptP2WSH, + Value: 100000, + }, { + PkScript: testScriptP2WKH, + Value: 50000, + }}, + }, + Inputs: []psbt.PInput{{}, {}}, + Outputs: []psbt.POutput{{}, {}}, + }, + feeRateSatPerKB: 2000, // 2 sat/byte + expectedErr: "", + validatePackage: true, + expectedChangeBeforeFee: (utxo1Amount + utxo2Amount) - 150000, + expectedInputs: []wire.OutPoint{utxo1, utxo2}, + additionalChecks: func(t *testing.T, packet *psbt.Packet, + changeIndex int32) { + + // Check outputs, find index for each of the 3 expected. + txOuts := packet.UnsignedTx.TxOut + require.Len(t, txOuts, 3, "tx outputs") + + p2wkhIndex := -1 + p2wshIndex := -1 + totalOut := int64(0) + for idx, txOut := range txOuts { + script := txOut.PkScript + totalOut += txOut.Value + + switch { + case bytes.Equal(script, testScriptP2WKH): + p2wkhIndex = idx + + case bytes.Equal(script, testScriptP2WSH): + p2wshIndex = idx + + } + } + totalIn := int64(0) + for _, txIn := range packet.Inputs { + totalIn += txIn.WitnessUtxo.Value + } + + // All outputs must be found. + require.Greater(t, p2wkhIndex, -1) + require.Greater(t, p2wshIndex, -1) + require.Greater(t, changeIndex, int32(-1)) + + // After BIP 69 sorting, the P2WKH output should be + // before the P2WSH output because the PK script is + // lexicographically smaller. + require.Less( + t, p2wkhIndex, p2wshIndex, + "index after sorting", + ) + }, + }, { + name: "one input and a custom change scope: BIP0084", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: utxo1, + }}, + }, + Inputs: []psbt.PInput{{}}, + }, + feeRateSatPerKB: 20000, + validatePackage: true, + changeKeyScope: &waddrmgr.KeyScopeBIP0084, + expectedInputs: []wire.OutPoint{utxo1}, + expectedChangeBeforeFee: utxo1Amount, + }, { + name: "no inputs and a custom change scope: BIP0084", + packet: &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxOut: []*wire.TxOut{{ + PkScript: testScriptP2WSH, + Value: 100000, + }, { + PkScript: testScriptP2WKH, + Value: 50000, + }}, + }, + Outputs: []psbt.POutput{{}, {}}, + }, + feeRateSatPerKB: 2000, // 2 sat/byte + expectedErr: "", + validatePackage: true, + changeKeyScope: &waddrmgr.KeyScopeBIP0084, + expectedChangeBeforeFee: utxo1Amount - 150000, + expectedInputs: []wire.OutPoint{utxo1}, + }} + + calcFee := func(feeRateSatPerKB btcutil.Amount, + packet *psbt.Packet) btcutil.Amount { + + var numP2WKHInputs, numNP2WKHInputs int + for _, txin := range packet.UnsignedTx.TxIn { + if txin.PreviousOutPoint == utxo1 { + numP2WKHInputs++ + } + if txin.PreviousOutPoint == utxo2 { + numNP2WKHInputs++ + } + } + estimatedSize := txsizes.EstimateVirtualSize( + 0, 0, numP2WKHInputs, numNP2WKHInputs, + packet.UnsignedTx.TxOut, 0, + ) + return txrules.FeeForSerializeSize( + feeRateSatPerKB, estimatedSize, + ) + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + changeIndex, err := w.FundPsbtDeprecated( + tc.packet, nil, 1, 0, + tc.feeRateSatPerKB, CoinSelectionLargest, + WithCustomChangeScope(tc.changeKeyScope), + ) + + // In any case, unlock the UTXO before continuing, we + // don't want to pollute other test iterations. + for _, in := range tc.packet.UnsignedTx.TxIn { + w.UnlockOutpoint(in.PreviousOutPoint) + } + + // Make sure the error is what we expected. + if tc.expectedErr != "" { + require.ErrorContains(t, err, tc.expectedErr) + return + } + + require.NoError(t, err) + + if !tc.validatePackage { + return + } + + // Check wire inputs. + packet := tc.packet + assertTxInputs(t, packet, tc.expectedInputs) + + // Run any additional tests if available. + if tc.additionalChecks != nil { + tc.additionalChecks(t, packet, changeIndex) + } + + // Finally, check the change output size and fee. + txOuts := packet.UnsignedTx.TxOut + totalOut := int64(0) + for _, txOut := range txOuts { + totalOut += txOut.Value + } + totalIn := int64(0) + for _, txIn := range packet.Inputs { + totalIn += txIn.WitnessUtxo.Value + } + fee := totalIn - totalOut + + expectedFee := calcFee(tc.feeRateSatPerKB, packet) + require.EqualValues(t, expectedFee, fee, "fee") + require.EqualValues( + t, tc.expectedChangeBeforeFee, + txOuts[changeIndex].Value+int64(expectedFee), + ) + + changeTxOut := txOuts[changeIndex] + changeOutput := packet.Outputs[changeIndex] + + require.NotEmpty(t, changeOutput.Bip32Derivation) + b32d := changeOutput.Bip32Derivation[0] + require.Len(t, b32d.Bip32Path, 5, "derivation path len") + require.Len(t, b32d.PubKey, 33, "pubkey len") + + // The third item should be the branch and should belong + // to a change output. + require.EqualValues(t, 1, b32d.Bip32Path[3]) + + assertChangeOutputScope( + t, changeTxOut.PkScript, tc.changeKeyScope, + ) + + if txscript.IsPayToTaproot(changeTxOut.PkScript) { + require.NotEmpty( + t, changeOutput.TaprootInternalKey, + ) + require.Len( + t, changeOutput.TaprootInternalKey, 32, + "internal key len", + ) + require.NotEmpty( + t, changeOutput.TaprootBip32Derivation, + ) + + trb32d := changeOutput.TaprootBip32Derivation[0] + require.Equal( + t, b32d.Bip32Path, trb32d.Bip32Path, + ) + require.Len( + t, trb32d.XOnlyPubKey, 32, + "schnorr pubkey len", + ) + require.Equal( + t, changeOutput.TaprootInternalKey, + trb32d.XOnlyPubKey, + ) + } + }) + } +} + +func assertTxInputs(t *testing.T, packet *psbt.Packet, + expected []wire.OutPoint) { + + require.Len(t, packet.UnsignedTx.TxIn, len(expected)) + + // The order of the UTXOs is random, we need to loop through each of + // them to make sure they're found. We also check that no signature data + // was added yet. + for _, txIn := range packet.UnsignedTx.TxIn { + if !containsUtxo(expected, txIn.PreviousOutPoint) { + t.Fatalf("outpoint %v not found in list of expected "+ + "UTXOs", txIn.PreviousOutPoint) + } + + require.Empty(t, txIn.SignatureScript) + require.Empty(t, txIn.Witness) + } +} + +// assertChangeOutputScope checks if the pkScript has the right type. +func assertChangeOutputScope(t *testing.T, pkScript []byte, + changeScope *waddrmgr.KeyScope) { + + // By default (changeScope == nil), the script should + // be a pay-to-taproot one. + switch changeScope { + case nil, &waddrmgr.KeyScopeBIP0086: + require.True(t, txscript.IsPayToTaproot(pkScript)) + + case &waddrmgr.KeyScopeBIP0049Plus, &waddrmgr.KeyScopeBIP0084: + require.True(t, txscript.IsPayToWitnessPubKeyHash(pkScript)) + + case &waddrmgr.KeyScopeBIP0044: + require.True(t, txscript.IsPayToPubKeyHash(pkScript)) + + default: + require.Fail(t, "assertChangeOutputScope error", + "change scope: %s", changeScope.String()) + } +} + +func containsUtxo(list []wire.OutPoint, candidate wire.OutPoint) bool { + for _, utxo := range list { + if utxo == candidate { + return true + } + } + + return false +} + +// TestFinalizePsbt tests that a given PSBT packet can be finalized. +func TestFinalizePsbt(t *testing.T) { + t.Parallel() + + w := testWallet(t) + + // Create a P2WKH address we can use to send some coins to. + addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) + if err != nil { + t.Fatalf("unable to get current address: %v", addr) + } + p2wkhAddr, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatalf("unable to convert wallet address to p2wkh: %v", err) + } + + // Also create a nested P2WKH address we can send coins to. + addr, err = w.CurrentAddress(0, waddrmgr.KeyScopeBIP0049Plus) + if err != nil { + t.Fatalf("unable to get current address: %v", addr) + } + np2wkhAddr, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatalf("unable to convert wallet address to np2wkh: %v", err) + } + + // Register two big UTXO that will be used when funding the PSBT. + utxOutP2WKH := wire.NewTxOut(1000000, p2wkhAddr) + utxOutNP2WKH := wire.NewTxOut(1000000, np2wkhAddr) + incomingTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{utxOutP2WKH, utxOutNP2WKH}, + } + addUtxo(t, w, incomingTx) + + // Create the packet that we want to sign. + packet := &psbt.Packet{ + UnsignedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: incomingTx.TxHash(), + Index: 0, + }, + }, { + PreviousOutPoint: wire.OutPoint{ + Hash: incomingTx.TxHash(), + Index: 1, + }, + }}, + TxOut: []*wire.TxOut{{ + PkScript: testScriptP2WKH, + Value: 50000, + }, { + PkScript: testScriptP2WSH, + Value: 100000, + }, { + PkScript: testScriptP2WKH, + Value: 849632, + }}, + }, + Inputs: []psbt.PInput{{ + WitnessUtxo: utxOutP2WKH, + SighashType: txscript.SigHashAll, + }, { + NonWitnessUtxo: incomingTx, + SighashType: txscript.SigHashAll, + }}, + Outputs: []psbt.POutput{{}, {}, {}}, + } + + // Finalize it to add all witness data then extract the final TX. + err = w.FinalizePsbtDeprecated(nil, 0, packet) + if err != nil { + t.Fatalf("error finalizing PSBT packet: %v", err) + } + finalTx, err := psbt.Extract(packet) + if err != nil { + t.Fatalf("error extracting final TX from PSBT: %v", err) + } + + // Finally verify that the created witness is valid. + err = validateMsgTx( + finalTx, [][]byte{utxOutP2WKH.PkScript, utxOutNP2WKH.PkScript}, + []btcutil.Amount{1000000, 1000000}, + ) + if err != nil { + t.Fatalf("error validating tx: %v", err) + } +} + +var ( + testBlockHash, _ = chainhash.NewHashFromStr( + "00000000000000017188b968a371bab95aa43522665353b646e41865abae" + + "02a4", + ) + testBlockHeight int32 = 276425 + + alwaysAllowUtxo = func(utxo wtxmgr.Credit) bool { return true } +) + +// TestTxToOutput checks that no new address is added to he database if we +// request a dry run of the txToOutputs call. It also makes sure a subsequent +// non-dry run call produces a similar transaction to the dry-run. +func TestTxToOutputsDryRun(t *testing.T) { + t.Parallel() + + w := testWallet(t) + + // Create an address we can use to send some coins to. + keyScope := waddrmgr.KeyScopeBIP0049Plus + addr, err := w.CurrentAddress(0, keyScope) + if err != nil { + t.Fatalf("unable to get current address: %v", addr) + } + p2shAddr, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatalf("unable to convert wallet address to p2sh: %v", err) + } + + // Add an output paying to the wallet's address to the database. + txOut := wire.NewTxOut(100000, p2shAddr) + incomingTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, + }, + TxOut: []*wire.TxOut{ + txOut, + }, + } + addUtxo(t, w, incomingTx) + + // Now tell the wallet to create a transaction paying to the specified + // outputs. + txOuts := []*wire.TxOut{ + { + PkScript: p2shAddr, + Value: 10000, + }, + { + PkScript: p2shAddr, + Value: 20000, + }, + } + + // First do a few dry-runs, making sure the number of addresses in the + // database us not inflated. + dryRunTx, err := w.txToOutputs( + txOuts, nil, nil, 0, 1, 1000, CoinSelectionLargest, true, + nil, alwaysAllowUtxo, + ) + if err != nil { + t.Fatalf("unable to author tx: %v", err) + } + change := dryRunTx.Tx.TxOut[dryRunTx.ChangeIndex] + + addresses, err := w.AccountAddresses(0) + if err != nil { + t.Fatalf("unable to get addresses: %v", err) + } + + if len(addresses) != 1 { + t.Fatalf("expected 1 address, found %v", len(addresses)) + } + + dryRunTx2, err := w.txToOutputs( + txOuts, nil, nil, 0, 1, 1000, CoinSelectionLargest, true, + nil, alwaysAllowUtxo, + ) + if err != nil { + t.Fatalf("unable to author tx: %v", err) + } + change2 := dryRunTx2.Tx.TxOut[dryRunTx2.ChangeIndex] + + addresses, err = w.AccountAddresses(0) + if err != nil { + t.Fatalf("unable to get addresses: %v", err) + } + + if len(addresses) != 1 { + t.Fatalf("expected 1 address, found %v", len(addresses)) + } + + // The two dry-run TXs should be invalid, since they don't have + // signatures. + err = validateMsgTx( + dryRunTx.Tx, dryRunTx.PrevScripts, dryRunTx.PrevInputValues, + ) + if err == nil { + t.Fatalf("Expected tx to be invalid") + } + + err = validateMsgTx( + dryRunTx2.Tx, dryRunTx2.PrevScripts, dryRunTx2.PrevInputValues, + ) + if err == nil { + t.Fatalf("Expected tx to be invalid") + } + + // Now we do a proper, non-dry run. This should add a change address + // to the database. + tx, err := w.txToOutputs( + txOuts, nil, nil, 0, 1, 1000, CoinSelectionLargest, false, + nil, alwaysAllowUtxo, + ) + if err != nil { + t.Fatalf("unable to author tx: %v", err) + } + change3 := tx.Tx.TxOut[tx.ChangeIndex] + + addresses, err = w.AccountAddresses(0) + if err != nil { + t.Fatalf("unable to get addresses: %v", err) + } + + if len(addresses) != 2 { + t.Fatalf("expected 2 addresses, found %v", len(addresses)) + } + + err = validateMsgTx(tx.Tx, tx.PrevScripts, tx.PrevInputValues) + if err != nil { + t.Fatalf("Expected tx to be valid: %v", err) + } + + // Finally, we check that all the transaction were using the same + // change address. + if !bytes.Equal(change.PkScript, change2.PkScript) { + t.Fatalf("first dry-run using different change address " + + "than second") + } + if !bytes.Equal(change2.PkScript, change3.PkScript) { + t.Fatalf("dry-run using different change address " + + "than wet run") + } +} + +// addUtxo add the given transaction to the wallet's database marked as a +// confirmed UTXO . +func addUtxo(t *testing.T, w *Wallet, incomingTx *wire.MsgTx) { + var b bytes.Buffer + if err := incomingTx.Serialize(&b); err != nil { + t.Fatalf("unable to serialize tx: %v", err) + } + txBytes := b.Bytes() + + rec, err := wtxmgr.NewTxRecord(txBytes, time.Now()) + if err != nil { + t.Fatalf("unable to create tx record: %v", err) + } + + // The block meta will be inserted to tell the wallet this is a + // confirmed transaction. + block := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: *testBlockHash, + Height: testBlockHeight, + }, + Time: time.Unix(1387737310, 0), + } + + if err := walletdb.Update(w.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + err = w.txStore.InsertTx(ns, rec, block) + if err != nil { + return err + } + // Add all tx outputs as credits. + for i := 0; i < len(incomingTx.TxOut); i++ { + err = w.txStore.AddCredit( + ns, rec, block, uint32(i), false, + ) + if err != nil { + return err + } + } + return nil + }); err != nil { + t.Fatalf("failed inserting tx: %v", err) + } +} + +// addTxAndCredit adds the given transaction to the wallet's database marked as +// a confirmed UTXO specified by the creditIndex. +func addTxAndCredit(t *testing.T, w *Wallet, tx *wire.MsgTx, + creditIndex uint32) { + + var b bytes.Buffer + require.NoError(t, tx.Serialize(&b), "unable to serialize tx") + + txBytes := b.Bytes() + + rec, err := wtxmgr.NewTxRecord(txBytes, time.Now()) + require.NoError(t, err) + + // The block meta will be inserted to tell the wallet this is a + // confirmed transaction. + block := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: *testBlockHash, + Height: testBlockHeight, + }, + Time: time.Unix(1387737310, 0), + } + + err = walletdb.Update(w.db, func(dbTx walletdb.ReadWriteTx) error { + ns := dbTx.ReadWriteBucket(wtxmgrNamespaceKey) + + err = w.txStore.InsertTx(ns, rec, block) + if err != nil { + return err + } + + // Add the specified output as credit. + err = w.txStore.AddCredit(ns, rec, block, creditIndex, false) + if err != nil { + return err + } + + return nil + }) + require.NoError(t, err, "failed inserting tx") +} + +// TestInputYield verifies the functioning of the inputYieldsPositively. +func TestInputYield(t *testing.T) { + t.Parallel() + + addr, _ := btcutil.DecodeAddress("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", &chaincfg.MainNetParams) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + credit := &wire.TxOut{ + Value: 1000, + PkScript: pkScript, + } + + // At 10 sat/b this input is yielding positively. + require.True(t, inputYieldsPositively(credit, 10000)) + + // At 20 sat/b this input is yielding negatively. + require.False(t, inputYieldsPositively(credit, 20000)) +} + +// TestTxToOutputsRandom tests random coin selection. +func TestTxToOutputsRandom(t *testing.T) { + t.Parallel() + + w := testWallet(t) + + // Create an address we can use to send some coins to. + keyScope := waddrmgr.KeyScopeBIP0049Plus + addr, err := w.CurrentAddress(0, keyScope) + if err != nil { + t.Fatalf("unable to get current address: %v", addr) + } + p2shAddr, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatalf("unable to convert wallet address to p2sh: %v", err) + } + + // Add a set of utxos to the wallet. + incomingTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, + }, + TxOut: []*wire.TxOut{}, + } + for amt := int64(5000); amt <= 125000; amt += 10000 { + incomingTx.AddTxOut(wire.NewTxOut(amt, p2shAddr)) + } + + addUtxo(t, w, incomingTx) + + // Now tell the wallet to create a transaction paying to the specified + // outputs. + txOuts := []*wire.TxOut{ + { + PkScript: p2shAddr, + Value: 50000, + }, + { + PkScript: p2shAddr, + Value: 100000, + }, + } + + const ( + feeSatPerKb = 100000 + maxIterations = 100 + ) + + createTx := func() *txauthor.AuthoredTx { + tx, err := w.txToOutputs( + txOuts, nil, nil, 0, 1, feeSatPerKb, + CoinSelectionRandom, true, nil, alwaysAllowUtxo, + ) + require.NoError(t, err) + return tx + } + + firstTx := createTx() + var isRandom bool + for iteration := 0; iteration < maxIterations; iteration++ { + tx := createTx() + + // Check to see if we are getting a total input value. + // We consider this proof that the randomization works. + if tx.TotalInput != firstTx.TotalInput { + isRandom = true + } + + // At the used fee rate of 100 sat/b, the 5000 sat input is + // negatively yielding. We don't expect it to ever be selected. + for _, inputValue := range tx.PrevInputValues { + require.NotEqual(t, inputValue, btcutil.Amount(5000)) + } + } + + require.True(t, isRandom) +} + +// TestCreateSimpleCustomChange tests that it's possible to let the +// CreateSimpleTx use all coins for coin selection, but specify a custom scope +// that isn't the current default scope. +func TestCreateSimpleCustomChange(t *testing.T) { + t.Parallel() + + w := testWallet(t) + + // First, we'll make a P2TR and a P2WKH address to send some coins to + // (two different coin scopes). + p2wkhAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) + require.NoError(t, err) + + p2trAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0086) + require.NoError(t, err) + + // We'll now make a transaction that'll send coins to both outputs, + // then "credit" the wallet for that send. + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + p2trScript, err := txscript.PayToAddrScript(p2trAddr) + require.NoError(t, err) + + const testAmt = 1_000_000 + + incomingTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, + }, + TxOut: []*wire.TxOut{ + wire.NewTxOut(testAmt, p2wkhScript), + wire.NewTxOut(testAmt, p2trScript), + }, + } + addUtxo(t, w, incomingTx) + + // With the amounts credited to the wallet, we'll now do a dry run coin + // selection w/o any default args. + targetTxOut := &wire.TxOut{ + Value: 1_500_000, + PkScript: p2trScript, + } + tx1, err := w.txToOutputs( + []*wire.TxOut{targetTxOut}, nil, nil, 0, 1, 1000, + CoinSelectionLargest, true, nil, alwaysAllowUtxo, + ) + require.NoError(t, err) + + // We expect that all inputs were used and also the change output is a + // taproot output (the current default). + require.Len(t, tx1.Tx.TxIn, 2) + require.Len(t, tx1.Tx.TxOut, 2) + for _, txOut := range tx1.Tx.TxOut { + scriptType, _, _, err := txscript.ExtractPkScriptAddrs( + txOut.PkScript, w.chainParams, + ) + require.NoError(t, err) + + require.Equal(t, scriptType, txscript.WitnessV1TaprootTy) + } + + // Next, we'll do another dry run, but this time, specify a custom + // change key scope. We'll also require that only inputs of P2TR are used. + targetTxOut = &wire.TxOut{ + Value: 500_000, + PkScript: p2trScript, + } + tx2, err := w.txToOutputs( + []*wire.TxOut{targetTxOut}, &waddrmgr.KeyScopeBIP0086, + &waddrmgr.KeyScopeBIP0084, 0, 1, 1000, CoinSelectionLargest, + true, nil, alwaysAllowUtxo, + ) + require.NoError(t, err) + + // The resulting transaction should spend a single input, and use P2WKH + // as the output script. + require.Len(t, tx2.Tx.TxIn, 1) + require.Len(t, tx2.Tx.TxOut, 2) + for i, txOut := range tx2.Tx.TxOut { + if i != tx2.ChangeIndex { + continue + } + + scriptType, _, _, err := txscript.ExtractPkScriptAddrs( + txOut.PkScript, w.chainParams, + ) + require.NoError(t, err) + + require.Equal(t, scriptType, txscript.WitnessV0PubKeyHashTy) + } +} + +// TestSelectUtxosTxoToOutpoint tests that it is possible to use passed +// selected utxos to craft a transaction in `txToOutpoint`. +func TestSelectUtxosTxoToOutpoint(t *testing.T) { + t.Parallel() + + w := testWallet(t) + + // First, we'll make a P2TR and a P2WKH address to send some coins to. + p2wkhAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) + require.NoError(t, err) + + p2trAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0086) + require.NoError(t, err) + + // We'll now make a transaction that'll send coins to both outputs, + // then "credit" the wallet for that send. + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + p2trScript, err := txscript.PayToAddrScript(p2trAddr) + require.NoError(t, err) + + incomingTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {}, + }, + TxOut: []*wire.TxOut{ + wire.NewTxOut(1_000_000, p2wkhScript), + wire.NewTxOut(2_000_000, p2trScript), + wire.NewTxOut(3_000_000, p2trScript), + wire.NewTxOut(7_000_000, p2trScript), + }, + } + addUtxo(t, w, incomingTx) + + // We expect 4 unspent UTXOs. + unspent, err := w.ListUnspentDeprecated(0, 80, "") + require.NoError(t, err) + require.Len(t, unspent, 4, "expected 4 unspent UTXOs") + + tCases := []struct { + name string + selectUTXOs []wire.OutPoint + errString string + }{ + { + name: "Duplicate utxo values", + selectUTXOs: []wire.OutPoint{ + { + Hash: incomingTx.TxHash(), + Index: 1, + }, + { + Hash: incomingTx.TxHash(), + Index: 1, + }, + }, + errString: "selected UTXOs contain duplicate values", + }, + { + name: "all selected UTXOs not eligible for spending", + selectUTXOs: []wire.OutPoint{ + { + Hash: chainhash.Hash([32]byte{1}), + Index: 1, + }, + { + Hash: chainhash.Hash([32]byte{3}), + Index: 1, + }, + }, + errString: "selected outpoint not eligible for " + + "spending", + }, + { + name: "some select UTXOs not eligible for spending", + selectUTXOs: []wire.OutPoint{ + { + Hash: chainhash.Hash([32]byte{1}), + Index: 1, + }, + { + Hash: incomingTx.TxHash(), + Index: 1, + }, + }, + errString: "selected outpoint not eligible for " + + "spending", + }, + { + name: "select utxo, no duplicates and all eligible " + + "for spending", + selectUTXOs: []wire.OutPoint{ + { + Hash: incomingTx.TxHash(), + Index: 1, + }, + { + Hash: incomingTx.TxHash(), + Index: 2, + }, + }, + }, + } + + for _, tc := range tCases { + t.Run(tc.name, func(t *testing.T) { + // Test by sending 200_000. + targetTxOut := &wire.TxOut{ + Value: 200_000, + PkScript: p2trScript, + } + tx1, err := w.txToOutputs( + []*wire.TxOut{targetTxOut}, nil, nil, 0, 1, + 1000, CoinSelectionLargest, true, + tc.selectUTXOs, alwaysAllowUtxo, + ) + if tc.errString != "" { + require.ErrorContains(t, err, tc.errString) + require.Nil(t, tx1) + + return + } + + require.NoError(t, err) + require.NotNil(t, tx1) + + // We expect all and only our select UTXOs to be input + // in this transaction. + require.Len(t, tx1.Tx.TxIn, len(tc.selectUTXOs)) + + lookupSelectUtxos := make(map[wire.OutPoint]struct{}) + for _, utxo := range tc.selectUTXOs { + lookupSelectUtxos[utxo] = struct{}{} + } + + for _, tx := range tx1.Tx.TxIn { + _, ok := lookupSelectUtxos[tx.PreviousOutPoint] + require.True(t, ok) + } + + // Expect two outputs, change and the actual payment to + // the address. + require.Len(t, tx1.Tx.TxOut, 2) + }) + } +} + +// TestComputeInputScript checks that the wallet can create the full +// witness script for a witness output. +func TestComputeInputScript(t *testing.T) { + t.Parallel() + + w := testWallet(t) + + testCases := []struct { + name string + scope waddrmgr.KeyScope + expectedScriptLen int + }{{ + name: "BIP084 P2WKH", + scope: waddrmgr.KeyScopeBIP0084, + expectedScriptLen: 0, + }, { + name: "BIP049 nested P2WKH", + scope: waddrmgr.KeyScopeBIP0049Plus, + expectedScriptLen: 23, + }} + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + runTestCase(t, w, tc.scope, tc.expectedScriptLen) + }) + } +} + +func runTestCase(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, + scriptLen int) { + + // Create an address we can use to send some coins to. + addr, err := w.CurrentAddress(0, scope) + if err != nil { + t.Fatalf("unable to get current address: %v", addr) + } + p2shAddr, err := txscript.PayToAddrScript(addr) + if err != nil { + t.Fatalf("unable to convert wallet address to p2sh: %v", err) + } + + // Add an output paying to the wallet's address to the database. + utxOut := wire.NewTxOut(100000, p2shAddr) + incomingTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{utxOut}, + } + addUtxo(t, w, incomingTx) + + // Create a transaction that spends the UTXO created above and spends to + // the same address again. + prevOut := wire.OutPoint{ + Hash: incomingTx.TxHash(), + Index: 0, + } + outgoingTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: prevOut, + }}, + TxOut: []*wire.TxOut{utxOut}, + } + fetcher := txscript.NewCannedPrevOutputFetcher( + utxOut.PkScript, utxOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(outgoingTx, fetcher) + + // Compute the input script to spend the UTXO now. + witness, script, err := w.ComputeInputScript( + outgoingTx, utxOut, 0, sigHashes, txscript.SigHashAll, nil, + ) + if err != nil { + t.Fatalf("error computing input script: %v", err) + } + if len(script) != scriptLen { + t.Fatalf("unexpected script length, got %d wanted %d", + len(script), scriptLen) + } + if len(witness) != 2 { + t.Fatalf("unexpected witness stack length, got %d, wanted %d", + len(witness), 2) + } + + // Finally verify that the created witness is valid. + outgoingTx.TxIn[0].Witness = witness + outgoingTx.TxIn[0].SignatureScript = script + err = validateMsgTx( + outgoingTx, [][]byte{utxOut.PkScript}, []btcutil.Amount{100000}, + ) + if err != nil { + t.Fatalf("error validating tx: %v", err) + } +} diff --git a/wallet/example_test.go b/wallet/example_test.go deleted file mode 100644 index 6babad60d7..0000000000 --- a/wallet/example_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package wallet - -import ( - "testing" - "time" - - "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/walletdb" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -// defaultDBTimeout specifies the timeout value when opening the wallet -// database. -var defaultDBTimeout = 10 * time.Second - -// testWallet creates a test wallet and unlocks it. -func testWallet(t *testing.T) *Wallet { - t.Helper() - // Set up a wallet. - dir := t.TempDir() - - seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes) - if err != nil { - t.Fatalf("unable to create seed: %v", err) - } - - pubPass := []byte("hello") - privPass := []byte("world") - - loader := NewLoader( - &chainParams, dir, true, defaultDBTimeout, 250, - WithWalletSyncRetryInterval(10*time.Millisecond), - ) - w, err := loader.CreateNewWallet(pubPass, privPass, seed, time.Now()) - if err != nil { - t.Fatalf("unable to create wallet: %v", err) - } - - chainClient := &mockChainClient{} - w.chainClient = chainClient - w.cfg.Chain = chainClient - - // Start the wallet. - w.StartDeprecated() - - // Add the shutdown to the test's cleanup process. - t.Cleanup(func() { - w.StopDeprecated() - w.WaitForShutdown() - }) - - err = w.UnlockDeprecated(privPass, time.After(10*time.Minute)) - if err != nil { - t.Fatalf("unable to unlock wallet: %v", err) - } - - return w -} - -// mockers is a struct that holds all the mocked interfaces that can be -// used to test the wallet. -type mockers struct { - // chain is the mock blockchain backend. - chain *mockChain - - // addrStore is the mock address store. - addrStore *mockAddrStore - - // txStore is the mock transaction store. - txStore *mockTxStore - - // addr is the mock managed address. - addr *mockManagedAddress - - // accountManager is the mock account manager. - accountManager *mockAccountStore - - // pubKeyAddr is the mock managed public key address. - pubKeyAddr *mockManagedPubKeyAddr -} - -// testWalletWithMocks creates a test wallet and unlocks it. In contrast to -// testWallet, this function mocks out all the wallet's dependencies so that -// we can test the wallet's logic in isolation. -func testWalletWithMocks(t *testing.T) (*Wallet, *mockers) { - t.Helper() - // Set up a wallet. - dir := t.TempDir() - - seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes) - require.NoError(t, err) - - pubPass := []byte("hello") - privPass := []byte("world") - - loader := NewLoader( - &chainParams, dir, true, defaultDBTimeout, 250, - WithWalletSyncRetryInterval(10*time.Millisecond), - ) - w, err := loader.CreateNewWallet(pubPass, privPass, seed, time.Now()) - require.NoError(t, err) - - chain := &mockChain{} - txStore := &mockTxStore{} - addrStore := &mockAddrStore{} - addr := &mockManagedAddress{} - accountManager := &mockAccountStore{} - pubKeyAddr := &mockManagedPubKeyAddr{} - - addrStore.On("IsLocked").Return(false) - addrStore.On("Unlock", mock.Anything, mock.Anything).Return(nil) - - w.chainClient = chain - w.txStore = txStore - w.addrStore = addrStore - - // Start the wallet. - w.StartDeprecated() - - err = w.UnlockDeprecated(privPass, time.After(60*time.Minute)) - require.NoError(t, err) - - // Create the mockers struct so it can be used by the tests to mock - // methods. - m := &mockers{ - chain: chain, - txStore: txStore, - addrStore: addrStore, - addr: addr, - accountManager: accountManager, - pubKeyAddr: pubKeyAddr, - } - - // When the test finishes, we need to assert the mocked methods are - // called or not called as expected. - t.Cleanup(func() { - chain.AssertExpectations(t) - txStore.AssertExpectations(t) - addrStore.AssertExpectations(t) - addr.AssertExpectations(t) - accountManager.AssertExpectations(t) - pubKeyAddr.AssertExpectations(t) - }) - - return w, m -} - -// testWalletWatchingOnly creates a test watch only wallet and unlocks it. -func testWalletWatchingOnly(t *testing.T) *Wallet { - t.Helper() - // Set up a wallet. - dir := t.TempDir() - - pubPass := []byte("hello") - loader := NewLoader( - &chainParams, dir, true, defaultDBTimeout, 250, - WithWalletSyncRetryInterval(10*time.Millisecond), - ) - w, err := loader.CreateNewWatchingOnlyWallet(pubPass, time.Now()) - if err != nil { - t.Fatalf("unable to create wallet: %v", err) - } - chainClient := &mockChainClient{} - w.chainClient = chainClient - - err = walletdb.Update(w.Database(), func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) - for scope, schema := range waddrmgr.ScopeAddrMap { - _, err := w.addrStore.NewScopedKeyManager( - ns, scope, schema, - ) - if err != nil { - return err - } - } - - return nil - }) - if err != nil { - t.Fatalf("unable to create default scopes: %v", err) - } - - w.StartDeprecated() - t.Cleanup(func() { - w.StopDeprecated() - w.WaitForShutdown() - }) - - return w -} - -func init() { - // Use fast scrypt options for tests to avoid CPU exhaustion and - // timeouts, especially when running with -race. - waddrmgr.DefaultScryptOptions = waddrmgr.FastScryptOptions -} diff --git a/wallet/import_test.go b/wallet/import_test.go deleted file mode 100644 index 1905940a0a..0000000000 --- a/wallet/import_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package wallet - -import ( - "encoding/binary" - "strings" - "testing" - - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/hdkeychain" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/stretchr/testify/require" -) - -func hardenedKey(key uint32) uint32 { - return key + hdkeychain.HardenedKeyStart -} - -func deriveAcctPubKey(t *testing.T, root *hdkeychain.ExtendedKey, - scope waddrmgr.KeyScope, paths ...uint32) *hdkeychain.ExtendedKey { - - path := []uint32{hardenedKey(scope.Purpose), hardenedKey(scope.Coin)} - path = append(path, paths...) - - var ( - currentKey = root - err error - ) - for _, pathPart := range path { - currentKey, err = currentKey.Derive(pathPart) - require.NoError(t, err) - } - - // The Neuter() method checks the version and doesn't know any - // non-standard methods. We need to convert them to standard, neuter, - // then convert them back with the target extended public key version. - pubVersionBytes := make([]byte, 4) - copy(pubVersionBytes, chainParams.HDPublicKeyID[:]) - switch { - case strings.HasPrefix(root.String(), "uprv"): - binary.BigEndian.PutUint32(pubVersionBytes, uint32( - waddrmgr.HDVersionTestNetBIP0049, - )) - - case strings.HasPrefix(root.String(), "vprv"): - binary.BigEndian.PutUint32(pubVersionBytes, uint32( - waddrmgr.HDVersionTestNetBIP0084, - )) - } - - currentKey, err = currentKey.CloneWithVersion( - chainParams.HDPrivateKeyID[:], - ) - require.NoError(t, err) - currentKey, err = currentKey.Neuter() - require.NoError(t, err) - currentKey, err = currentKey.CloneWithVersion(pubVersionBytes) - require.NoError(t, err) - - return currentKey -} - -type testCase struct { - name string - masterPriv string - accountIndex uint32 - addrType waddrmgr.AddressType - expectedScope waddrmgr.KeyScope - expectedAddr string - expectedChangeAddr string -} - -var ( - testCases = []*testCase{{ - name: "bip44 with nested witness address type", - masterPriv: "tprv8ZgxMBicQKsPeWwrFuNjEGTTDSY4mRLwd2KDJAPGa1AY" + - "quw38bZqNMSuB3V1Va3hqJBo9Pt8Sx7kBQer5cNMrb8SYquoWPt9" + - "Y3BZdhdtUcw", - accountIndex: 0, - addrType: waddrmgr.NestedWitnessPubKey, - expectedScope: waddrmgr.KeyScopeBIP0049Plus, - expectedAddr: "2N5YTxG9XtGXx1YyhZb7N2pwEjoZLLMHGKj", - expectedChangeAddr: "2N7wpz5Gy2zEJTvq2MAuU6BCTEBLXNQ8dUw", - }, { - name: "bip44 with witness address type", - masterPriv: "tprv8ZgxMBicQKsPeWwrFuNjEGTTDSY4mRLwd2KDJAPGa1AY" + - "quw38bZqNMSuB3V1Va3hqJBo9Pt8Sx7kBQer5cNMrb8SYquoWPt9" + - "Y3BZdhdtUcw", - accountIndex: 777, - addrType: waddrmgr.WitnessPubKey, - expectedScope: waddrmgr.KeyScopeBIP0084, - expectedAddr: "bcrt1qllxcutkzsukf8u8c8stkp464j0esu9xquft3s0", - expectedChangeAddr: "bcrt1qu6jmqglrthscptjqj3egx54wy8xqvzn54ex9eh", - }, { - name: "traditional bip49", - masterPriv: "uprv8tXDerPXZ1QsVp8y6GAMSMYxPQgWi3LSY8qS5ZH9x1YRu" + - "1kGPFjPzR73CFSbVUhdEwJbtsUgucUJ4hGQoJnNepp3RBcE6Jhdom" + - "FD2KeY6G9", - accountIndex: 9, - addrType: waddrmgr.NestedWitnessPubKey, - expectedScope: waddrmgr.KeyScopeBIP0049Plus, - expectedAddr: "2NBCJ9WzGXZqpLpXGq3Hacybj3c4eHRcqgh", - expectedChangeAddr: "2N3bankFu6F3ZNU41iVJQqyS9MXqp9dvn1M", - }, { - name: "bip49+", - masterPriv: "uprv8tXDerPXZ1QsVp8y6GAMSMYxPQgWi3LSY8qS5ZH9x1YRu" + - "1kGPFjPzR73CFSbVUhdEwJbtsUgucUJ4hGQoJnNepp3RBcE6Jhdom" + - "FD2KeY6G9", - accountIndex: 9, - addrType: waddrmgr.WitnessPubKey, - expectedScope: waddrmgr.KeyScopeBIP0049Plus, - expectedAddr: "2NBCJ9WzGXZqpLpXGq3Hacybj3c4eHRcqgh", - expectedChangeAddr: "bcrt1qeqn05w2hfq6axpdprhs4y7x65gxkkvfvx0emz4", - }, { - name: "bip84", - masterPriv: "vprv9DMUxX4ShgxMM7L5vcwyeSeTZNpxefKwTFMerxB3L1vJ" + - "x7ZVdutxcUmBDTQBVPMYeaRQeM5FNGpqwysyX1CPT4VeHXJegDX8" + - "5VJrQvaFaz3", - accountIndex: 1, - addrType: waddrmgr.WitnessPubKey, - expectedScope: waddrmgr.KeyScopeBIP0084, - expectedAddr: "bcrt1q5vepvcl0z8xj7kps4rsux722r4dvfwlh5ntexr", - expectedChangeAddr: "bcrt1qlwe2kgxcsa8x4huu79yff4rze0l5mwaf2apn3y", - }} -) - -// TestImportAccountDeprecated tests that extended public keys can successfully -// be imported into both watch only and normal wallets. -func TestImportAccountDeprecated(t *testing.T) { - t.Parallel() - - for _, tc := range testCases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - testImportAccount(t, w, tc, false, tc.name) - }) - - name := tc.name + " watch-only" - t.Run(name, func(t *testing.T) { - t.Parallel() - - w := testWalletWatchingOnly(t) - - testImportAccount(t, w, tc, true, name) - }) - } -} - -func testImportAccount(t *testing.T, w *Wallet, tc *testCase, watchOnly bool, - name string) { - - // First derive the master public key of the account we want to import. - root, err := hdkeychain.NewKeyFromString(tc.masterPriv) - require.NoError(t, err) - - // Derive the extended private and public key for our target account. - acct1Pub := deriveAcctPubKey( - t, root, tc.expectedScope, hardenedKey(tc.accountIndex), - ) - - // We want to make sure we can import and handle multiple accounts, so - // we create another one. - acct2Pub := deriveAcctPubKey( - t, root, tc.expectedScope, hardenedKey(tc.accountIndex+1), - ) - - // And we also want to be able to import loose extended public keys - // without needing to specify an explicit scope. - acct3ExternalExtPub := deriveAcctPubKey( - t, root, tc.expectedScope, hardenedKey(tc.accountIndex+2), 0, 0, - ) - acct3ExternalPub, err := acct3ExternalExtPub.ECPubKey() - require.NoError(t, err) - - // Do a dry run import first and check that it results in the expected - // addresses being derived. - _, extAddrs, intAddrs, err := w.ImportAccountDryRun( - name+"1", acct1Pub, root.ParentFingerprint(), &tc.addrType, 1, - ) - require.NoError(t, err) - require.Len(t, extAddrs, 1) - require.Equal(t, tc.expectedAddr, extAddrs[0].Address().String()) - require.Len(t, intAddrs, 1) - require.Equal(t, tc.expectedChangeAddr, intAddrs[0].Address().String()) - - // Import the extended public keys into new accounts. - acct1, err := w.ImportAccountDeprecated( - name+"1", acct1Pub, root.ParentFingerprint(), &tc.addrType, - ) - require.NoError(t, err) - require.Equal(t, tc.expectedScope, acct1.KeyScope) - - acct2, err := w.ImportAccountDeprecated( - name+"2", acct2Pub, root.ParentFingerprint(), &tc.addrType, - ) - require.NoError(t, err) - require.Equal(t, tc.expectedScope, acct2.KeyScope) - - err = w.ImportPublicKeyDeprecated(acct3ExternalPub, tc.addrType) - require.NoError(t, err) - - // If the wallet is watch only, there is no default account and our - // imported account will be index 0. - firstAccountIndex := uint32(1) - numAccounts := 2 - if watchOnly { - firstAccountIndex = 0 - numAccounts = 1 - } - - // We should have 2 additional accounts now. - acctResult, err := w.Accounts(tc.expectedScope) - require.NoError(t, err) - require.Len(t, acctResult.Accounts, numAccounts+2) - - // Validate the state of the accounts. - require.Equal(t, firstAccountIndex, acct1.AccountNumber) - require.Equal(t, name+"1", acct1.AccountName) - require.Equal(t, true, acct1.IsWatchOnly) - require.Equal(t, root.ParentFingerprint(), acct1.MasterKeyFingerprint) - require.NotNil(t, acct1.AccountPubKey) - require.Equal(t, acct1Pub.String(), acct1.AccountPubKey.String()) - require.Equal(t, uint32(0), acct1.InternalKeyCount) - require.Equal(t, uint32(0), acct1.ExternalKeyCount) - require.Equal(t, uint32(0), acct1.ImportedKeyCount) - - require.Equal(t, firstAccountIndex+1, acct2.AccountNumber) - require.Equal(t, name+"2", acct2.AccountName) - require.Equal(t, true, acct2.IsWatchOnly) - require.Equal(t, root.ParentFingerprint(), acct2.MasterKeyFingerprint) - require.NotNil(t, acct2.AccountPubKey) - require.Equal(t, acct2Pub.String(), acct2.AccountPubKey.String()) - require.Equal(t, uint32(0), acct2.InternalKeyCount) - require.Equal(t, uint32(0), acct2.ExternalKeyCount) - require.Equal(t, uint32(0), acct2.ImportedKeyCount) - - // Test address derivation. - extAddr, err := w.NewAddressDeprecated( - acct1.AccountNumber, tc.expectedScope, - ) - require.NoError(t, err) - require.Equal(t, tc.expectedAddr, extAddr.String()) - intAddr, err := w.NewChangeAddress(acct1.AccountNumber, tc.expectedScope) - require.NoError(t, err) - require.Equal(t, tc.expectedChangeAddr, intAddr.String()) - - // Make sure the key count was increased. - acct1, err = w.AccountProperties(tc.expectedScope, acct1.AccountNumber) - require.NoError(t, err) - require.Equal(t, uint32(1), acct1.InternalKeyCount) - require.Equal(t, uint32(1), acct1.ExternalKeyCount) - require.Equal(t, uint32(0), acct1.ImportedKeyCount) - - // Make sure we can't get private keys for the imported - // accounts. - _, err = w.DumpWIFPrivateKey(intAddr) - require.True(t, waddrmgr.IsError(err, waddrmgr.ErrWatchingOnly)) - - // Get the address info for the single key we imported. - switch tc.addrType { - case waddrmgr.NestedWitnessPubKey: - witnessAddr, err := btcutil.NewAddressWitnessPubKeyHash( - btcutil.Hash160(acct3ExternalPub.SerializeCompressed()), - w.chainParams, - ) - require.NoError(t, err) - - witnessProg, err := txscript.PayToAddrScript(witnessAddr) - require.NoError(t, err) - - intAddr, err = btcutil.NewAddressScriptHash( - witnessProg, w.chainParams, - ) - require.NoError(t, err) - - case waddrmgr.WitnessPubKey: - intAddr, err = btcutil.NewAddressWitnessPubKeyHash( - btcutil.Hash160(acct3ExternalPub.SerializeCompressed()), - w.chainParams, - ) - require.NoError(t, err) - - default: - t.Fatalf("unhandled address type %v", tc.addrType) - } - - addrManaged, err := w.AddressInfoDeprecated(intAddr) - require.NoError(t, err) - require.Equal(t, true, addrManaged.Imported()) -} diff --git a/wallet/psbt_test.go b/wallet/psbt_test.go deleted file mode 100644 index 67994f99c7..0000000000 --- a/wallet/psbt_test.go +++ /dev/null @@ -1,516 +0,0 @@ -// Copyright (c) 2020 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "bytes" - "encoding/hex" - "testing" - - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/txscript" - "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/wallet/txrules" - "github.com/btcsuite/btcwallet/wallet/txsizes" - "github.com/stretchr/testify/require" -) - -var ( - testScriptP2WSH, _ = hex.DecodeString( - "0020d554616badeb46ccd4ce4b115e1c8d098e942d1387212d0af9ff93a1" + - "9c8f100e", - ) - testScriptP2WKH, _ = hex.DecodeString( - "0014e7a43aa41ef6d72dc6baeeaad8362cedf63b79a3", - ) -) - -// TestFundPsbt tests that a given PSBT packet is funded correctly. -func TestFundPsbt(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // Create a P2WKH address we can use to send some coins to. - addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) - require.NoError(t, err) - p2wkhAddr, err := txscript.PayToAddrScript(addr) - require.NoError(t, err) - - // Also create a nested P2WKH address we can use to send some coins to. - addr, err = w.CurrentAddress(0, waddrmgr.KeyScopeBIP0049Plus) - require.NoError(t, err) - np2wkhAddr, err := txscript.PayToAddrScript(addr) - require.NoError(t, err) - - // Register two big UTXO that will be used when funding the PSBT. - const utxo1Amount = 1000000 - incomingTx1 := &wire.MsgTx{ - TxIn: []*wire.TxIn{{}}, - TxOut: []*wire.TxOut{wire.NewTxOut(utxo1Amount, p2wkhAddr)}, - } - addUtxo(t, w, incomingTx1) - utxo1 := wire.OutPoint{ - Hash: incomingTx1.TxHash(), - Index: 0, - } - - const utxo2Amount = 900000 - incomingTx2 := &wire.MsgTx{ - TxIn: []*wire.TxIn{{}}, - TxOut: []*wire.TxOut{wire.NewTxOut(utxo2Amount, np2wkhAddr)}, - } - addUtxo(t, w, incomingTx2) - utxo2 := wire.OutPoint{ - Hash: incomingTx2.TxHash(), - Index: 0, - } - - testCases := []struct { - name string - packet *psbt.Packet - feeRateSatPerKB btcutil.Amount - changeKeyScope *waddrmgr.KeyScope - expectedErr string - validatePackage bool - expectedChangeBeforeFee int64 - expectedInputs []wire.OutPoint - additionalChecks func(*testing.T, *psbt.Packet, int32) - }{{ - name: "no outputs provided", - packet: &psbt.Packet{ - UnsignedTx: &wire.MsgTx{}, - }, - feeRateSatPerKB: 0, - expectedErr: "PSBT packet must contain at least one " + - "input or output", - }, { - name: "single input, no outputs", - packet: &psbt.Packet{ - UnsignedTx: &wire.MsgTx{ - TxIn: []*wire.TxIn{{ - PreviousOutPoint: utxo1, - }}, - }, - Inputs: []psbt.PInput{{}}, - }, - feeRateSatPerKB: 20000, - validatePackage: true, - expectedInputs: []wire.OutPoint{utxo1}, - expectedChangeBeforeFee: utxo1Amount, - }, { - name: "no dust outputs", - packet: &psbt.Packet{ - UnsignedTx: &wire.MsgTx{ - TxOut: []*wire.TxOut{{ - PkScript: []byte("foo"), - Value: 100, - }}, - }, - Outputs: []psbt.POutput{{}}, - }, - feeRateSatPerKB: 0, - expectedErr: "transaction output is dust", - }, { - name: "two outputs, no inputs", - packet: &psbt.Packet{ - UnsignedTx: &wire.MsgTx{ - TxOut: []*wire.TxOut{{ - PkScript: testScriptP2WSH, - Value: 100000, - }, { - PkScript: testScriptP2WKH, - Value: 50000, - }}, - }, - Outputs: []psbt.POutput{{}, {}}, - }, - feeRateSatPerKB: 2000, // 2 sat/byte - expectedErr: "", - validatePackage: true, - expectedChangeBeforeFee: utxo1Amount - 150000, - expectedInputs: []wire.OutPoint{utxo1}, - }, { - name: "large output, no inputs", - packet: &psbt.Packet{ - UnsignedTx: &wire.MsgTx{ - TxOut: []*wire.TxOut{{ - PkScript: testScriptP2WSH, - Value: 1500000, - }}, - }, - Outputs: []psbt.POutput{{}}, - }, - feeRateSatPerKB: 4000, // 4 sat/byte - expectedErr: "", - validatePackage: true, - expectedChangeBeforeFee: (utxo1Amount + utxo2Amount) - 1500000, - expectedInputs: []wire.OutPoint{utxo1, utxo2}, - }, { - name: "two outputs, two inputs", - packet: &psbt.Packet{ - UnsignedTx: &wire.MsgTx{ - TxIn: []*wire.TxIn{{ - PreviousOutPoint: utxo1, - }, { - PreviousOutPoint: utxo2, - }}, - TxOut: []*wire.TxOut{{ - PkScript: testScriptP2WSH, - Value: 100000, - }, { - PkScript: testScriptP2WKH, - Value: 50000, - }}, - }, - Inputs: []psbt.PInput{{}, {}}, - Outputs: []psbt.POutput{{}, {}}, - }, - feeRateSatPerKB: 2000, // 2 sat/byte - expectedErr: "", - validatePackage: true, - expectedChangeBeforeFee: (utxo1Amount + utxo2Amount) - 150000, - expectedInputs: []wire.OutPoint{utxo1, utxo2}, - additionalChecks: func(t *testing.T, packet *psbt.Packet, - changeIndex int32) { - - // Check outputs, find index for each of the 3 expected. - txOuts := packet.UnsignedTx.TxOut - require.Len(t, txOuts, 3, "tx outputs") - - p2wkhIndex := -1 - p2wshIndex := -1 - totalOut := int64(0) - for idx, txOut := range txOuts { - script := txOut.PkScript - totalOut += txOut.Value - - switch { - case bytes.Equal(script, testScriptP2WKH): - p2wkhIndex = idx - - case bytes.Equal(script, testScriptP2WSH): - p2wshIndex = idx - - } - } - totalIn := int64(0) - for _, txIn := range packet.Inputs { - totalIn += txIn.WitnessUtxo.Value - } - - // All outputs must be found. - require.Greater(t, p2wkhIndex, -1) - require.Greater(t, p2wshIndex, -1) - require.Greater(t, changeIndex, int32(-1)) - - // After BIP 69 sorting, the P2WKH output should be - // before the P2WSH output because the PK script is - // lexicographically smaller. - require.Less( - t, p2wkhIndex, p2wshIndex, - "index after sorting", - ) - }, - }, { - name: "one input and a custom change scope: BIP0084", - packet: &psbt.Packet{ - UnsignedTx: &wire.MsgTx{ - TxIn: []*wire.TxIn{{ - PreviousOutPoint: utxo1, - }}, - }, - Inputs: []psbt.PInput{{}}, - }, - feeRateSatPerKB: 20000, - validatePackage: true, - changeKeyScope: &waddrmgr.KeyScopeBIP0084, - expectedInputs: []wire.OutPoint{utxo1}, - expectedChangeBeforeFee: utxo1Amount, - }, { - name: "no inputs and a custom change scope: BIP0084", - packet: &psbt.Packet{ - UnsignedTx: &wire.MsgTx{ - TxOut: []*wire.TxOut{{ - PkScript: testScriptP2WSH, - Value: 100000, - }, { - PkScript: testScriptP2WKH, - Value: 50000, - }}, - }, - Outputs: []psbt.POutput{{}, {}}, - }, - feeRateSatPerKB: 2000, // 2 sat/byte - expectedErr: "", - validatePackage: true, - changeKeyScope: &waddrmgr.KeyScopeBIP0084, - expectedChangeBeforeFee: utxo1Amount - 150000, - expectedInputs: []wire.OutPoint{utxo1}, - }} - - calcFee := func(feeRateSatPerKB btcutil.Amount, - packet *psbt.Packet) btcutil.Amount { - - var numP2WKHInputs, numNP2WKHInputs int - for _, txin := range packet.UnsignedTx.TxIn { - if txin.PreviousOutPoint == utxo1 { - numP2WKHInputs++ - } - if txin.PreviousOutPoint == utxo2 { - numNP2WKHInputs++ - } - } - estimatedSize := txsizes.EstimateVirtualSize( - 0, 0, numP2WKHInputs, numNP2WKHInputs, - packet.UnsignedTx.TxOut, 0, - ) - return txrules.FeeForSerializeSize( - feeRateSatPerKB, estimatedSize, - ) - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - changeIndex, err := w.FundPsbtDeprecated( - tc.packet, nil, 1, 0, - tc.feeRateSatPerKB, CoinSelectionLargest, - WithCustomChangeScope(tc.changeKeyScope), - ) - - // In any case, unlock the UTXO before continuing, we - // don't want to pollute other test iterations. - for _, in := range tc.packet.UnsignedTx.TxIn { - w.UnlockOutpoint(in.PreviousOutPoint) - } - - // Make sure the error is what we expected. - if tc.expectedErr != "" { - require.ErrorContains(t, err, tc.expectedErr) - return - } - - require.NoError(t, err) - - if !tc.validatePackage { - return - } - - // Check wire inputs. - packet := tc.packet - assertTxInputs(t, packet, tc.expectedInputs) - - // Run any additional tests if available. - if tc.additionalChecks != nil { - tc.additionalChecks(t, packet, changeIndex) - } - - // Finally, check the change output size and fee. - txOuts := packet.UnsignedTx.TxOut - totalOut := int64(0) - for _, txOut := range txOuts { - totalOut += txOut.Value - } - totalIn := int64(0) - for _, txIn := range packet.Inputs { - totalIn += txIn.WitnessUtxo.Value - } - fee := totalIn - totalOut - - expectedFee := calcFee(tc.feeRateSatPerKB, packet) - require.EqualValues(t, expectedFee, fee, "fee") - require.EqualValues( - t, tc.expectedChangeBeforeFee, - txOuts[changeIndex].Value+int64(expectedFee), - ) - - changeTxOut := txOuts[changeIndex] - changeOutput := packet.Outputs[changeIndex] - - require.NotEmpty(t, changeOutput.Bip32Derivation) - b32d := changeOutput.Bip32Derivation[0] - require.Len(t, b32d.Bip32Path, 5, "derivation path len") - require.Len(t, b32d.PubKey, 33, "pubkey len") - - // The third item should be the branch and should belong - // to a change output. - require.EqualValues(t, 1, b32d.Bip32Path[3]) - - assertChangeOutputScope( - t, changeTxOut.PkScript, tc.changeKeyScope, - ) - - if txscript.IsPayToTaproot(changeTxOut.PkScript) { - require.NotEmpty( - t, changeOutput.TaprootInternalKey, - ) - require.Len( - t, changeOutput.TaprootInternalKey, 32, - "internal key len", - ) - require.NotEmpty( - t, changeOutput.TaprootBip32Derivation, - ) - - trb32d := changeOutput.TaprootBip32Derivation[0] - require.Equal( - t, b32d.Bip32Path, trb32d.Bip32Path, - ) - require.Len( - t, trb32d.XOnlyPubKey, 32, - "schnorr pubkey len", - ) - require.Equal( - t, changeOutput.TaprootInternalKey, - trb32d.XOnlyPubKey, - ) - } - }) - } -} - -func assertTxInputs(t *testing.T, packet *psbt.Packet, - expected []wire.OutPoint) { - - require.Len(t, packet.UnsignedTx.TxIn, len(expected)) - - // The order of the UTXOs is random, we need to loop through each of - // them to make sure they're found. We also check that no signature data - // was added yet. - for _, txIn := range packet.UnsignedTx.TxIn { - if !containsUtxo(expected, txIn.PreviousOutPoint) { - t.Fatalf("outpoint %v not found in list of expected "+ - "UTXOs", txIn.PreviousOutPoint) - } - - require.Empty(t, txIn.SignatureScript) - require.Empty(t, txIn.Witness) - } -} - -// assertChangeOutputScope checks if the pkScript has the right type. -func assertChangeOutputScope(t *testing.T, pkScript []byte, - changeScope *waddrmgr.KeyScope) { - - // By default (changeScope == nil), the script should - // be a pay-to-taproot one. - switch changeScope { - case nil, &waddrmgr.KeyScopeBIP0086: - require.True(t, txscript.IsPayToTaproot(pkScript)) - - case &waddrmgr.KeyScopeBIP0049Plus, &waddrmgr.KeyScopeBIP0084: - require.True(t, txscript.IsPayToWitnessPubKeyHash(pkScript)) - - case &waddrmgr.KeyScopeBIP0044: - require.True(t, txscript.IsPayToPubKeyHash(pkScript)) - - default: - require.Fail(t, "assertChangeOutputScope error", - "change scope: %s", changeScope.String()) - } -} - -func containsUtxo(list []wire.OutPoint, candidate wire.OutPoint) bool { - for _, utxo := range list { - if utxo == candidate { - return true - } - } - - return false -} - -// TestFinalizePsbt tests that a given PSBT packet can be finalized. -func TestFinalizePsbt(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - // Create a P2WKH address we can use to send some coins to. - addr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) - if err != nil { - t.Fatalf("unable to get current address: %v", addr) - } - p2wkhAddr, err := txscript.PayToAddrScript(addr) - if err != nil { - t.Fatalf("unable to convert wallet address to p2wkh: %v", err) - } - - // Also create a nested P2WKH address we can send coins to. - addr, err = w.CurrentAddress(0, waddrmgr.KeyScopeBIP0049Plus) - if err != nil { - t.Fatalf("unable to get current address: %v", addr) - } - np2wkhAddr, err := txscript.PayToAddrScript(addr) - if err != nil { - t.Fatalf("unable to convert wallet address to np2wkh: %v", err) - } - - // Register two big UTXO that will be used when funding the PSBT. - utxOutP2WKH := wire.NewTxOut(1000000, p2wkhAddr) - utxOutNP2WKH := wire.NewTxOut(1000000, np2wkhAddr) - incomingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{{}}, - TxOut: []*wire.TxOut{utxOutP2WKH, utxOutNP2WKH}, - } - addUtxo(t, w, incomingTx) - - // Create the packet that we want to sign. - packet := &psbt.Packet{ - UnsignedTx: &wire.MsgTx{ - TxIn: []*wire.TxIn{{ - PreviousOutPoint: wire.OutPoint{ - Hash: incomingTx.TxHash(), - Index: 0, - }, - }, { - PreviousOutPoint: wire.OutPoint{ - Hash: incomingTx.TxHash(), - Index: 1, - }, - }}, - TxOut: []*wire.TxOut{{ - PkScript: testScriptP2WKH, - Value: 50000, - }, { - PkScript: testScriptP2WSH, - Value: 100000, - }, { - PkScript: testScriptP2WKH, - Value: 849632, - }}, - }, - Inputs: []psbt.PInput{{ - WitnessUtxo: utxOutP2WKH, - SighashType: txscript.SigHashAll, - }, { - NonWitnessUtxo: incomingTx, - SighashType: txscript.SigHashAll, - }}, - Outputs: []psbt.POutput{{}, {}, {}}, - } - - // Finalize it to add all witness data then extract the final TX. - err = w.FinalizePsbtDeprecated(nil, 0, packet) - if err != nil { - t.Fatalf("error finalizing PSBT packet: %v", err) - } - finalTx, err := psbt.Extract(packet) - if err != nil { - t.Fatalf("error extracting final TX from PSBT: %v", err) - } - - // Finally verify that the created witness is valid. - err = validateMsgTx( - finalTx, [][]byte{utxOutP2WKH.PkScript, utxOutNP2WKH.PkScript}, - []btcutil.Amount{1000000, 1000000}, - ) - if err != nil { - t.Fatalf("error validating tx: %v", err) - } -} diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 148b6aea06..9f2637e409 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -38,100 +38,6 @@ var ( errSignMock = errors.New("sign error") ) -// TestComputeInputScript checks that the wallet can create the full -// witness script for a witness output. -func TestComputeInputScript(t *testing.T) { - t.Parallel() - - w := testWallet(t) - - testCases := []struct { - name string - scope waddrmgr.KeyScope - expectedScriptLen int - }{{ - name: "BIP084 P2WKH", - scope: waddrmgr.KeyScopeBIP0084, - expectedScriptLen: 0, - }, { - name: "BIP049 nested P2WKH", - scope: waddrmgr.KeyScopeBIP0049Plus, - expectedScriptLen: 23, - }} - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - runTestCase(t, w, tc.scope, tc.expectedScriptLen) - }) - } -} - -func runTestCase(t *testing.T, w *Wallet, scope waddrmgr.KeyScope, - scriptLen int) { - - // Create an address we can use to send some coins to. - addr, err := w.CurrentAddress(0, scope) - if err != nil { - t.Fatalf("unable to get current address: %v", addr) - } - p2shAddr, err := txscript.PayToAddrScript(addr) - if err != nil { - t.Fatalf("unable to convert wallet address to p2sh: %v", err) - } - - // Add an output paying to the wallet's address to the database. - utxOut := wire.NewTxOut(100000, p2shAddr) - incomingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{{}}, - TxOut: []*wire.TxOut{utxOut}, - } - addUtxo(t, w, incomingTx) - - // Create a transaction that spends the UTXO created above and spends to - // the same address again. - prevOut := wire.OutPoint{ - Hash: incomingTx.TxHash(), - Index: 0, - } - outgoingTx := &wire.MsgTx{ - TxIn: []*wire.TxIn{{ - PreviousOutPoint: prevOut, - }}, - TxOut: []*wire.TxOut{utxOut}, - } - fetcher := txscript.NewCannedPrevOutputFetcher( - utxOut.PkScript, utxOut.Value, - ) - sigHashes := txscript.NewTxSigHashes(outgoingTx, fetcher) - - // Compute the input script to spend the UTXO now. - witness, script, err := w.ComputeInputScript( - outgoingTx, utxOut, 0, sigHashes, txscript.SigHashAll, nil, - ) - if err != nil { - t.Fatalf("error computing input script: %v", err) - } - if len(script) != scriptLen { - t.Fatalf("unexpected script length, got %d wanted %d", - len(script), scriptLen) - } - if len(witness) != 2 { - t.Fatalf("unexpected witness stack length, got %d, wanted %d", - len(witness), 2) - } - - // Finally verify that the created witness is valid. - outgoingTx.TxIn[0].Witness = witness - outgoingTx.TxIn[0].SignatureScript = script - err = validateMsgTx( - outgoingTx, [][]byte{utxOut.PkScript}, []btcutil.Amount{100000}, - ) - if err != nil { - t.Fatalf("error validating tx: %v", err) - } -} - // TestDerivePubKeySuccess tests the successful derivation of a public key. func TestDerivePubKeySuccess(t *testing.T) { t.Parallel() diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 0362d1b096..4c2b264c6d 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -2,22 +2,23 @@ package wallet import ( "encoding/hex" + "errors" "fmt" - "math" - "strings" - "sync" - "sync/atomic" "testing" "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" - "github.com/btcsuite/btcwallet/waddrmgr" - "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" - "github.com/stretchr/testify/require" - "golang.org/x/sync/errgroup" +) + +var ( + // errBlockNotFound is an error returned when a block is not found. + errBlockNotFound = errors.New("block not found") + + // errHeaderNotFound is an error returned when a header is not found. + errHeaderNotFound = errors.New("header not found") ) var ( @@ -35,6 +36,84 @@ var ( } ) +// mockChainConn is a mock in-memory implementation of the chainConn interface +// that will be used for the birthday block sanity check tests. The struct is +// capable of being backed by a chain in order to reproduce real-world +// scenarios. +type mockChainConn struct { + chainTip uint32 + blockHashes map[uint32]chainhash.Hash + blocks map[chainhash.Hash]*wire.MsgBlock +} + +var _ chainConn = (*mockChainConn)(nil) + +// createMockChainConn creates a new mock chain connection backed by a chain +// with N blocks. Each block has a timestamp that is exactly blockInterval after +// the previous block's timestamp. +func createMockChainConn(genesis *wire.MsgBlock, n uint32, + blockInterval time.Duration) *mockChainConn { + + c := &mockChainConn{ + chainTip: n, + blockHashes: make(map[uint32]chainhash.Hash), + blocks: make(map[chainhash.Hash]*wire.MsgBlock), + } + + genesisHash := genesis.BlockHash() + c.blockHashes[0] = genesisHash + c.blocks[genesisHash] = genesis + + for i := uint32(1); i <= n; i++ { + prevTimestamp := c.blocks[c.blockHashes[i-1]].Header.Timestamp + block := &wire.MsgBlock{ + Header: wire.BlockHeader{ + Timestamp: prevTimestamp.Add(blockInterval), + }, + } + + blockHash := block.BlockHash() + c.blockHashes[i] = blockHash + c.blocks[blockHash] = block + } + + return c +} + +// GetBestBlock returns the hash and height of the best block known to the +// backend. +func (c *mockChainConn) GetBestBlock() (*chainhash.Hash, int32, error) { + bestHash, ok := c.blockHashes[c.chainTip] + if !ok { + return nil, 0, fmt.Errorf("%w: height %d", + errBlockNotFound, c.chainTip) + } + + return &bestHash, int32(c.chainTip), nil +} + +// GetBlockHash returns the hash of the block with the given height. +func (c *mockChainConn) GetBlockHash(height int64) (*chainhash.Hash, error) { + hash, ok := c.blockHashes[uint32(height)] + if !ok { + return nil, fmt.Errorf("%w: height %d", errBlockNotFound, height) + } + + return &hash, nil +} + +// GetBlockHeader returns the header for the block with the given hash. +func (c *mockChainConn) GetBlockHeader( + hash *chainhash.Hash) (*wire.BlockHeader, error) { + + block, ok := c.blocks[*hash] + if !ok { + return nil, fmt.Errorf("%w: hash %v", errHeaderNotFound, hash) + } + + return &block.Header, nil +} + // TestLocateBirthdayBlock ensures we can properly map a block in the chain to a // timestamp. func TestLocateBirthdayBlock(t *testing.T) { @@ -114,542 +193,3 @@ func TestLocateBirthdayBlock(t *testing.T) { } } } - -// TestLabelTransaction tests labelling of transactions with invalid labels, -// and failure to label a transaction when it already has a label. -func TestLabelTransaction(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - - // Whether the transaction should be known to the wallet. - txKnown bool - - // Whether the test should write an existing label to disk. - existingLabel bool - - // The overwrite parameter to call label transaction with. - overwrite bool - - // The error we expect to be returned. - expectedErr error - }{ - { - name: "existing label, not overwrite", - txKnown: true, - existingLabel: true, - overwrite: false, - expectedErr: ErrTxLabelExists, - }, - { - name: "existing label, overwritten", - txKnown: true, - existingLabel: true, - overwrite: true, - expectedErr: nil, - }, - { - name: "no prexisting label, ok", - txKnown: true, - existingLabel: false, - overwrite: false, - expectedErr: nil, - }, - { - name: "transaction unknown", - txKnown: false, - existingLabel: false, - overwrite: false, - expectedErr: ErrUnknownTransaction, - }, - } - - for _, test := range tests { - test := test - - t.Run(test.name, func(t *testing.T) { - w := testWallet(t) - - // If the transaction should be known to the store, we - // write txdetail to disk. - if test.txKnown { - rec, err := wtxmgr.NewTxRecord( - TstSerializedTx, time.Now(), - ) - if err != nil { - t.Fatal(err) - } - - err = walletdb.Update(w.db, - func(tx walletdb.ReadWriteTx) error { - - ns := tx.ReadWriteBucket( - wtxmgrNamespaceKey, - ) - - return w.txStore.InsertTx( - ns, rec, nil, - ) - }) - if err != nil { - t.Fatalf("could not insert tx: %v", err) - } - } - - // If we want to setup an existing label for the purpose - // of the test, write one to disk. - if test.existingLabel { - err := w.LabelTransaction( - *TstTxHash, "existing label", false, - ) - if err != nil { - t.Fatalf("could not write label: %v", - err) - } - } - - newLabel := "new label" - err := w.LabelTransaction( - *TstTxHash, newLabel, test.overwrite, - ) - if err != test.expectedErr { - t.Fatalf("expected: %v, got: %v", - test.expectedErr, err) - } - }) - } -} - -// TestGetTransaction tests if we can fetch a mined, an existing -// and a non-existing transaction from the wallet like we expect. -func TestGetTransaction(t *testing.T) { - t.Parallel() - rec, err := wtxmgr.NewTxRecord(TstSerializedTx, time.Now()) - require.NoError(t, err) - - tests := []struct { - name string - - // Transaction id. - txid chainhash.Hash - - // Expected height. - expectedHeight int32 - - // Store function. - f func(wtxmgr.TxStore, - walletdb.ReadWriteBucket) (wtxmgr.TxStore, error) - - // The error we expect to be returned. - expectedErr error - }{ - { - name: "existing unmined transaction", - txid: *TstTxHash, - expectedHeight: -1, - // We write txdetail for the tx to disk. - f: func(s wtxmgr.TxStore, ns walletdb.ReadWriteBucket) ( - wtxmgr.TxStore, error) { - - err = s.InsertTx(ns, rec, nil) - return s, err - }, - expectedErr: nil, - }, - { - name: "existing mined transaction", - txid: *TstTxHash, - // We write txdetail for the tx to disk. - f: func(s wtxmgr.TxStore, ns walletdb.ReadWriteBucket) ( - wtxmgr.TxStore, error) { - - err = s.InsertTx(ns, rec, TstMinedSignedTxBlockDetails) - return s, err - }, - expectedHeight: TstMinedTxBlockHeight, - expectedErr: nil, - }, - { - name: "non-existing transaction", - txid: *TstTxHash, - // Write no txdetail to disk. - f: func(s wtxmgr.TxStore, _ walletdb.ReadWriteBucket) ( - wtxmgr.TxStore, error) { - - return s, nil - }, - expectedErr: ErrNoTx, - }, - } - for _, test := range tests { - test := test - - t.Run(test.name, func(t *testing.T) { - w := testWallet(t) - - err := walletdb.Update(w.db, func(rw walletdb.ReadWriteTx) error { - ns := rw.ReadWriteBucket(wtxmgrNamespaceKey) - _, err := test.f(w.txStore, ns) - return err - }) - require.NoError(t, err) - tx, err := w.GetTransaction(test.txid) - require.ErrorIs(t, err, test.expectedErr) - - // Discontinue if no transaction were found. - if err != nil { - return - } - - // Check if we get the expected hash. - require.Equal(t, &test.txid, tx.Summary.Hash) - - // Check the block height. - require.Equal(t, test.expectedHeight, tx.Height) - }) - } -} - -// TestGetTransactionConfirmations tests that GetTransaction correctly -// calculates confirmations for both confirmed and unconfirmed transactions. -// This is a regression test for a bug where confirmations were set to the -// block height instead of being calculated as currentHeight - blockHeight + 1. -// -// The bug had several negative impacts: -// - Unconfirmed transactions showed -1 confirmations instead of 0, breaking -// zero-conf (accepting transactions before block inclusion) -// - Confirmed transactions showed block height instead of actual confirmation -// count -// - LND and other consumers would make incorrect decisions based on wrong -// counts -func TestGetTransactionConfirmations(t *testing.T) { - t.Parallel() - - rec, err := wtxmgr.NewTxRecord(TstSerializedTx, time.Now()) - require.NoError(t, err) - - tests := []struct { - name string - - // Block height where transaction is mined (-1 for unmined). - txBlockHeight int32 - - // Current wallet sync height. - currentHeight int32 - - // Expected confirmations. - expectedConfirmations int32 - - // Expected height in result. - expectedHeight int32 - - // Whether to check for non-zero timestamp. - expectTimestamp bool - }{ - { - name: "unconfirmed tx", - txBlockHeight: -1, - currentHeight: 100, - expectedConfirmations: 0, - expectedHeight: -1, - expectTimestamp: false, - }, - { - name: "tx with 1 confirmation", - txBlockHeight: 100, - currentHeight: 100, - expectedConfirmations: 1, - expectedHeight: 100, - expectTimestamp: true, - }, - { - name: "tx with 3 confirmations", - txBlockHeight: 8, - currentHeight: 10, - expectedConfirmations: 3, - expectedHeight: 8, - expectTimestamp: true, - }, - { - name: "old tx with many confirmations", - txBlockHeight: 1, - currentHeight: 1000, - expectedConfirmations: 1000, - expectedHeight: 1, - expectTimestamp: true, - }, - { - name: "tx in future block", - txBlockHeight: 105, - currentHeight: 100, - expectedConfirmations: 0, - expectedHeight: 105, - expectTimestamp: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - w := testWallet(t) - - // Set the wallet's synced height. - err := walletdb.Update( - w.db, func(tx walletdb.ReadWriteTx) error { - addrmgrNs := tx.ReadWriteBucket( - waddrmgrNamespaceKey, - ) - bs := &waddrmgr.BlockStamp{ - Height: tt.currentHeight, - Hash: chainhash.Hash{}, - } - - return w.addrStore.SetSyncedTo( - addrmgrNs, bs, - ) - }, - ) - require.NoError(t, err) - - // Insert transaction into wallet. - err = walletdb.Update( - w.db, func(tx walletdb.ReadWriteTx) error { - ns := tx.ReadWriteBucket( - wtxmgrNamespaceKey, - ) - - // Create block metadata if transaction - // is mined. - var blockMeta *wtxmgr.BlockMeta - if tt.txBlockHeight != -1 { - hash := chainhash.Hash{} - height := tt.txBlockHeight - block := wtxmgr.Block{ - Hash: hash, - Height: height, - } - blockMeta = &wtxmgr.BlockMeta{ - Block: block, - Time: time.Now(), - } - } - - return w.txStore.InsertTx( - ns, rec, blockMeta, - ) - }, - ) - require.NoError(t, err) - - result, err := w.GetTransaction(*TstTxHash) - require.NoError(t, err) - - require.Equal( - t, tt.expectedConfirmations, - result.Confirmations, - ) - - require.Equal(t, tt.expectedHeight, result.Height) - - if tt.expectTimestamp { - require.NotZero(t, result.Timestamp) - } else { - require.Zero(t, result.Timestamp) - } - - // Additional checks for unconfirmed transactions. - if tt.txBlockHeight == -1 { - require.Nil(t, result.BlockHash) - require.Equal(t, int32(0), result.Confirmations) - } else { - require.NotNil(t, result.BlockHash) - // Only expect positive confirmations when tx is - // not in a future block. - if tt.txBlockHeight <= tt.currentHeight { - require.Positive( - t, result.Confirmations, - ) - } else { - // Confirmed txns in future blocks for - // example due to reorg should be - // treated as unconfirmed and have 0 - // confirmations. - require.Equal( - t, int32(0), - result.Confirmations, - ) - } - } - }) - } -} - -// TestDuplicateAddressDerivation tests that duplicate addresses are not -// derived when multiple goroutines are concurrently requesting new addresses. -func TestDuplicateAddressDerivation(t *testing.T) { - w := testWallet(t) - var ( - m sync.Mutex - globalAddrs = make(map[string]btcutil.Address) - ) - - for o := 0; o < 10; o++ { - var eg errgroup.Group - - for n := 0; n < 10; n++ { - eg.Go(func() error { - addrs := make([]btcutil.Address, 10) - for i := 0; i < 10; i++ { - addr, err := w.NewAddressDeprecated( - 0, waddrmgr.KeyScopeBIP0084, - ) - if err != nil { - return err - } - - addrs[i] = addr - } - - m.Lock() - defer m.Unlock() - - for idx := range addrs { - addrStr := addrs[idx].String() - if a, ok := globalAddrs[addrStr]; ok { - return fmt.Errorf("duplicate "+ - "address! already "+ - "have %v, want to "+ - "add %v", a, addrs[idx]) - } - - globalAddrs[addrStr] = addrs[idx] - } - - return nil - }) - } - - require.NoError(t, eg.Wait()) - } -} - -func TestEndRecovery(t *testing.T) { - // This is an unconventional unit test, but I'm trying to keep things as - // succint as possible so that this test is readable without having to mock - // up literally everything. - // The unmonitored goroutine we're looking at is pretty deep: - // SynchronizeRPC -> handleChainNotifications -> syncWithChain -> recovery - // The "deadlock" we're addressing isn't actually a deadlock, but the wallet - // will hang on Stop() -> WaitForShutdown() until (*Wallet).recovery gets - // every single block, which could be hours depending on hardware and - // network factors. The WaitGroup is incremented in SynchronizeRPC, and - // WaitForShutdown will not return until handleChainNotifications returns, - // which is blocked by a running (*Wallet).recovery loop. - // It is noted that the conditions for long recovery are difficult to hit - // when using btcwallet with a fresh seed, because it requires an early - // birthday to be set or established. - - w := testWallet(t) - - blockHashCalled := make(chan struct{}) - - chainClient := &mockChainClient{ - // Force the loop to iterate about forever. - getBestBlockHeight: math.MaxInt32, - // Get control of when the loop iterates. - getBlockHashFunc: func() (*chainhash.Hash, error) { - blockHashCalled <- struct{}{} - return &chainhash.Hash{}, nil - }, - // Avoid a panic. - getBlockHeader: &wire.BlockHeader{}, - } - - recoveryDone := make(chan struct{}) - go func() { - defer close(recoveryDone) - w.recovery(chainClient, &waddrmgr.BlockStamp{}) - }() - - getBlockHashCalls := func(expCalls int) { - var i int - for { - select { - case <-blockHashCalled: - i++ - case <-time.After(time.Second): - t.Fatal("expected BlockHash to be called") - } - if i == expCalls { - break - } - } - } - - // Recovery is running. - getBlockHashCalls(3) - - // Closing the quit channel, e.g. Stop() without endRecovery, alone will not - // end the recovery loop. - w.quitMu.Lock() - close(w.quit) - w.quitMu.Unlock() - // Continues scanning. - getBlockHashCalls(3) - - // We're done with this one - atomic.StoreUint32(&w.recovering.Load().(*recoverySyncer).quit, 1) - select { - case <-blockHashCalled: - case <-recoveryDone: - } - - // Try again. - w = testWallet(t) - - // We'll catch the error to make sure we're hitting our desired path. The - // WaitGroup isn't required for the test, but does show how it completes - // shutdown at a higher level. - var err error - w.wg.Add(1) - recoveryDone = make(chan struct{}) - go func() { - defer w.wg.Done() - defer close(recoveryDone) - err = w.recovery(chainClient, &waddrmgr.BlockStamp{}) - }() - - waitedForShutdown := make(chan struct{}) - go func() { - w.WaitForShutdown() - close(waitedForShutdown) - }() - - // Recovery is running. - getBlockHashCalls(3) - - // endRecovery is required to exit the unmonitored goroutine. - end := w.endRecovery() - select { - case <-blockHashCalled: - case <-recoveryDone: - } - <-end - - // testWallet starts a couple of other unrelated goroutines that need to be - // killed, so we still need to close the quit channel. - w.quitMu.Lock() - close(w.quit) - w.quitMu.Unlock() - - select { - case <-waitedForShutdown: - case <-time.After(time.Second): - t.Fatal("WaitForShutdown never returned") - } - - if !strings.EqualFold(err.Error(), "recovery: forced shutdown") { - t.Fatal("wrong error") - } -} diff --git a/wallet/watchingonly_test.go b/wallet/watchingonly_test.go deleted file mode 100644 index 4af27ebb14..0000000000 --- a/wallet/watchingonly_test.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2018 The btcsuite developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package wallet - -import ( - "testing" - "time" - - "github.com/btcsuite/btcd/chaincfg" - _ "github.com/btcsuite/btcwallet/walletdb/bdb" -) - -// TestCreateWatchingOnly checks that we can construct a watching-only -// wallet. -func TestCreateWatchingOnly(t *testing.T) { - // Set up a wallet. - dir := t.TempDir() - - pubPass := []byte("hello") - - loader := NewLoader( - &chaincfg.TestNet3Params, dir, true, defaultDBTimeout, 250, - WithWalletSyncRetryInterval(10*time.Millisecond), - ) - _, err := loader.CreateNewWatchingOnlyWallet(pubPass, time.Now()) - if err != nil { - t.Fatalf("unable to create wallet: %v", err) - } -} From c5261bfce10f1f6cbed8afa89303c95d27521983 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 22 Jan 2026 05:43:39 +0800 Subject: [PATCH 293/691] golangci+gemini: ignore `deprecated_test.go` and fix tab space --- .gemini/styleguide.md | 4 ++-- .golangci.yml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index 98c22a3dce..83bf86ec2a 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -9,7 +9,7 @@ - Unit tests must always use the `require` library. Either table driven unit tests or tests using the `rapid` library are preferred. - The line length MUST NOT exceed 80 characters, this is very important. - You must count the Golang indentation (tabulator character) as 8 spaces when + You must count the Golang indentation (tabulator character) as 4 spaces when determining the line length. Use creative approaches or the wrapping rules specified below to make sure the line length isn't exceeded. - Every function must be commented with its purpose and assumptions. @@ -151,7 +151,7 @@ if amt < 546 { ### 80 character line length - Wrap columns at 80 characters. -- Tabs are 8 spaces. +- Tabs are 4 spaces. **WRONG** ```go diff --git a/.golangci.yml b/.golangci.yml index 6411961e23..b805751b2e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -117,6 +117,7 @@ linters: paths: - rpc/legacyrpc/ - wallet/deprecated.go + - wallet/deprecated_test.go rules: # Exclude gosec from running for tests so that tests with weak randomness From c8e7bc1902f7f6fb3a0fe419b26cdf42cf5afe76 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 22 Jan 2026 11:08:04 +0800 Subject: [PATCH 294/691] wallet: introduce `CreateWalletParams` This specifies different ways the wallet can be created. --- wallet/manager.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 wallet/manager.go diff --git a/wallet/manager.go b/wallet/manager.go new file mode 100644 index 0000000000..3f6d58c515 --- /dev/null +++ b/wallet/manager.go @@ -0,0 +1,84 @@ +package wallet + +import ( + "errors" + "time" + + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcwallet/waddrmgr" +) + +var ( + // ErrWalletParams is returned when the creation parameters are invalid. + ErrWalletParams = errors.New("invalid wallet params") +) + +// CreateMode determines how a new wallet is initialized. +type CreateMode uint8 + +const ( + // ModeUnknown indicates no specific creation mode. + ModeUnknown CreateMode = iota + + // ModeGenSeed indicates creating a new wallet by generating a fresh random + // seed. + ModeGenSeed + + // ModeImportSeed indicates restoring a wallet from a provided seed + // (CreateWalletParams.Seed). + ModeImportSeed + + // ModeImportExtKey indicates creating a wallet from an extended key + // (CreateWalletParams.RootKey). + ModeImportExtKey + + // ModeShell indicates creating an empty wallet shell (no root key). + // Intended for importing specific Account XPubs. + ModeShell +) + +// WatchOnlyAccount contains the information needed to import a watch-only +// account. +type WatchOnlyAccount struct { + // Scope is the key scope of the account. + Scope waddrmgr.KeyScope + + // Account is the account number. + Account uint32 + + // XPub is the extended public key for the account. + XPub *hdkeychain.ExtendedKey +} + +// CreateWalletParams holds the parameters required to initialize a new wallet. +// These are one-time inputs used during the creation process. +type CreateWalletParams struct { + // Mode determines which fields below are required. + Mode CreateMode + + // Seed is required for ModeImportSeed. Ignored for others. + Seed []byte + + // RootKey is required for ModeImportExtKey. Ignored for others. Can be XPrv + // or XPub. + RootKey *hdkeychain.ExtendedKey + + // InitialAccounts is optional for ModeShell. Reserved for future use and + // currently has no effect during wallet creation. + InitialAccounts []WatchOnlyAccount + + // WatchOnly controls whether the resulting wallet is watch-only. + // - If true with Seed/XPrv input: Derives Master XPub, then discards + // the private material. + // - If true with XPub/Shell input: No-op (already watch-only). + WatchOnly bool + + // Birthday is the wallet's birthday. + Birthday time.Time + + // PubPassphrase is the public passphrase for the wallet. + PubPassphrase []byte + + // PrivatePassphrase is the private passphrase for the wallet. + PrivatePassphrase []byte +} From 80d3115facba95dbfb651383cbd26e99a17992ce Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 22 Jan 2026 08:50:52 +0800 Subject: [PATCH 295/691] wallet: add db method `DBCreateWallet` --- wallet/db_ops.go | 43 +++++++++++++++++++++++++++++++++++++++++ wallet/db_ops_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/wallet/db_ops.go b/wallet/db_ops.go index b30660bec3..6d1c91e442 100644 --- a/wallet/db_ops.go +++ b/wallet/db_ops.go @@ -9,6 +9,7 @@ import ( "fmt" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -16,6 +17,48 @@ import ( "github.com/btcsuite/btcwallet/wtxmgr" ) +// DBCreateWallet initializes the database structure for a new wallet. +func DBCreateWallet(cfg Config, params CreateWalletParams, + rootKey *hdkeychain.ExtendedKey) error { + + err := walletdb.Update(cfg.DB, func(tx walletdb.ReadWriteTx) error { + // Create the top-level bucket for the address manager. + addrMgrNs, err := tx.CreateTopLevelBucket(waddrmgrNamespaceKey) + if err != nil { + return fmt.Errorf("create addr mgr bucket: %w", err) + } + + // Create the top-level bucket for the transaction manager. + txMgrNs, err := tx.CreateTopLevelBucket(wtxmgrNamespaceKey) + if err != nil { + return fmt.Errorf("create tx mgr bucket: %w", err) + } + + // Initialize the address manager in the database. This sets up + // the master keys and the initial account structure. + err = waddrmgr.Create( + addrMgrNs, rootKey, params.PubPassphrase, params.PrivatePassphrase, + cfg.ChainParams, nil, params.Birthday, + ) + if err != nil { + return fmt.Errorf("create addr mgr: %w", err) + } + + // Initialize the transaction manager in the database. + err = wtxmgr.Create(txMgrNs) + if err != nil { + return fmt.Errorf("create tx mgr: %w", err) + } + + return nil + }) + if err != nil { + return fmt.Errorf("update: %w", err) + } + + return nil +} + // DBGetBirthdayBlock retrieves the current birthday block from the database. // // TODO(yy): Refactor this in the `Store` implementation - we can call diff --git a/wallet/db_ops_test.go b/wallet/db_ops_test.go index 7b7e7e2bd1..80f0442bfd 100644 --- a/wallet/db_ops_test.go +++ b/wallet/db_ops_test.go @@ -16,6 +16,51 @@ import ( "github.com/stretchr/testify/require" ) +// TestDBCreateWallet verifies that the wallet database is correctly +// initialized with the address and transaction manager buckets. +func TestDBCreateWallet(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet with a fresh database. + // Note: createTestWalletWithMocks creates the top-level buckets, but + // they are empty. DBCreateWallet will populate them. + w, _ := createTestWalletWithMocks(t) + + params := CreateWalletParams{ + PubPassphrase: []byte("public"), + PrivatePassphrase: []byte("private"), + Birthday: time.Now(), + } + + // Act: Initialize the wallet database. + err := DBCreateWallet(w.cfg, params, nil) + + // Assert: Verify initialization success. + require.NoError(t, err) + + // Verify that the address manager and transaction manager can be + // opened, indicating successful initialization. + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + require.NotNil(t, addrmgrNs) + + _, err := waddrmgr.Open( + addrmgrNs, params.PubPassphrase, w.cfg.ChainParams, + ) + if err != nil { + return err + } + + txmgrNs := tx.ReadBucket(wtxmgrNamespaceKey) + require.NotNil(t, txmgrNs) + + _, err = wtxmgr.Open(txmgrNs, w.cfg.ChainParams) + + return err + }) + require.NoError(t, err) +} + // TestDBBirthdayBlock verifies that the wallet can successfully persist and // retrieve its birthday block information. func TestDBBirthdayBlock(t *testing.T) { From 733566aeb51a09ad404d573dab20f9066f2bc94c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 22 Jan 2026 09:36:02 +0800 Subject: [PATCH 296/691] wallet: add `validate` for `Config` --- wallet/wallet.go | 27 ++++++++++++++++ wallet/wallet_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/wallet/wallet.go b/wallet/wallet.go index 12c4b507d4..29d7f7fb2b 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -87,6 +87,10 @@ var ( // or not hardened). ErrInvalidAccountKey = errors.New("invalid account key") + // ErrMissingParam is returned when a required parameter is missing from + // the configuration. + ErrMissingParam = errors.New("missing config parameter") + // Namespace bucket keys. waddrmgrNamespaceKey = []byte("waddrmgr") wtxmgrNamespaceKey = []byte("wtxmgr") @@ -195,6 +199,27 @@ type Config struct { MaxCFilterItems int } +// validate checks the configuration for consistency and completeness. +func (c *Config) validate() error { + if c.DB == nil { + return fmt.Errorf("%w: DB", ErrMissingParam) + } + + if c.Chain == nil { + return fmt.Errorf("%w: Chain", ErrMissingParam) + } + + if c.ChainParams == nil { + return fmt.Errorf("%w: ChainParams", ErrMissingParam) + } + + if c.Name == "" { + return fmt.Errorf("%w: Name", ErrMissingParam) + } + + return nil +} + // locateBirthdayBlock returns a block that meets the given birthday timestamp // by a margin of +/-2 hours. This is safe to do as the timestamp is already 2 // days in the past of the actual timestamp. @@ -299,6 +324,8 @@ type Wallet struct { // NtfnServer handles the delivery of wallet-related events (e.g., new // transactions, block connections) to connected clients. + // + // TODO(yy): Deprecate. NtfnServer *NotificationServer // wg is a wait group used to track and wait for all long-running diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 4c2b264c6d..fb1304930a 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" ) var ( @@ -36,6 +37,80 @@ var ( } ) +// TestConfigValidate ensures that the Config.validate method correctly +// identifies missing required parameters. +func TestConfigValidate(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + testCases := []struct { + name string + config Config + expectedErr string + }{ + { + name: "valid config", + config: Config{ + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + }, + }, + { + name: "missing DB", + config: Config{ + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + }, + expectedErr: "DB", + }, + { + name: "missing Chain", + config: Config{ + DB: db, + ChainParams: &chainParams, + Name: "test-wallet", + }, + expectedErr: "Chain", + }, + { + name: "missing ChainParams", + config: Config{ + DB: db, + Chain: &mockChain{}, + Name: "test-wallet", + }, + expectedErr: "ChainParams", + }, + { + name: "missing Name", + config: Config{ + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + }, + expectedErr: "Name", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.validate() + if tc.expectedErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.expectedErr) + } + }) + } +} + // mockChainConn is a mock in-memory implementation of the chainConn interface // that will be used for the birthday block sanity check tests. The struct is // capable of being backed by a chain in order to reproduce real-world From c7db33f5a0e815f5af43ba81c6fdeadd986d78ea Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 22 Jan 2026 09:38:06 +0800 Subject: [PATCH 297/691] wallet: add `DBLoadWallet` --- wallet/db_ops.go | 64 +++++++++++++++++++++++++++++++++++++++++++ wallet/db_ops_test.go | 29 ++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/wallet/db_ops.go b/wallet/db_ops.go index 6d1c91e442..223b2faf3b 100644 --- a/wallet/db_ops.go +++ b/wallet/db_ops.go @@ -6,6 +6,7 @@ package wallet import ( "context" + "errors" "fmt" "github.com/btcsuite/btcd/btcutil" @@ -14,9 +15,20 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/walletdb/migration" "github.com/btcsuite/btcwallet/wtxmgr" ) +var ( + // ErrMissingAddressManager is returned when the address manager namespace + // is missing from the database. + ErrMissingAddressManager = errors.New("missing address manager namespace") + + // ErrMissingTxManager is returned when the transaction manager namespace is + // missing from the database. + ErrMissingTxManager = errors.New("missing transaction manager namespace") +) + // DBCreateWallet initializes the database structure for a new wallet. func DBCreateWallet(cfg Config, params CreateWalletParams, rootKey *hdkeychain.ExtendedKey) error { @@ -59,6 +71,58 @@ func DBCreateWallet(cfg Config, params CreateWalletParams, return nil } +// DBLoadWallet initializes the database and returns the address and transaction +// managers. +func DBLoadWallet(cfg Config) (*waddrmgr.Manager, *wtxmgr.Store, error) { + var ( + addrMgr *waddrmgr.Manager + txMgr *wtxmgr.Store + ) + + // Before attempting to open the wallet, we'll check if there are any + // database upgrades for us to proceed. We'll also create our references + // to the address and transaction managers, as they are backed by the + // database. + err := walletdb.Update(cfg.DB, func(tx walletdb.ReadWriteTx) error { + addrMgrBucket := tx.ReadWriteBucket(waddrmgrNamespaceKey) + if addrMgrBucket == nil { + return ErrMissingAddressManager + } + + txMgrBucket := tx.ReadWriteBucket(wtxmgrNamespaceKey) + if txMgrBucket == nil { + return ErrMissingTxManager + } + + addrMgrUpgrader := waddrmgr.NewMigrationManager(addrMgrBucket) + txMgrUpgrader := wtxmgr.NewMigrationManager(txMgrBucket) + + err := migration.Upgrade(txMgrUpgrader, addrMgrUpgrader) + if err != nil { + return fmt.Errorf("failed to upgrade database: %w", err) + } + + addrMgr, err = waddrmgr.Open( + addrMgrBucket, cfg.PubPassphrase, cfg.ChainParams, + ) + if err != nil { + return fmt.Errorf("failed to open address manager: %w", err) + } + + txMgr, err = wtxmgr.Open(txMgrBucket, cfg.ChainParams) + if err != nil { + return fmt.Errorf("failed to open transaction manager: %w", err) + } + + return nil + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to load wallet: %w", err) + } + + return addrMgr, txMgr, nil +} + // DBGetBirthdayBlock retrieves the current birthday block from the database. // // TODO(yy): Refactor this in the `Store` implementation - we can call diff --git a/wallet/db_ops_test.go b/wallet/db_ops_test.go index 80f0442bfd..6672e18e0c 100644 --- a/wallet/db_ops_test.go +++ b/wallet/db_ops_test.go @@ -61,6 +61,35 @@ func TestDBCreateWallet(t *testing.T) { require.NoError(t, err) } +// TestDBLoadWallet verifies that the wallet database can be successfully loaded +// and the address and transaction managers retrieved. +func TestDBLoadWallet(t *testing.T) { + t.Parallel() + + // Arrange: Create a test wallet and initialize it. + w, _ := createTestWalletWithMocks(t) + + pubPass := []byte("public") + w.cfg.PubPassphrase = pubPass + + params := CreateWalletParams{ + PubPassphrase: pubPass, + PrivatePassphrase: []byte("private"), + Birthday: time.Now(), + } + + err := DBCreateWallet(w.cfg, params, nil) + require.NoError(t, err) + + // Act: Load the wallet database. + addrMgr, txMgr, err := DBLoadWallet(w.cfg) + + // Assert: Verify that both managers were loaded successfully. + require.NoError(t, err) + require.NotNil(t, addrMgr) + require.NotNil(t, txMgr) +} + // TestDBBirthdayBlock verifies that the wallet can successfully persist and // retrieve its birthday block information. func TestDBBirthdayBlock(t *testing.T) { From 358bb0edb4997f26c5e592bbf46f0d6a68fad3c5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 22 Jan 2026 11:08:52 +0800 Subject: [PATCH 298/691] wallet: introduce wallet `Manager` This manager replaces the old wallet loader to handle the creation and loading the wallets. --- wallet/manager.go | 203 ++++++++++++++++++ wallet/manager_test.go | 455 +++++++++++++++++++++++++++++++++++++++++ wallet/wallet.go | 4 + 3 files changed, 662 insertions(+) create mode 100644 wallet/manager_test.go diff --git a/wallet/manager.go b/wallet/manager.go index 3f6d58c515..ef7fe5ca07 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -1,7 +1,11 @@ package wallet import ( + "context" "errors" + "fmt" + "sort" + "sync" "time" "github.com/btcsuite/btcd/btcutil/hdkeychain" @@ -82,3 +86,202 @@ type CreateWalletParams struct { // PrivatePassphrase is the private passphrase for the wallet. PrivatePassphrase []byte } + +// Manager is a high-level manager that handles the lifecycle of multiple +// wallets. It acts as a factory for creating and loading wallets, and can +// optionally track the active wallets. +// +// The Manager enables a one-to-many relationship, allowing a single application +// to manage multiple distinct wallets (e.g., for different coins or different +// accounts) simultaneously. +type Manager struct { + sync.RWMutex + + // wallets holds the active wallets keyed by their unique name. + wallets map[string]*Wallet +} + +// NewManager creates a new Wallet Manager. +func NewManager() *Manager { + return &Manager{ + wallets: make(map[string]*Wallet), + } +} + +// String returns a summary of the active wallets managed by the Manager. +func (m *Manager) String() string { + m.RLock() + defer m.RUnlock() + + names := make([]string, 0, len(m.wallets)) + for name := range m.wallets { + names = append(names, name) + } + + sort.Strings(names) + + return fmt.Sprintf("active_wallets=%v", names) +} + +// Create creates a new wallet based on the provided configuration and +// initialization parameters. It initializes the database structure and then +// loads the wallet. +func (m *Manager) Create(cfg Config, + params CreateWalletParams) (*Wallet, error) { + + err := cfg.validate() + if err != nil { + return nil, err + } + + rootKey, err := m.deriveRootKey(cfg, params) + if err != nil { + return nil, err + } + + // If the wallet is NOT watch-only, we require a private root key to be able + // to sign transactions and derive child private keys. + if !params.WatchOnly && rootKey != nil && !rootKey.IsPrivate() { + return nil, fmt.Errorf("%w: private key required for "+ + "non-watch-only wallet", ErrWalletParams) + } + + // Create the underlying database structure. + err = DBCreateWallet(cfg, params, rootKey) + if err != nil { + return nil, err + } + + // Load the newly created wallet. + w, err := m.Load(cfg) + if err != nil { + return nil, err + } + + return w, nil +} + +// Load loads an existing wallet from the provided configuration. It opens the +// database, initializes the wallet structure, and registers it with the manager +// for tracking. +func (m *Manager) Load(cfg Config) (*Wallet, error) { + err := cfg.validate() + if err != nil { + return nil, err + } + + // Check if the wallet is already loaded. + m.RLock() + existingW, ok := m.wallets[cfg.Name] + m.RUnlock() + + if ok { + return existingW, nil + } + + addrMgr, txMgr, err := DBLoadWallet(cfg) + if err != nil { + return nil, err + } + + // Apply the safe default for auto-lock duration if not specified. + if cfg.AutoLockDuration == 0 { + cfg.AutoLockDuration = defaultLockDuration + } + + // Initialize the auto-lock timer in a stopped state. We perform a + // non-blocking drain on the channel to ensure it's empty and won't fire + // immediately. + lockTimer := time.NewTimer(0) + if !lockTimer.Stop() { + <-lockTimer.C + } + + lifetimeCtx, cancel := context.WithCancel(context.Background()) + + w := &Wallet{ + cfg: cfg, + addrStore: addrMgr, + txStore: txMgr, + requestChan: make(chan any), + lifetimeCtx: lifetimeCtx, + cancel: cancel, + lockTimer: lockTimer, + } + + w.sync = newSyncer(cfg, w.addrStore, w.txStore, w) + w.state = newWalletState(w.sync) + + // Register the wallet. + m.Lock() + m.wallets[cfg.Name] = w + m.Unlock() + + return w, nil +} + +// deriveRootKey resolves the master extended key based on the creation mode. +func (m *Manager) deriveRootKey(cfg Config, + params CreateWalletParams) (*hdkeychain.ExtendedKey, error) { + + switch params.Mode { + case ModeGenSeed: + return m.genRootKey(cfg) + + case ModeImportSeed: + return m.deriveFromSeed(cfg, params.Seed) + + case ModeImportExtKey: + // Ensure an extended key was provided. + if params.RootKey == nil { + return nil, fmt.Errorf("%w: root key is required", + ErrWalletParams) + } + + // Use the provided extended key (can be XPrv or XPub). + return params.RootKey, nil + + case ModeShell: + // In shell mode, no root key is persisted. Accounts will be + // imported individually. + return nil, nil //nolint:nilnil + + case ModeUnknown: + fallthrough + + default: + return nil, fmt.Errorf("%w: unknown mode %v", ErrWalletParams, + params.Mode) + } +} + +// genRootKey generates a fresh random seed and derives the master extended +// private key from it. +func (m *Manager) genRootKey(cfg Config) (*hdkeychain.ExtendedKey, error) { + // Generate a fresh random seed using the recommended length. + seed, err := hdkeychain.GenerateSeed(hdkeychain.RecommendedSeedLen) + if err != nil { + return nil, fmt.Errorf("failed to generate seed: %w", err) + } + + return m.deriveFromSeed(cfg, seed) +} + +// deriveFromSeed derives the master extended private key from the provided +// seed. +func (m *Manager) deriveFromSeed(cfg Config, seed []byte) ( + *hdkeychain.ExtendedKey, error) { + + // Ensure a seed was provided for restoration. + if len(seed) == 0 { + return nil, fmt.Errorf("%w: seed is required", ErrWalletParams) + } + + // Derive the master extended private key from the provided seed. + key, err := hdkeychain.NewMaster(seed, cfg.ChainParams) + if err != nil { + return nil, fmt.Errorf("failed to derive master key: %w", err) + } + + return key, nil +} diff --git a/wallet/manager_test.go b/wallet/manager_test.go new file mode 100644 index 0000000000..1ea7352757 --- /dev/null +++ b/wallet/manager_test.go @@ -0,0 +1,455 @@ +package wallet + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/stretchr/testify/require" +) + +// TestManagerCreateSuccess verifies that a wallet can be successfully created +// in various modes. It checks that the Manager correctly initializes the +// wallet structure and registers it for tracking. +func TestManagerCreateSuccess(t *testing.T) { + t.Parallel() + + // Pre-calculate common setup values to be used in multiple test cases. + // This ensures we have valid cryptographic material ready for import + // scenarios. + seed, err := hdkeychain.GenerateSeed(hdkeychain.RecommendedSeedLen) + require.NoError(t, err) + + rootKey, err := hdkeychain.NewMaster(seed, &chainParams) + require.NoError(t, err) + + // Arrange: Define test cases for different creation modes. + + tests := []struct { + name string + params CreateWalletParams + }{ + + { + name: "ModeGenSeed", + params: CreateWalletParams{ + Mode: ModeGenSeed, + PubPassphrase: []byte("public"), + PrivatePassphrase: []byte("private"), + Birthday: time.Now(), + }, + }, + { + name: "ModeImportSeed", + params: CreateWalletParams{ + Mode: ModeImportSeed, + Seed: seed, + PubPassphrase: []byte("public"), + PrivatePassphrase: []byte("private"), + Birthday: time.Now(), + }, + }, + { + name: "ModeImportExtKey", + params: CreateWalletParams{ + Mode: ModeImportExtKey, + RootKey: rootKey, + PubPassphrase: []byte("public"), + PrivatePassphrase: []byte("private"), + Birthday: time.Now(), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Create a fresh test database for this run. We use setupTestDB + // which ensures we have a clean slate (empty buckets) to verify + // that Create correctly initializes the schema. + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + m := NewManager() + cfg := Config{ + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + PubPassphrase: []byte("public"), + } + + // Attempt to create the wallet with the specified parameters. + w, err := m.Create(cfg, tc.params) + + // Verify that the wallet was created successfully and returned + // without error. + require.NoError(t, err) + require.NotNil(t, w) + + // Verify internal state: Ensure the manager is tracking the + // newly created wallet in its internal map, keyed by the + // configuration name. + m.RLock() + loadedW, ok := m.wallets["test-wallet"] + m.RUnlock() + require.True(t, ok) + require.Equal(t, w, loadedW) + }) + } +} + +// TestManagerCreateError verifies that wallet creation fails when invalid +// parameters are provided. This ensures that the Manager correctly validates +// inputs before attempting to modify the database. +func TestManagerCreateError(t *testing.T) { + t.Parallel() + + // Pre-calculate cryptographic material to construct specific test + // scenarios. + seed, err := hdkeychain.GenerateSeed(hdkeychain.RecommendedSeedLen) + require.NoError(t, err) + + rootKey, err := hdkeychain.NewMaster(seed, &chainParams) + require.NoError(t, err) + + pubKey, err := rootKey.Neuter() + require.NoError(t, err) + + tests := []struct { + name string + params CreateWalletParams + expectedErr string + }{ + { + name: "ModeImportSeed missing seed", + params: CreateWalletParams{ + Mode: ModeImportSeed, + Seed: nil, + }, + expectedErr: "seed is required", + }, + { + name: "ModeImportExtKey missing key", + params: CreateWalletParams{ + Mode: ModeImportExtKey, + RootKey: nil, + }, + expectedErr: "root key is required", + }, + { + name: "Public Key for Non-WatchOnly", + params: CreateWalletParams{ + Mode: ModeImportExtKey, + RootKey: pubKey, + WatchOnly: false, + }, + expectedErr: "private key required", + }, + { + name: "Unknown Mode", + params: CreateWalletParams{ + Mode: ModeUnknown, + }, + expectedErr: "unknown mode", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + m := NewManager() + cfg := Config{ + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + } + + // Attempt to create the wallet. We expect this to fail due to + // the invalid parameters configured in the test case. + _, err := m.Create(cfg, tc.params) + + // Verify that the error matches our expectation. + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedErr) + }) + } +} + +// TestManagerCreate_InvalidConfig verifies that the Create method performs +// configuration validation before proceeding with any operations. +func TestManagerCreate_InvalidConfig(t *testing.T) { + t.Parallel() + + m := NewManager() + + // Call Create with an empty Config struct. This should fail because + // required fields like DB and ChainParams are missing. + w, err := m.Create(Config{}, CreateWalletParams{}) + + require.ErrorIs(t, err, ErrMissingParam) + require.ErrorContains(t, err, "DB") + require.Nil(t, w) +} + +// TestManagerLoadSuccess verifies that an existing wallet can be successfully +// loaded from the database. This tests the persistence and restoration flow. +func TestManagerLoadSuccess(t *testing.T) { + t.Parallel() + + // Initialize a database and create a wallet to serve as our existing + // state. + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + m := NewManager() + cfg := Config{ + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + PubPassphrase: []byte("public"), + } + params := CreateWalletParams{ + Mode: ModeGenSeed, + PubPassphrase: []byte("public"), + PrivatePassphrase: []byte("private"), + Birthday: time.Now(), + } + + wCreated, err := m.Create(cfg, params) + require.NoError(t, err) + require.NotNil(t, wCreated) + + // Create a new Manager instance to simulate a fresh start (e.g., daemon + // restart) and attempt to load the wallet from the existing database. + m2 := NewManager() + w, err := m2.Load(cfg) + + // Verify that the load operation succeeded and returned a valid wallet. + require.NoError(t, err) + require.NotNil(t, w) + + // Ensure the loaded wallet is correctly registered in the new manager. + m2.RLock() + loadedW, ok := m2.wallets["test-wallet"] + m2.RUnlock() + require.True(t, ok) + require.Same(t, w, loadedW) +} + +// TestManagerLoad_ExistingWallet verifies that if Load is called for a wallet +// that is already managed in memory, the Manager detects this. +func TestManagerLoad_ExistingWallet(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + m := NewManager() + cfg := Config{ + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + PubPassphrase: []byte("public"), + } + params := CreateWalletParams{ + Mode: ModeGenSeed, + PubPassphrase: []byte("public"), + PrivatePassphrase: []byte("private"), + Birthday: time.Now(), + } + + wCreated, err := m.Create(cfg, params) + require.NoError(t, err) + + // Attempt to load the same wallet again using the same manager instance. + // Since it's already loaded in memory, the manager should return the + // existing instance rather than reloading from disk. + wLoaded, err := m.Load(cfg) + + // Verify that we got the same wallet instance back. + require.NoError(t, err) + require.Same(t, wCreated, wLoaded) +} + +// TestManagerLoadError verifies that Load properly handles invalid +// configurations and corrupted or uninitialized databases. +func TestManagerLoadError(t *testing.T) { + t.Parallel() + + t.Run("Invalid Config", func(t *testing.T) { + t.Parallel() + + m := NewManager() + + // Attempt to load with an empty config. This should fail validation. + w, err := m.Load(Config{}) + require.ErrorContains(t, err, "missing config parameter") + require.Nil(t, w) + }) + + t.Run("Uninitialized DB", func(t *testing.T) { + t.Parallel() + + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + m := NewManager() + cfg := Config{ + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test", + } + + // Attempt to load from a database that has valid buckets but no + // wallet data (waddrmgr is not initialized). This should fail + // at the database loading step. + w, err := m.Load(cfg) + + // We expect an error from waddrmgr.Open indicating the address + // manager namespace is missing or invalid. + require.Error(t, err) + require.Nil(t, w) + }) +} + +// TestManagerString verifies that the String representation of the Manager +// correctly lists the tracked wallets in alphabetical order. +func TestManagerString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*Manager) + expected string + }{ + { + name: "empty", + setup: func(m *Manager) {}, + expected: "active_wallets=[]", + }, + { + name: "multiple sorted", + setup: func(m *Manager) { + m.wallets["wallet-b"] = &Wallet{} + m.wallets["wallet-a"] = &Wallet{} + }, + expected: "active_wallets=[wallet-a wallet-b]", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + m := NewManager() + tc.setup(m) + require.Equal(t, tc.expected, m.String()) + }) + } +} + +// TestManager_deriveFromSeed verifies the internal helper method +// deriveFromSeed, checking that it correctly derives a master private key +// from a seed and validates inputs. +func TestManager_deriveFromSeed(t *testing.T) { + t.Parallel() + + m := NewManager() + cfg := Config{ChainParams: &chainParams} + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + seed, err := hdkeychain.GenerateSeed(hdkeychain.RecommendedSeedLen) + require.NoError(t, err) + + key, err := m.deriveFromSeed(cfg, seed) + + // Verify we got a valid private extended key. + require.NoError(t, err) + require.NotNil(t, key) + require.True(t, key.IsPrivate()) + }) + + t.Run("Empty Seed", func(t *testing.T) { + t.Parallel() + + key, err := m.deriveFromSeed(cfg, nil) + require.ErrorIs(t, err, ErrWalletParams) + require.ErrorContains(t, err, "seed is required") + require.Nil(t, key) + }) + + t.Run("Invalid Seed Length", func(t *testing.T) { + t.Parallel() + + // Providing a seed that is too short for hdkeychain.NewMaster. + key, err := m.deriveFromSeed(cfg, []byte{0x01}) + require.ErrorContains(t, err, "failed to derive master key") + require.Nil(t, key) + }) +} + +// TestManager_genRootKey verifies the internal helper method genRootKey, +// ensuring it generates a random seed and derives a valid master key. +func TestManager_genRootKey(t *testing.T) { + t.Parallel() + + m := NewManager() + cfg := Config{ChainParams: &chainParams} + + key, err := m.genRootKey(cfg) + + // Verify we got a valid private extended key. + require.NoError(t, err) + require.NotNil(t, key) + require.True(t, key.IsPrivate()) +} + +// TestManager_deriveRootKey verifies the high-level key derivation logic, +// checking that it correctly dispatches to the appropriate helper based on +// the creation mode. +func TestManager_deriveRootKey(t *testing.T) { + t.Parallel() + + m := NewManager() + cfg := Config{ChainParams: &chainParams} + + // 1. ModeShell: Should return nil/nil (no root key for shell). + t.Run("ModeShell", func(t *testing.T) { + t.Parallel() + + key, err := m.deriveRootKey(cfg, CreateWalletParams{Mode: ModeShell}) + require.NoError(t, err) + require.Nil(t, key) + }) + + t.Run("ModeUnknown", func(t *testing.T) { + t.Parallel() + + key, err := m.deriveRootKey(cfg, CreateWalletParams{Mode: ModeUnknown}) + require.ErrorIs(t, err, ErrWalletParams) + require.ErrorContains(t, err, "unknown mode") + require.Nil(t, key) + }) + + // 3. ModeGenSeed: Should return a newly generated private key. + t.Run("ModeGenSeed", func(t *testing.T) { + t.Parallel() + + key, err := m.deriveRootKey(cfg, CreateWalletParams{Mode: ModeGenSeed}) + require.NoError(t, err) + require.NotNil(t, key) + require.True(t, key.IsPrivate()) + }) +} diff --git a/wallet/wallet.go b/wallet/wallet.go index 29d7f7fb2b..37aa2d0e14 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -52,6 +52,10 @@ const ( // birthday timestamp and our birthday block's timestamp when searching // for a better birthday block candidate (if possible). birthdayBlockDelta = 2 * time.Hour + + // defaultLockDuration is the default duration for automatic wallet + // locking. + defaultLockDuration = 10 * time.Minute ) var ( From 7b8d0ce6ac3da03cecd3ceeec6d7e37afb8ac663 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 23 Jan 2026 11:45:43 +0800 Subject: [PATCH 299/691] wallet: build retry loop when syncer fails --- wallet/controller.go | 93 ++++++++++++++++++++++++++-- wallet/controller_test.go | 127 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 5 deletions(-) diff --git a/wallet/controller.go b/wallet/controller.go index 4a66de81e0..947ec4a648 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -11,6 +11,21 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" ) +const ( + // initialBackoff is the initial delay between synchronization retry + // attempts. + initialBackoff = 1 * time.Second + + // maxBackoff is the maximum delay allowed between synchronization retry + // attempts. + maxBackoff = 5 * time.Minute + + // stableRunTime is the minimum amount of time the syncer must run + // without error to be considered "stable", at which point the retry + // backoff is reset to initialBackoff. + stableRunTime = 10 * time.Minute +) + var ( // ErrWalletNotStopped is returned when an attempt is made to start the // wallet when it is not in the stopped state. @@ -171,11 +186,7 @@ func (w *Wallet) Start(startCtx context.Context) error { go func() { defer w.wg.Done() - // TODO(yy): build a retry loop. - err := w.sync.run(w.lifetimeCtx) - if err != nil { - log.Errorf("Chain sync loop exited with error: %v", err) - } + w.runSyncLoop() }() // 5. Mark the wallet as fully started. @@ -187,6 +198,78 @@ func (w *Wallet) Start(startCtx context.Context) error { return nil } +// runSyncLoop executes the main chain synchronization loop with automatic +// retries and exponential backoff. It ensures the wallet attempts to stay +// synced even if the backend connection is flaky. +func (w *Wallet) runSyncLoop() { + backoff := initialBackoff + + for { + startTime := time.Now() + + // Block until the syncer exits. + err := w.sync.run(w.lifetimeCtx) + + // If the wallet is shutting down, we can exit immediately. + if w.lifetimeCtx.Err() != nil { + log.Info("Chain sync loop exiting due to wallet shutdown") + + return + } + + // If the syncer exited cleanly (nil error), it generally means it was + // requested to stop, so we shouldn't restart. + if err == nil { + log.Info("Chain sync loop exited normally") + return + } + + log.Errorf("Chain sync loop exited with error: %v", err) + + var shouldContinue bool + + backoff, shouldContinue = w.waitForBackoff( + startTime, backoff, time.After, + ) + if !shouldContinue { + return + } + } +} + +// waitForBackoff handles the delay between synchronization retry attempts. It +// resets the backoff if the previous run was stable, waits for the calculated +// delay, and then returns the updated backoff duration for the next attempt. +// It returns false if the wallet is shutting down. +func (w *Wallet) waitForBackoff(startTime time.Time, backoff time.Duration, + timerFn func(time.Duration) <-chan time.Time) (time.Duration, bool) { + + // If the syncer ran for a significant amount of time, we consider it a + // "stable" run and reset the backoff. + if time.Since(startTime) > stableRunTime { + backoff = initialBackoff + } + + log.Infof("Restarting sync loop in %v...", backoff) + + // Wait for the backoff period or a shutdown signal. + select { + case <-timerFn(backoff): + // Increase backoff for the next attempt, capping it. + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + + return backoff, true + + case <-w.lifetimeCtx.Done(): + log.Debug("Backoff interrupted by wallet shutdown") + + return 0, false + } +} + // performRuntimeSetup executes the synchronous initialization tasks required // before the wallet's main loops can start. This includes sanity checking the // birthday block, loading accounts into memory, and cleaning up expired locks. diff --git a/wallet/controller_test.go b/wallet/controller_test.go index efbb83431d..b2aa393057 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -1646,3 +1646,130 @@ func TestControllerLock_StateError(t *testing.T) { // Assert: Verify error. require.ErrorIs(t, err, ErrStateForbidden) } + +// TestWaitForBackoff_StableRun verifies that the backoff is reset to the +// initial value if the syncer has been running for a stable amount of time. +func TestWaitForBackoff_StableRun(t *testing.T) { + t.Parallel() + + // Arrange: Create a wallet with a canceled context to avoid waiting. + w := &Wallet{ + lifetimeCtx: context.Background(), + } + + // Mock a start time that exceeds the stable run time. + startTime := time.Now().Add(-stableRunTime - time.Minute) + currentBackoff := maxBackoff + + // Mock the timer function to fire immediately. + timerFn := func(d time.Duration) <-chan time.Time { + // Verify that the backoff was reset to initial before waiting. + require.Equal(t, initialBackoff, d) + + c := make(chan time.Time, 1) + c <- time.Now() + + return c + } + + // Act: Wait for backoff. + nextBackoff, ok := w.waitForBackoff(startTime, currentBackoff, timerFn) + + // Assert: Verify that the operation continued and backoff doubled. + require.True(t, ok) + require.Equal(t, initialBackoff*2, nextBackoff) +} + +// TestWaitForBackoff_UnstableRun verifies that the backoff duration doubles +// when the syncer fails quickly (unstable run). +func TestWaitForBackoff_UnstableRun(t *testing.T) { + t.Parallel() + + // Arrange: Create a wallet. + w := &Wallet{ + lifetimeCtx: context.Background(), + } + + // Mock a start time that is recent (unstable). + startTime := time.Now() + currentBackoff := time.Second + + // Mock the timer function. + timerFn := func(d time.Duration) <-chan time.Time { + // Verify that the backoff was NOT reset. + require.Equal(t, currentBackoff, d) + + c := make(chan time.Time, 1) + c <- time.Now() + + return c + } + + // Act: Wait for backoff. + nextBackoff, ok := w.waitForBackoff(startTime, currentBackoff, timerFn) + + // Assert: Verify that the operation continued and backoff doubled. + require.True(t, ok) + require.Equal(t, currentBackoff*2, nextBackoff) +} + +// TestWaitForBackoff_MaxBackoffCap verifies that the backoff duration is +// capped at maxBackoff. +func TestWaitForBackoff_MaxBackoffCap(t *testing.T) { + t.Parallel() + + // Arrange: Create a wallet. + w := &Wallet{ + lifetimeCtx: context.Background(), + } + + startTime := time.Now() + // Current backoff is already high enough that doubling it would exceed + // maxBackoff. + currentBackoff := maxBackoff + + timerFn := func(d time.Duration) <-chan time.Time { + require.Equal(t, currentBackoff, d) + + c := make(chan time.Time, 1) + c <- time.Now() + + return c + } + + // Act: Wait for backoff. + nextBackoff, ok := w.waitForBackoff(startTime, currentBackoff, timerFn) + + // Assert: Verify that the backoff is capped. + require.True(t, ok) + require.Equal(t, maxBackoff, nextBackoff) +} + +// TestWaitForBackoff_Shutdown verifies that waitForBackoff returns early if +// the wallet is shutting down. +func TestWaitForBackoff_Shutdown(t *testing.T) { + t.Parallel() + + // Arrange: Create a wallet with a canceled context. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + w := &Wallet{ + lifetimeCtx: ctx, + } + + startTime := time.Now() + currentBackoff := time.Second + + // Mock a timer that never fires, ensuring we select on the context. + timerFn := func(d time.Duration) <-chan time.Time { + return make(chan time.Time) + } + + // Act: Wait for backoff. + nextBackoff, ok := w.waitForBackoff(startTime, currentBackoff, timerFn) + + // Assert: Verify that the operation was aborted. + require.False(t, ok) + require.Equal(t, time.Duration(0), nextBackoff) +} From 4609001767144b601b8c02da1c5380cdd420a15f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 29 Jan 2026 22:55:35 +0800 Subject: [PATCH 300/691] wallet: finalize `ModeShell` --- wallet/account_manager.go | 26 +++++++++++++----- wallet/manager.go | 41 ++++++++++++++++++++++++++--- wallet/manager_test.go | 55 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/wallet/account_manager.go b/wallet/account_manager.go index 7f53f58756..64796d1e2b 100644 --- a/wallet/account_manager.go +++ b/wallet/account_manager.go @@ -608,12 +608,7 @@ func (w *Wallet) balanceForUTXO(addrmgrNs walletdb.ReadBucket, // // The time complexity of this method is dominated by the database lookup to // ensure the account name is unique within the scope. -// -// TODO(yy): we will move the db operation to a dedicated method, so we can -// ignore cyclop for now. -// -//nolint:cyclop -func (w *Wallet) ImportAccount(_ context.Context, +func (w *Wallet) ImportAccount(ctx context.Context, name string, accountKey *hdkeychain.ExtendedKey, masterKeyFingerprint uint32, addrType waddrmgr.AddressType, dryRun bool) (*waddrmgr.AccountProperties, error) { @@ -623,9 +618,26 @@ func (w *Wallet) ImportAccount(_ context.Context, return nil, err } + return w.importAccountInternal( + ctx, name, accountKey, masterKeyFingerprint, addrType, dryRun, + ) +} + +// importAccountInternal is the internal implementation of ImportAccount, +// allowing callers (like Manager.Create) to bypass the started check. +// +// TODO(yy): we will move the db operation to a dedicated method, so we can +// ignore cyclop for now. +// +//nolint:cyclop +func (w *Wallet) importAccountInternal(_ context.Context, + name string, accountKey *hdkeychain.ExtendedKey, + masterKeyFingerprint uint32, addrType waddrmgr.AddressType, + dryRun bool) (*waddrmgr.AccountProperties, error) { + // Ensure we have a valid account public key. We require an account-level // key (depth 3) to properly manage the derivation path. - err = validateExtendedPubKey(accountKey, true, w.cfg.ChainParams) + err := validateExtendedPubKey(accountKey, true, w.cfg.ChainParams) if err != nil { return nil, err } diff --git a/wallet/manager.go b/wallet/manager.go index ef7fe5ca07..96b61e3182 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -47,11 +47,17 @@ type WatchOnlyAccount struct { // Scope is the key scope of the account. Scope waddrmgr.KeyScope - // Account is the account number. - Account uint32 - // XPub is the extended public key for the account. XPub *hdkeychain.ExtendedKey + + // MasterKeyFingerprint is the fingerprint of the master key. + MasterKeyFingerprint uint32 + + // Name is the name of the account. + Name string + + // AddrType is the address type of the account. + AddrType waddrmgr.AddressType } // CreateWalletParams holds the parameters required to initialize a new wallet. @@ -158,9 +164,38 @@ func (m *Manager) Create(cfg Config, return nil, err } + // If we are in shell mode and have initial accounts, we import them now. + if params.Mode == ModeShell && len(params.InitialAccounts) > 0 { + err = w.importInitialAccounts( + context.Background(), params.InitialAccounts, + ) + if err != nil { + return nil, err + } + } + return w, nil } +// importInitialAccounts imports a list of watch-only accounts into the wallet. +// This is typically used during wallet initialization in shell mode. +func (w *Wallet) importInitialAccounts(ctx context.Context, + accounts []WatchOnlyAccount) error { + + for _, account := range accounts { + _, err := w.importAccountInternal( + ctx, account.Name, account.XPub, account.MasterKeyFingerprint, + account.AddrType, false, + ) + if err != nil { + return fmt.Errorf("failed to import account %s: %w", account.Name, + err) + } + } + + return nil +} + // Load loads an existing wallet from the provided configuration. It opens the // database, initializes the wallet structure, and registers it with the manager // for tracking. diff --git a/wallet/manager_test.go b/wallet/manager_test.go index 1ea7352757..8a684b440b 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -5,6 +5,8 @@ import ( "time" "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/walletdb" "github.com/stretchr/testify/require" ) @@ -23,8 +25,18 @@ func TestManagerCreateSuccess(t *testing.T) { rootKey, err := hdkeychain.NewMaster(seed, &chainParams) require.NoError(t, err) - // Arrange: Define test cases for different creation modes. + // Create an account XPub for ModeShell testing. + // Derive account key: m/44'/0'/0' + acctKey, err := rootKey.Derive(hdkeychain.HardenedKeyStart + 44) + require.NoError(t, err) + acctKey, err = acctKey.Derive(hdkeychain.HardenedKeyStart + 0) + require.NoError(t, err) + acctKey, err = acctKey.Derive(hdkeychain.HardenedKeyStart + 0) + require.NoError(t, err) + acctXPub, err := acctKey.Neuter() + require.NoError(t, err) + // Arrange: Define test cases for different creation modes. tests := []struct { name string params CreateWalletParams @@ -59,6 +71,22 @@ func TestManagerCreateSuccess(t *testing.T) { Birthday: time.Now(), }, }, + { + name: "ModeShell", + params: CreateWalletParams{ + Mode: ModeShell, + InitialAccounts: []WatchOnlyAccount{{ + Scope: waddrmgr.KeyScopeBIP0049Plus, + XPub: acctXPub, + MasterKeyFingerprint: 0, + Name: "test-shell-account", + AddrType: waddrmgr.NestedWitnessPubKey, + }}, + WatchOnly: true, + PubPassphrase: []byte("public"), + Birthday: time.Now(), + }, + }, } for _, tc := range tests { @@ -95,7 +123,30 @@ func TestManagerCreateSuccess(t *testing.T) { loadedW, ok := m.wallets["test-wallet"] m.RUnlock() require.True(t, ok) - require.Equal(t, w, loadedW) + require.Same(t, w, loadedW) + + // If ModeShell, verify account was imported. + if tc.params.Mode == ModeShell { + // We can't use w.GetAccount here because the wallet is not + // started. We'll verify directly against the address manager. + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + scopeMgr, err := w.addrStore.FetchScopedKeyManager( + tc.params.InitialAccounts[0].Scope, + ) + if err != nil { + return err + } + + _, err = scopeMgr.LookupAccount( + ns, tc.params.InitialAccounts[0].Name, + ) + + return err + }) + require.NoError(t, err) + } }) } } From 599039b7f7e25fe25cd6516c677ac071b4079b7f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 29 Jan 2026 23:05:31 +0800 Subject: [PATCH 301/691] wallet: add validation for `CreateWalletParams` --- wallet/manager.go | 78 +++++++++++++++++++++++++++++++++++- wallet/manager_test.go | 90 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 2 deletions(-) diff --git a/wallet/manager.go b/wallet/manager.go index 96b61e3182..07cbb1a276 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -93,6 +93,75 @@ type CreateWalletParams struct { PrivatePassphrase []byte } +// validate ensures that the parameters are consistent with the chosen creation +// mode. +// +// We skip cyclop because this method performs exhaustive validation of +// mutually exclusive fields across all creation modes. +// +//nolint:cyclop +func (p *CreateWalletParams) validate() error { + if p.Mode == ModeUnknown { + return fmt.Errorf("%w: unknown mode", ErrWalletParams) + } + + // InitialAccounts should only be set for ModeShell. + if p.Mode != ModeShell && len(p.InitialAccounts) > 0 { + return fmt.Errorf("%w: initial accounts should only be set "+ + "for ModeShell", ErrWalletParams) + } + + if p.Mode == ModeGenSeed { + if len(p.Seed) != 0 { + return fmt.Errorf("%w: seed should not be set for "+ + "ModeGenSeed", ErrWalletParams) + } + + if p.RootKey != nil { + return fmt.Errorf("%w: root key should not be set for "+ + "ModeGenSeed", ErrWalletParams) + } + } + + if p.Mode == ModeImportSeed { + if len(p.Seed) == 0 { + return fmt.Errorf("%w: seed is required for "+ + "ModeImportSeed", ErrWalletParams) + } + + if p.RootKey != nil { + return fmt.Errorf("%w: root key should not be set for "+ + "ModeImportSeed", ErrWalletParams) + } + } + + if p.Mode == ModeImportExtKey { + if p.RootKey == nil { + return fmt.Errorf("%w: root key is required for "+ + "ModeImportExtKey", ErrWalletParams) + } + + if len(p.Seed) != 0 { + return fmt.Errorf("%w: seed should not be set for "+ + "ModeImportExtKey", ErrWalletParams) + } + } + + if p.Mode == ModeShell { + if len(p.Seed) != 0 { + return fmt.Errorf("%w: seed should not be set for "+ + "ModeShell", ErrWalletParams) + } + + if p.RootKey != nil { + return fmt.Errorf("%w: root key should not be set for "+ + "ModeShell", ErrWalletParams) + } + } + + return nil +} + // Manager is a high-level manager that handles the lifecycle of multiple // wallets. It acts as a factory for creating and loading wallets, and can // optionally track the active wallets. @@ -132,14 +201,19 @@ func (m *Manager) String() string { // Create creates a new wallet based on the provided configuration and // initialization parameters. It initializes the database structure and then // loads the wallet. -func (m *Manager) Create(cfg Config, - params CreateWalletParams) (*Wallet, error) { +func (m *Manager) Create(cfg Config, params CreateWalletParams) ( + *Wallet, error) { err := cfg.validate() if err != nil { return nil, err } + err = params.validate() + if err != nil { + return nil, err + } + rootKey, err := m.deriveRootKey(cfg, params) if err != nil { return nil, err diff --git a/wallet/manager_test.go b/wallet/manager_test.go index 8a684b440b..813c81ad06 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -233,6 +233,96 @@ func TestManagerCreateError(t *testing.T) { } } +// TestCreateWalletParams_Validate verifies that the validate method enforces +// the correct constraints for each creation mode. +func TestCreateWalletParams_Validate(t *testing.T) { + t.Parallel() + + // Pre-calculate cryptographic material to construct specific test + // scenarios. + seed, err := hdkeychain.GenerateSeed(hdkeychain.RecommendedSeedLen) + require.NoError(t, err) + + rootKey, err := hdkeychain.NewMaster(seed, &chainParams) + require.NoError(t, err) + + tests := []struct { + name string + params CreateWalletParams + expectedErr string + }{ + { + name: "ModeGenSeed with Seed", + params: CreateWalletParams{ + Mode: ModeGenSeed, + Seed: seed, + }, + expectedErr: "seed should not be set for ModeGenSeed", + }, + { + name: "ModeGenSeed with RootKey", + params: CreateWalletParams{ + Mode: ModeGenSeed, + RootKey: rootKey, + }, + expectedErr: "root key should not be set for ModeGenSeed", + }, + { + name: "ModeImportSeed with RootKey", + params: CreateWalletParams{ + Mode: ModeImportSeed, + Seed: seed, + RootKey: rootKey, + }, + expectedErr: "root key should not be set for ModeImportSeed", + }, + { + name: "ModeImportExtKey with Seed", + params: CreateWalletParams{ + Mode: ModeImportExtKey, + RootKey: rootKey, + Seed: seed, + }, + expectedErr: "seed should not be set for ModeImportExtKey", + }, + { + name: "ModeShell with Seed", + params: CreateWalletParams{ + Mode: ModeShell, + Seed: seed, + }, + expectedErr: "seed should not be set for ModeShell", + }, + { + name: "Unknown Mode", + params: CreateWalletParams{ + Mode: ModeUnknown, + }, + expectedErr: "unknown mode", + }, + { + name: "InitialAccounts with ModeGenSeed", + params: CreateWalletParams{ + Mode: ModeGenSeed, + InitialAccounts: []WatchOnlyAccount{{ + Name: "test", + }}, + }, + expectedErr: "initial accounts should only " + + "be set for ModeShell", + }} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.params.validate() + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedErr) + }) + } +} + // TestManagerCreate_InvalidConfig verifies that the Create method performs // configuration validation before proceeding with any operations. func TestManagerCreate_InvalidConfig(t *testing.T) { From 50d20dca304606319e98ac09a01581b4b339177b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 29 Jan 2026 23:12:51 +0800 Subject: [PATCH 302/691] wallet: fix linter cyclop --- wallet/manager.go | 53 +++++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/wallet/manager.go b/wallet/manager.go index 07cbb1a276..0487e6d7ce 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -201,31 +201,14 @@ func (m *Manager) String() string { // Create creates a new wallet based on the provided configuration and // initialization parameters. It initializes the database structure and then // loads the wallet. -func (m *Manager) Create(cfg Config, params CreateWalletParams) ( - *Wallet, error) { +func (m *Manager) Create(cfg Config, + params CreateWalletParams) (*Wallet, error) { - err := cfg.validate() - if err != nil { - return nil, err - } - - err = params.validate() - if err != nil { - return nil, err - } - - rootKey, err := m.deriveRootKey(cfg, params) + rootKey, err := m.prepareWalletCreation(cfg, params) if err != nil { return nil, err } - // If the wallet is NOT watch-only, we require a private root key to be able - // to sign transactions and derive child private keys. - if !params.WatchOnly && rootKey != nil && !rootKey.IsPrivate() { - return nil, fmt.Errorf("%w: private key required for "+ - "non-watch-only wallet", ErrWalletParams) - } - // Create the underlying database structure. err = DBCreateWallet(cfg, params, rootKey) if err != nil { @@ -329,6 +312,36 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { return w, nil } +// prepareWalletCreation validates the configuration and parameters, and derives +// the root key for wallet creation. +func (m *Manager) prepareWalletCreation(cfg Config, + params CreateWalletParams) (*hdkeychain.ExtendedKey, error) { + + err := cfg.validate() + if err != nil { + return nil, err + } + + err = params.validate() + if err != nil { + return nil, err + } + + rootKey, err := m.deriveRootKey(cfg, params) + if err != nil { + return nil, err + } + + // If the wallet is NOT watch-only, we require a private root key to be able + // to sign transactions and derive child private keys. + if !params.WatchOnly && rootKey != nil && !rootKey.IsPrivate() { + return nil, fmt.Errorf("%w: private key required for "+ + "non-watch-only wallet", ErrWalletParams) + } + + return rootKey, nil +} + // deriveRootKey resolves the master extended key based on the creation mode. func (m *Manager) deriveRootKey(cfg Config, params CreateWalletParams) (*hdkeychain.ExtendedKey, error) { From 5e6e3909e69d227177937e1c766421dfbb8033db Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 27 Jan 2026 20:35:21 +0800 Subject: [PATCH 303/691] gitignore: ignore profile files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 172541d00c..25a01ef540 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ coverage.txt .DS_Store .aider* coverage.out +*.prof +*.test +*cpu.out From 655b4f2a244afe1c0fae7d6dba20aff6ed3421a1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 27 Jan 2026 17:58:39 +0800 Subject: [PATCH 304/691] wallet: validate `RecoveryWindow` --- wallet/manager_test.go | 42 +++++++++++++++++++----------------- wallet/wallet.go | 14 ++++++++++++ wallet/wallet_test.go | 48 ++++++++++++++++++++++++++++-------------- 3 files changed, 69 insertions(+), 35 deletions(-) diff --git a/wallet/manager_test.go b/wallet/manager_test.go index 813c81ad06..669a12d275 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -101,11 +101,12 @@ func TestManagerCreateSuccess(t *testing.T) { m := NewManager() cfg := Config{ - DB: db, - Chain: &mockChain{}, - ChainParams: &chainParams, - Name: "test-wallet", - PubPassphrase: []byte("public"), + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + PubPassphrase: []byte("public"), + RecoveryWindow: MinRecoveryWindow, } // Attempt to create the wallet with the specified parameters. @@ -216,10 +217,11 @@ func TestManagerCreateError(t *testing.T) { m := NewManager() cfg := Config{ - DB: db, - Chain: &mockChain{}, - ChainParams: &chainParams, - Name: "test-wallet", + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + RecoveryWindow: MinRecoveryWindow, } // Attempt to create the wallet. We expect this to fail due to @@ -351,11 +353,12 @@ func TestManagerLoadSuccess(t *testing.T) { m := NewManager() cfg := Config{ - DB: db, - Chain: &mockChain{}, - ChainParams: &chainParams, - Name: "test-wallet", - PubPassphrase: []byte("public"), + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + PubPassphrase: []byte("public"), + RecoveryWindow: MinRecoveryWindow, } params := CreateWalletParams{ Mode: ModeGenSeed, @@ -395,11 +398,12 @@ func TestManagerLoad_ExistingWallet(t *testing.T) { m := NewManager() cfg := Config{ - DB: db, - Chain: &mockChain{}, - ChainParams: &chainParams, - Name: "test-wallet", - PubPassphrase: []byte("public"), + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + PubPassphrase: []byte("public"), + RecoveryWindow: MinRecoveryWindow, } params := CreateWalletParams{ Mode: ModeGenSeed, diff --git a/wallet/wallet.go b/wallet/wallet.go index 37aa2d0e14..8106b22b13 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -56,6 +56,12 @@ const ( // defaultLockDuration is the default duration for automatic wallet // locking. defaultLockDuration = 10 * time.Minute + + // MinRecoveryWindow is the minimum allowed value for the RecoveryWindow + // configuration parameter. This value ensures that a sufficient number + // of addresses are scanned during wallet recovery to avoid missing + // funds due to gaps in the address chain. + MinRecoveryWindow = 20 ) var ( @@ -95,6 +101,9 @@ var ( // the configuration. ErrMissingParam = errors.New("missing config parameter") + // ErrInvalidParam is returned when a parameter is invalid. + ErrInvalidParam = errors.New("invalid config parameter") + // Namespace bucket keys. waddrmgrNamespaceKey = []byte("waddrmgr") wtxmgrNamespaceKey = []byte("wtxmgr") @@ -221,6 +230,11 @@ func (c *Config) validate() error { return fmt.Errorf("%w: Name", ErrMissingParam) } + if c.RecoveryWindow < MinRecoveryWindow { + return fmt.Errorf("%w: RecoveryWindow must be at least %d", + ErrInvalidParam, MinRecoveryWindow) + } + return nil } diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index fb1304930a..10f83635ec 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -53,45 +53,61 @@ func TestConfigValidate(t *testing.T) { { name: "valid config", config: Config{ - DB: db, - Chain: &mockChain{}, - ChainParams: &chainParams, - Name: "test-wallet", + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + RecoveryWindow: MinRecoveryWindow, }, }, + { + name: "invalid RecoveryWindow", + config: Config{ + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + RecoveryWindow: MinRecoveryWindow - 1, + }, + expectedErr: "RecoveryWindow", + }, { name: "missing DB", config: Config{ - Chain: &mockChain{}, - ChainParams: &chainParams, - Name: "test-wallet", + Chain: &mockChain{}, + ChainParams: &chainParams, + Name: "test-wallet", + RecoveryWindow: MinRecoveryWindow, }, expectedErr: "DB", }, { name: "missing Chain", config: Config{ - DB: db, - ChainParams: &chainParams, - Name: "test-wallet", + DB: db, + ChainParams: &chainParams, + Name: "test-wallet", + RecoveryWindow: MinRecoveryWindow, }, expectedErr: "Chain", }, { name: "missing ChainParams", config: Config{ - DB: db, - Chain: &mockChain{}, - Name: "test-wallet", + DB: db, + Chain: &mockChain{}, + Name: "test-wallet", + RecoveryWindow: MinRecoveryWindow, }, expectedErr: "ChainParams", }, { name: "missing Name", config: Config{ - DB: db, - Chain: &mockChain{}, - ChainParams: &chainParams, + DB: db, + Chain: &mockChain{}, + ChainParams: &chainParams, + RecoveryWindow: MinRecoveryWindow, }, expectedErr: "Name", }, From 4346addf3da5fe9185a039f6dd8f79f35b48c6c4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 27 Jan 2026 12:50:10 +0800 Subject: [PATCH 305/691] wallet: fix existing benchmark tests --- wallet/benchmark_helpers_test.go | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index ece842ca45..57245a2d64 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -296,6 +296,41 @@ func setupBenchmarkWallet(tb testing.TB, w := testWallet(setupT) require.False(tb, setupT.Failed(), "testWallet setup failed") + // Backfill Config and State for benchmarks comparing new APIs. + // Legacy testWallet does not populate these. + if w.cfg.DB == nil { + w.cfg = Config{ + DB: w.db, + ChainParams: w.chainParams, + Chain: w.chainClient, + } + } + + if w.sync == nil { + w.sync = newSyncer(w.cfg, w.addrStore, w.txStore, w) + } + + // Initialize controller channels and timer. + if w.requestChan == nil { + w.requestChan = make(chan any) + } + + if w.lockTimer == nil { + w.lockTimer = time.NewTimer(0) + if !w.lockTimer.Stop() { + <-w.lockTimer.C + } + } + + // Force state to Started to satisfy validateStarted() in new APIs. + err := w.state.toStarting() + if err == nil { + err = w.state.toStarted() + require.NoError(tb, err) + } + // Transition to Unlocked so signing operations are permitted. + w.state.toUnlocked() + addresses := createTestAccounts( tb, w, cfg.scopes, cfg.numAccounts, cfg.numAddresses, ) From 9d6084cff6dd4a858ebf91094b243878ea13e22f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 27 Jan 2026 12:50:39 +0800 Subject: [PATCH 306/691] wallet: exit early when chain is synced --- wallet/syncer.go | 5 +++++ wallet/syncer_test.go | 4 +--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/wallet/syncer.go b/wallet/syncer.go index d1ea32b3b9..d3b7f77b12 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -273,6 +273,11 @@ func (s *syncer) initChainSync(ctx context.Context) error { // waitUntilBackendSynced blocks until the chain backend considers itself // "current". func (s *syncer) waitUntilBackendSynced(ctx context.Context) error { + // Check immediately if the backend is already synced. + if s.cfg.Chain.IsCurrent() { + return nil + } + // We'll poll every second to determine if our chain considers itself // "current". t := time.NewTicker(time.Second) diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 58cf332f6d..a848ba92ae 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -116,11 +116,9 @@ func TestSyncerRun(t *testing.T) { Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, ) - // Mock expectations for the initial chain sync sequence. - // Use Maybe() because the run loop might exit immediately due to // context cancellation. mockAddrStore.On("Birthday").Return(time.Now()).Maybe() - mockChain.On("IsCurrent").Return(true).Maybe() + mockChain.On("IsCurrent").Return(false).Maybe() mockAddrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{}).Maybe() mockChain.On("NotifyBlocks").Return(nil).Maybe() From e066c6c970cd2e26fb3e6e5b3164b550f6854a47 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 27 Jan 2026 12:50:53 +0800 Subject: [PATCH 307/691] wallet: add benchmarks to check syncing empty blocks --- wallet/controller_benchmark_test.go | 290 ++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 wallet/controller_benchmark_test.go diff --git a/wallet/controller_benchmark_test.go b/wallet/controller_benchmark_test.go new file mode 100644 index 0000000000..25c0890a07 --- /dev/null +++ b/wallet/controller_benchmark_test.go @@ -0,0 +1,290 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" + "github.com/stretchr/testify/require" +) + +// testRecoveryWindow is the address lookahead window used during benchmarks. +// A larger window ensures that the wallet can discover addresses even if there +// are gaps in the address chain, which is essential for realistic +// synchronization benchmarks. +const testRecoveryWindow = 100 + +// BenchmarkSyncEmpty benchmarks the wallet synchronization performance against +// empty blocks and an empty wallet by comparing the legacy SynchronizeRPC with +// the new Controller.Start API across different block depths. +func BenchmarkSyncEmpty(b *testing.B) { + scenarios := []struct { + blocks int + }{ + {10}, + {100}, + {1000}, + } + + for _, s := range scenarios { + name := fmt.Sprintf("Blocks-%d", s.blocks) + b.Run(name, func(b *testing.B) { + // Initialize a common miner for all sub-benchmarks in this + // scenario. + miner := setupChain(b, s.blocks) + + b.Run("Legacy", func(b *testing.B) { + runLegacySync(b, miner) + }) + + b.Run("NewWithFullBlock", func(b *testing.B) { + runNewSync(b, miner, SyncMethodFullBlocks) + }) + + b.Run("NewWithCFilter", func(b *testing.B) { + runNewSync(b, miner, SyncMethodCFilters) + }) + }) + } +} + +// runLegacySync executes the legacy synchronization benchmark loop. +func runLegacySync(b *testing.B, miner *rpctest.Harness) { + b.Helper() + + for b.Loop() { + b.StopTimer() + + // Setup a fresh legacy wallet for each iteration. + seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes) + require.NoError(b, err) + w := setupLegacyWallet(b, seed) + + // Connect a fresh chain client. + chainClient := setupChainClient(b, miner) + + b.StartTimer() + + // Start legacy sync process. + w.StartDeprecated() + w.SynchronizeRPC(chainClient) + + // Poll until the wallet reports it is synced. + for !w.ChainSynced() { + time.Sleep(5 * time.Millisecond) + } + } +} + +// runNewSync executes the modern Controller synchronization benchmark loop. +func runNewSync(b *testing.B, miner *rpctest.Harness, method SyncMethod) { + b.Helper() + + for b.Loop() { + b.StopTimer() + + // Connect a fresh chain client. + chainClient := setupChainClient(b, miner) + + // Configure for the specified sync mode. + cfg := Config{ + Chain: chainClient, + SyncMethod: method, + } + + // Setup a fresh modern wallet. + seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes) + require.NoError(b, err) + w := setupNewWallet(b, seed, cfg) + + b.StartTimer() + + // Start modern controller and syncing. + err = w.Start(b.Context()) + require.NoError(b, err) + + // Poll until the controller reports it is synced. + // + // NOTE: We use w.Info() here to poll for the synced status. This is a + // heavier operation than the legacy mutex-protected boolean read, + // making the observed performance gains even more significant as they + // include this additional status-check overhead. + for { + info, err := w.Info(b.Context()) + require.NoError(b, err) + + if info.Synced { + break + } + + time.Sleep(5 * time.Millisecond) + } + } +} + +// setupLegacyWallet initializes a legacy wallet for benchmarking. It +// automatically registers resource cleanup. +func setupLegacyWallet(tb testing.TB, seed []byte) *Wallet { + tb.Helper() + + // Initialize temporary database directory and standard test credentials. + dir := tb.TempDir() + pubPass := []byte("public") + privPass := []byte("private") + + // Create the wallet using the legacy Loader. + loader := NewLoader( + &chaincfg.RegressionNetParams, dir, true, 10*time.Second, + testRecoveryWindow, + WithWalletSyncRetryInterval(10*time.Millisecond), + ) + + // Use an old birthday to ensure the wallet rescans past blocks. + birthday := time.Now().Add(-48 * time.Hour) + w, err := loader.CreateNewWallet(pubPass, privPass, seed, birthday) + require.NoError(tb, err) + + // Register cleanup function to ensure all legacy background processes are + // stopped and the database is correctly closed after the benchmark subtest. + tb.Cleanup(func() { + w.StopDeprecated() + w.WaitForShutdown() + + if val := w.Database(); val != nil { + require.NoError(tb, val.Close()) + } + }) + + return w +} + +// setupNewWallet initializes a modern wallet using the Manager API. It accepts +// a Config which should at least have the Chain client populated. It +// automatically registers resource cleanup. +func setupNewWallet(tb testing.TB, seed []byte, cfg Config) *Wallet { + tb.Helper() + + // Unconditionally initialize mandatory fields for the benchmark setup. + if cfg.DB == nil { + dir := tb.TempDir() + dbPath := filepath.Join(dir, "wallet.db") + db, err := walletdb.Create("bdb", dbPath, true, 10*time.Second, false) + require.NoError(tb, err) + cfg.DB = db + } + + cfg.ChainParams = &chaincfg.RegressionNetParams + cfg.Name = "new-bench-wallet" + cfg.PubPassphrase = []byte("public") + cfg.WalletSyncRetryInterval = 10 * time.Millisecond + cfg.RecoveryWindow = testRecoveryWindow + + privPass := []byte("private") + params := CreateWalletParams{ + Mode: ModeGenSeed, + Seed: seed, + PrivatePassphrase: privPass, + PubPassphrase: cfg.PubPassphrase, + Birthday: time.Now().Add(-48 * time.Hour), + } + + // Override params if seed is provided (for SyncData). + if seed != nil { + params.Mode = ModeImportSeed + params.Seed = seed + } + + // Create the wallet using the new Manager API. This returns a loaded + // but unstarted wallet instance. + manager := NewManager() + w, err := manager.Create(cfg, params) + require.NoError(tb, err) + + // Register cleanup function to handle the Controller shutdown and close + // the database handle after the benchmark subtest. + tb.Cleanup(func() { + _ = w.Stop(tb.Context()) + require.NoError(tb, w.cfg.DB.Close()) + }) + + return w +} + +// setupChain prepares a btcd node and generates the required blocks. +func setupChain(tb testing.TB, blocks int) *rpctest.Harness { + tb.Helper() + + args := []string{ + "--txindex", + "--minrelaytxfee=0.00000001", // 1 sat/kb + } + miner, err := rpctest.New(&chaincfg.RegressionNetParams, nil, args, "") + require.NoError(tb, err) + require.NoError(tb, miner.SetUp(true, uint32(blocks))) + + // Generate the requested number of empty blocks. + if blocks > 0 { + _, err := miner.Client.Generate(uint32(blocks)) + require.NoError(tb, err) + } + + tb.Cleanup(func() { + require.NoError(tb, miner.TearDown()) + }) + + return miner +} + +// setupChainClient initializes and starts a new RPC client connection to the +// provided chain backend. It automatically registers resource cleanup. +func setupChainClient(tb testing.TB, miner *rpctest.Harness) chain.Interface { + tb.Helper() + + rpcConf := miner.RPCConfig() + cfg := &chain.RPCClientConfig{ + ReconnectAttempts: 1, + Chain: &chaincfg.RegressionNetParams, + Conn: &rpcclient.ConnConfig{ + Host: rpcConf.Host, + User: rpcConf.User, + Pass: rpcConf.Pass, + Certificates: rpcConf.Certificates, + DisableTLS: false, + Endpoint: "ws", + }, + } + c, err := chain.NewRPCClientWithConfig(cfg) + require.NoError(tb, err) + + err = c.Start(tb.Context()) + require.NoError(tb, err) + + // Ensure the client is fully synced to the miner's tip. + require.Eventually(tb, func() bool { + _, height, err := c.GetBestBlock() + require.NoError(tb, err) + + _, bestHeight, err := miner.Client.GetBestBlock() + require.NoError(tb, err) + + return height == bestHeight + }, 5*time.Second, 50*time.Millisecond, "chain client failed to sync") + + // Register cleanup to ensure the connection is closed after the benchmark + // subtest. + tb.Cleanup(c.Stop) + + return c +} From 2d4fa5157c7ad4a75fe1a7647f3158bc7a99d206 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 27 Jan 2026 21:20:09 +0800 Subject: [PATCH 308/691] wallet: add benchmarks to check syncing with data --- wallet/controller_benchmark_test.go | 665 +++++++++++++++++++++++++--- 1 file changed, 607 insertions(+), 58 deletions(-) diff --git a/wallet/controller_benchmark_test.go b/wallet/controller_benchmark_test.go index 25c0890a07..ccdfef0ddb 100644 --- a/wallet/controller_benchmark_test.go +++ b/wallet/controller_benchmark_test.go @@ -5,26 +5,45 @@ package wallet import ( + "context" "fmt" + "os" + "os/exec" "path/filepath" + "runtime/pprof" + "strings" "testing" "time" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/integration/rpctest" - "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/chain/port" + "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" _ "github.com/btcsuite/btcwallet/walletdb/bdb" "github.com/stretchr/testify/require" ) -// testRecoveryWindow is the address lookahead window used during benchmarks. -// A larger window ensures that the wallet can discover addresses even if there -// are gaps in the address chain, which is essential for realistic -// synchronization benchmarks. -const testRecoveryWindow = 100 +const ( + // benchBlocksToSync is the number of blocks to generate and sync during + // the benchmark. + benchBlocksToSync = 1000 + + // benchTxsPerBlock is the total number of transactions per block + // (hits + noise). + benchTxsPerBlock = 100 + + // testRecoveryWindow is the address lookahead window used during + // benchmarks. A larger window ensures that the wallet can discover + // addresses even if there are gaps in the address chain, which is essential + // for realistic synchronization benchmarks. + testRecoveryWindow = 100 +) // BenchmarkSyncEmpty benchmarks the wallet synchronization performance against // empty blocks and an empty wallet by comparing the legacy SynchronizeRPC with @@ -60,6 +79,98 @@ func BenchmarkSyncEmpty(b *testing.B) { } } +// BenchmarkSyncData benchmarks the wallet synchronization performance against +// blocks with data and a populated wallet. It compares Legacy vs New APIs +// across different wallet sizes (number of accounts/addresses). +func BenchmarkSyncData(b *testing.B) { + scenarios := []struct { + addrs int + numUTXOs int + }{ + // Case 1: Sparse Discovery Stress Test. + // 100 total addresses, but only 1 UTXO sent to the 100th address + // (index 99). The hit is delivered at block 500. + // Density: 1 hit per 1000 blocks (0.1%). + // Intent: Tests how the wallet handles a nearly empty history where + // it must scan far into the chain and its lookahead window to find + // a single isolated transaction. + {addrs: 100, numUTXOs: 1}, + + // Case 2: Periodic Discovery Stress Test. + // 100 total addresses, with 10 UTXOs sent to every 10th address + // (index 9, 19, ... 99). Hits are delivered every 100 blocks. + // Density: 1 hit per 100 blocks (1%). + // Intent: Tests incremental discovery and sequential rescan logic + // as the wallet regularly finds hits and must decide whether to + // expand its search window. + {addrs: 100, numUTXOs: 10}, + + // Case 3: Dense Sync Throughput Test. + // 100 total addresses, with 100 UTXOs sent to every address. + // Hits are delivered every 10 blocks. + // Density: 1 hit per 10 blocks (10%). + // Intent: Tests the raw throughput of the transaction indexing and + // block processing logic when relevant data appears frequently. + {addrs: 100, numUTXOs: 100}, + } + + for _, s := range scenarios { + density := float64(s.numUTXOs) / float64(benchBlocksToSync) + name := fmt.Sprintf("UTXODensity-%.3f", density) + + b.Run(name, func(b *testing.B) { + seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes) + require.NoError(b, err) + + // Setup common miner and populate it with wallet-destined data. + // Always use 1 account. + miner := setupChainWithWalletData( + b, seed, s.addrs, s.numUTXOs, + ) + + b.ResetTimer() + + b.Run("Legacy", func(b *testing.B) { + runLegacySyncData(b, miner, seed, s.numUTXOs) + }) + + b.Run("NewWithFullBlock", func(b *testing.B) { + runNewSyncData( + b, miner, seed, SyncMethodFullBlocks, s.numUTXOs, + ) + }) + + b.Run("NewWithCFilter", func(b *testing.B) { + runNewSyncData( + b, miner, seed, SyncMethodCFilters, s.numUTXOs, + ) + }) + }) + } +} + +// startProfiling begins CPU profiling if the profileName is not empty. It +// returns a cleanup function that must be called to stop profiling. +func startProfiling(tb testing.TB) func() { + tb.Helper() + + // We use the test name to generate a unique profile filename for each + // benchmark case. Slashes are replaced with underscores to ensure a + // valid filename. + name := strings.ReplaceAll(tb.Name(), "/", "_") + ".prof" + + f, err := os.Create(name) + require.NoError(tb, err) + + err = pprof.StartCPUProfile(f) + require.NoError(tb, err) + + return func() { + pprof.StopCPUProfile() + require.NoError(tb, f.Close()) + } +} + // runLegacySync executes the legacy synchronization benchmark loop. func runLegacySync(b *testing.B, miner *rpctest.Harness) { b.Helper() @@ -75,6 +186,8 @@ func runLegacySync(b *testing.B, miner *rpctest.Harness) { // Connect a fresh chain client. chainClient := setupChainClient(b, miner) + stopProfile := startProfiling(b) + b.StartTimer() // Start legacy sync process. @@ -85,6 +198,55 @@ func runLegacySync(b *testing.B, miner *rpctest.Harness) { for !w.ChainSynced() { time.Sleep(5 * time.Millisecond) } + + stopProfile() + } +} + +// runLegacySyncData executes the legacy synchronization benchmark loop +// with data. +func runLegacySyncData(b *testing.B, miner *rpctest.Harness, seed []byte, + expectedUTXOs int) { + + b.Helper() + + for b.Loop() { + // Stop the timer to exclude expensive setup operations (like + // creating the wallet database and accounts) from the measured + // sync time. + b.StopTimer() + + // Setup a fresh legacy wallet. + w := setupLegacyWallet(b, seed) + + // Connect a fresh chain client (Bitcoind). + chainClient := setupChainClient(b, miner) + + stopProfile := startProfiling(b) + + // Start the timer for the actual synchronization phase. + b.StartTimer() + + // Start legacy sync process. + w.StartDeprecated() + + w.SynchronizeRPC(chainClient) + + // Poll until the wallet reports it is synced. + for !w.ChainSynced() { + time.Sleep(100 * time.Millisecond) + } + + // Stop the timer to exclude verification. + b.StopTimer() + + stopProfile() + + // Verify UTXO count using high-level method. + assertUTXOCountDeprecated(b, w, expectedUTXOs) + + // Restart timer for loop accounting. + b.StartTimer() } } @@ -99,16 +261,17 @@ func runNewSync(b *testing.B, miner *rpctest.Harness, method SyncMethod) { chainClient := setupChainClient(b, miner) // Configure for the specified sync mode. - cfg := Config{ - Chain: chainClient, - SyncMethod: method, - } + cfg := defaultWalletConfig(b) + cfg.Chain = chainClient + cfg.SyncMethod = method // Setup a fresh modern wallet. seed, err := hdkeychain.GenerateSeed(hdkeychain.MinSeedBytes) require.NoError(b, err) w := setupNewWallet(b, seed, cfg) + stopProfile := startProfiling(b) + b.StartTimer() // Start modern controller and syncing. @@ -131,6 +294,62 @@ func runNewSync(b *testing.B, miner *rpctest.Harness, method SyncMethod) { time.Sleep(5 * time.Millisecond) } + + stopProfile() + } +} + +// runNewSyncData executes the modern Controller synchronization benchmark loop +// with data. +func runNewSyncData(b *testing.B, miner *rpctest.Harness, seed []byte, + method SyncMethod, expectedUTXOs int) { + + b.Helper() + + for b.Loop() { + // Stop the timer to exclude expensive setup operations (like + // creating the wallet database and accounts) from the measured + // sync time. + b.StopTimer() + + chainClient := setupChainClient(b, miner) + cfg := defaultWalletConfig(b) + cfg.Chain = chainClient + cfg.SyncMethod = method + + w := setupNewWallet(b, seed, cfg) + + stopProfile := startProfiling(b) + + // Start the timer for the actual synchronization phase. + b.StartTimer() + + // Start modern controller and syncing. + err := w.Start(b.Context()) + require.NoError(b, err) + + // Poll until the controller reports it is synced. + for { + info, err := w.Info(b.Context()) + require.NoError(b, err) + + if info.Synced { + break + } + + time.Sleep(100 * time.Millisecond) + } + + // Stop the timer to exclude verification. + b.StopTimer() + + stopProfile() + + // Verify UTXO count using high-level method. + assertUTXOCount(b, w, expectedUTXOs) + + // Restart timer for loop accounting. + b.StartTimer() } } @@ -176,36 +395,15 @@ func setupLegacyWallet(tb testing.TB, seed []byte) *Wallet { func setupNewWallet(tb testing.TB, seed []byte, cfg Config) *Wallet { tb.Helper() - // Unconditionally initialize mandatory fields for the benchmark setup. - if cfg.DB == nil { - dir := tb.TempDir() - dbPath := filepath.Join(dir, "wallet.db") - db, err := walletdb.Create("bdb", dbPath, true, 10*time.Second, false) - require.NoError(tb, err) - cfg.DB = db - } - - cfg.ChainParams = &chaincfg.RegressionNetParams - cfg.Name = "new-bench-wallet" - cfg.PubPassphrase = []byte("public") - cfg.WalletSyncRetryInterval = 10 * time.Millisecond - cfg.RecoveryWindow = testRecoveryWindow - privPass := []byte("private") params := CreateWalletParams{ - Mode: ModeGenSeed, + Mode: ModeImportSeed, Seed: seed, PrivatePassphrase: privPass, PubPassphrase: cfg.PubPassphrase, Birthday: time.Now().Add(-48 * time.Hour), } - // Override params if seed is provided (for SyncData). - if seed != nil { - params.Mode = ModeImportSeed - params.Seed = seed - } - // Create the wallet using the new Manager API. This returns a loaded // but unstarted wallet instance. manager := NewManager() @@ -247,44 +445,395 @@ func setupChain(tb testing.TB, blocks int) *rpctest.Harness { return miner } -// setupChainClient initializes and starts a new RPC client connection to the -// provided chain backend. It automatically registers resource cleanup. +// setupChainWithWalletData prepares a miner and populates the blockchain with +// transactions destined for a wallet derived from the provided seed. This +// establishes a realistic environment for benchmarking synchronization. +func setupChainWithWalletData(tb testing.TB, seed []byte, + addrsPerAccount, numUTXOs int) *rpctest.Harness { + + tb.Helper() + + // Initialize common miner. + miner := setupChain(tb, 0) + + // 1. Setup a template wallet to extract addresses for the chain. + cfg := defaultWalletConfig(tb) + cfg.Chain = setupChainClient(tb, miner) + + templateW := setupNewWallet(tb, seed, cfg) + + err := templateW.Start(tb.Context()) + require.NoError(tb, err) + + // Unlock template wallet to derive addresses. + err = templateW.Unlock(tb.Context(), UnlockRequest{ + Passphrase: []byte("private"), + }) + require.NoError(tb, err) + + // Manually derive addresses for the template wallet. + var targetAddrs []btcutil.Address + + // Calculate chunk for selecting target addresses. + // Total addresses = addrsPerAccount (since 1 account). + // We want to pick 'numUTXOs' targets. + // Stride = Total / numUTXOs. + chunk := addrsPerAccount / numUTXOs + + accountName := waddrmgr.DefaultAccountName + for i := range addrsPerAccount { + addr, err := templateW.NewAddress(tb.Context(), + accountName, waddrmgr.WitnessPubKey, false) + require.NoError(tb, err) + + // Select target addresses based on the calculated chunk. + // For example, if we have 100 addresses and need 10 UTXOs, we pick + // every 10th address (index 9, 19, ... 99). This ensures the wallet + // must scan through gaps to find all hits, stressing the recovery + // and discovery logic. + targetIdx := (len(targetAddrs)+1)*chunk - 1 + if i == targetIdx { + targetAddrs = append(targetAddrs, addr) + } + } + + // Close the template wallet now that we are done with it. This releases + // the database lock and resources. + _ = templateW.Stop(tb.Context()) + require.NoError(tb, templateW.cfg.DB.Close()) + + // Ensure we selected the correct number of targets. + require.Len(tb, targetAddrs, numUTXOs, + "failed to select target addresses") + + // Pre-mine 200 blocks to ensure the miner has a sufficient balance of + // mature coinbase outputs. In Bitcoin, coinbase outputs cannot be spent + // until they have reached a depth of 100 blocks (maturity). Pre-mining 200 + // blocks ensures that the miner can immediately begin sending transactions + // to the wallet during the setup phase. + _, err = miner.Client.Generate(200) + require.NoError(tb, err) + + // 2. Setup the chain based on the scenario (numUTXOs). + switch numUTXOs { + case 1: + setupChainCase1(tb, miner, targetAddrs) + + case 10: + setupChainCase2(tb, miner, targetAddrs) + + case 100: + setupChainCase3(tb, miner, targetAddrs) + + default: + tb.Fatalf("Unsupported numUTXOs: %d", numUTXOs) + } + + return miner +} + +// setupChainCase1 mines 1000 blocks and sends exactly 1 UTXO to the wallet at +// the 500th block. This scenario tests how the wallet handles a sparse history +// and whether it can correctly recover from a birthday that predates a single, +// isolated transaction. +func setupChainCase1(tb testing.TB, miner *rpctest.Harness, + targetAddrs []btcutil.Address) { + + tb.Helper() + require.Len(tb, targetAddrs, 1) + + var err error + + // Iterate through 1000 blocks. We want the wallet to find its single UTXO + // midway through the scan. + for i := range benchBlocksToSync { + // Send the single UTXO to the wallet at block 500. + if i == 500 { + var pkScript []byte + + pkScript, err = txscript.PayToAddrScript(targetAddrs[0]) + require.NoError(tb, err) + + _, err = miner.SendOutputs([]*wire.TxOut{ + {Value: 1000, PkScript: pkScript}, + }, 1) + require.NoError(tb, err) + } + + // Fill the block with 100 noise transactions to simulate a realistic + // mainnet-like environment where the wallet must filter through + // many irrelevant transactions. + noiseCount := benchTxsPerBlock + if i == 500 { + noiseCount-- + } + + generateNonWalletTxns(tb, miner, noiseCount) + + // Mine the block. + _, err = miner.Client.Generate(1) + require.NoError(tb, err) + } +} + +// setupChainCase2 mines 1000 blocks and sends 1 UTXO to the wallet every 100 +// blocks, for a total of 10 UTXOs. This scenario tests the wallet's ability +// to handle incremental discovery and sequential rescans as it finds hits +// distributed regularly across the chain. +func setupChainCase2(tb testing.TB, miner *rpctest.Harness, + targetAddrs []btcutil.Address) { + + tb.Helper() + require.Len(tb, targetAddrs, 10) + + var err error + + targetIdx := 0 + for i := range benchBlocksToSync { + // Determine if this block is a hit (every 100th block). This creates + // a periodic matching pattern that triggers incremental discovery + // as the wallet reaches the end of its lookahead window. + isHit := (i+1)%100 == 0 + + if isHit { + // Pop the next target address and generate a script that pays + // to it. + var pkScript []byte + + pkScript, err = txscript.PayToAddrScript(targetAddrs[targetIdx]) + require.NoError(tb, err) + + targetIdx++ + + // Send the wallet payment. + _, err = miner.SendOutputs([]*wire.TxOut{ + {Value: 1000, PkScript: pkScript}, + }, 1) + require.NoError(tb, err) + } + + // Add noise transactions to fill the block to 100 total outputs. This + // ensures the wallet must filter through a realistic amount of + // irrelevant data in every block. + noiseCount := benchTxsPerBlock + if isHit { + noiseCount-- + } + + generateNonWalletTxns(tb, miner, noiseCount) + + // Mine the block. + _, err = miner.Client.Generate(1) + require.NoError(tb, err) + } +} + +// setupChainCase3 mines 1000 blocks and sends 1 UTXO to the wallet every 10 +// blocks, for a total of 100 UTXOs. This is a "dense" scenario that +// stresses the wallet's block processing and transaction indexing performance +// when relevant data appears frequently. +func setupChainCase3(tb testing.TB, miner *rpctest.Harness, + targetAddrs []btcutil.Address) { + + tb.Helper() + require.Len(tb, targetAddrs, 100) + + var err error + + targetIdx := 0 + for i := range benchBlocksToSync { + // Determine if this block is a hit (every 10th block). + isHit := (i+1)%10 == 0 + + if isHit { + // Pop the next target address and generate a script that pays + // to it. + var pkScript []byte + + pkScript, err = txscript.PayToAddrScript(targetAddrs[targetIdx]) + require.NoError(tb, err) + + targetIdx++ + + // Send the wallet payment. + _, err = miner.SendOutputs([]*wire.TxOut{ + {Value: 1000, PkScript: pkScript}, + }, 1) + require.NoError(tb, err) + } + + // Add noise transactions to reach the 100 txs/block target. This + // maintains a constant transaction density across all scenarios. + noiseCount := benchTxsPerBlock + if isHit { + noiseCount-- + } + + generateNonWalletTxns(tb, miner, noiseCount) + + // Mine the block. + _, err = miner.Client.Generate(1) + require.NoError(tb, err) + } +} + +// generateNonWalletTxns creates 'count' random transactions. +func generateNonWalletTxns(tb testing.TB, miner *rpctest.Harness, count int) { + tb.Helper() + + outputs := make([]*wire.TxOut, 0, count) + for range count { + mAddr, err := miner.NewAddress() + require.NoError(tb, err) + pkScript, err := txscript.PayToAddrScript(mAddr) + require.NoError(tb, err) + + outputs = append(outputs, &wire.TxOut{Value: 1000, PkScript: pkScript}) + } + + _, err := miner.SendOutputs(outputs, 1) + require.NoError(tb, err) +} + +// setupChainClient initializes and starts a new bitcoind client connection to +// the provided chain backend. It automatically registers resource cleanup. func setupChainClient(tb testing.TB, miner *rpctest.Harness) chain.Interface { tb.Helper() - rpcConf := miner.RPCConfig() - cfg := &chain.RPCClientConfig{ - ReconnectAttempts: 1, - Chain: &chaincfg.RegressionNetParams, - Conn: &rpcclient.ConnConfig{ - Host: rpcConf.Host, - User: rpcConf.User, - Pass: rpcConf.Pass, - Certificates: rpcConf.Certificates, - DisableTLS: false, - Endpoint: "ws", + // Start a bitcoind instance and connect it to miner. + tempBitcoindDir := tb.TempDir() + + zmqBlockPort := port.NextAvailablePort() + zmqTxPort := port.NextAvailablePort() + + zmqBlockHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqBlockPort) + zmqTxHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqTxPort) + + rpcPort := port.NextAvailablePort() + p2pPort := port.NextAvailablePort() + minerAddr := miner.P2PAddress() + + ctx, cancel := context.WithCancel(context.Background()) + tb.Cleanup(cancel) + + bitcoind := exec.CommandContext( + ctx, + "bitcoind", + "-datadir="+tempBitcoindDir, + "-regtest", + "-connect="+minerAddr, + "-txindex", + "-rpcauth=weks:469e9bb14ab2360f8e226efed5ca6f"+ + "d$507c670e800a95284294edb5773b05544b"+ + "220110063096c221be9933c82d38e1", + fmt.Sprintf("-rpcport=%d", rpcPort), + fmt.Sprintf("-port=%d", p2pPort), + "-disablewallet", + "-zmqpubrawblock="+zmqBlockHost, + "-zmqpubrawtx="+zmqTxHost, + "-blockfilterindex=1", + ) + require.NoError(tb, bitcoind.Start()) + + tb.Cleanup(func() { + _ = bitcoind.Process.Kill() + _ = bitcoind.Wait() + }) + + // Wait for the bitcoind instance to start up. + time.Sleep(time.Second) + + host := fmt.Sprintf("127.0.0.1:%d", rpcPort) + cfg := &chain.BitcoindConfig{ + ChainParams: &chaincfg.RegressionNetParams, + Host: host, + User: "weks", + Pass: "weks", + ZMQConfig: &chain.ZMQConfig{ + ZMQBlockHost: zmqBlockHost, + ZMQTxHost: zmqTxHost, + ZMQReadDeadline: 5 * time.Second, + MempoolPollingInterval: time.Millisecond * 100, }, } - c, err := chain.NewRPCClientWithConfig(cfg) + + chainConn, err := chain.NewBitcoindConn(cfg) require.NoError(tb, err) + require.NoError(tb, chainConn.Start()) - err = c.Start(tb.Context()) + tb.Cleanup(func() { + chainConn.Stop() + }) + + // Create a bitcoind client. + btcClient, err := chainConn.NewBitcoindClient() require.NoError(tb, err) + require.NoError(tb, btcClient.Start(tb.Context())) + + tb.Cleanup(func() { + btcClient.Stop() + }) - // Ensure the client is fully synced to the miner's tip. + // Wait for bitcoind to sync with the miner. + // We want to ensure it has synced at least to the miner's tip. require.Eventually(tb, func() bool { - _, height, err := c.GetBestBlock() - require.NoError(tb, err) + _, height, err := btcClient.GetBestBlock() + if err != nil { + return false + } - _, bestHeight, err := miner.Client.GetBestBlock() - require.NoError(tb, err) + _, minerHeight, _ := miner.Client.GetBestBlock() + + return height >= minerHeight + }, 30*time.Second, 100*time.Millisecond) - return height == bestHeight - }, 5*time.Second, 50*time.Millisecond, "chain client failed to sync") + return btcClient +} - // Register cleanup to ensure the connection is closed after the benchmark - // subtest. - tb.Cleanup(c.Stop) +// defaultWalletConfig returns a Config with standard benchmark settings. +func defaultWalletConfig(tb testing.TB) Config { + tb.Helper() + + dir := tb.TempDir() + dbPath := filepath.Join(dir, "wallet.db") + db, err := walletdb.Create("bdb", dbPath, true, 10*time.Second, false) + require.NoError(tb, err) + + return Config{ + DB: db, + ChainParams: &chaincfg.RegressionNetParams, + Name: "bench-wallet", + PubPassphrase: []byte("public"), + WalletSyncRetryInterval: 10 * time.Millisecond, + RecoveryWindow: testRecoveryWindow, + } +} + +// assertUTXOCount verifies the number of unspent outputs in a modern wallet. +func assertUTXOCount(b *testing.B, w *Wallet, expected int) { + b.Helper() + + require.Eventually(b, func() bool { + utxos, err := w.ListUnspent(b.Context(), UtxoQuery{ + MinConfs: 0, + MaxConfs: 99999, + }) + require.NoError(b, err) + + return len(utxos) == expected + }, 20*time.Second, 100*time.Millisecond, "new wallet utxo count mismatch") +} + +// assertUTXOCountDeprecated verifies the number of unspent outputs in a legacy +// wallet. +func assertUTXOCountDeprecated(b *testing.B, w *Wallet, expected int) { + b.Helper() + + require.Eventually(b, func() bool { + utxos, err := w.ListUnspentDeprecated(0, 999999, "") + require.NoError(b, err) - return c + return len(utxos) == expected + }, 20*time.Second, 100*time.Millisecond, + "legacy wallet utxo count mismatch") } From 1fc8b3c679f446ae46d8b7363ceb8673eea04705 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 28 Jan 2026 14:11:59 +0800 Subject: [PATCH 309/691] docs: add user docs for synchronization modes --- docs/user/README.md | 19 +++++++++ docs/user/synchronization_modes.md | 65 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 docs/user/README.md create mode 100644 docs/user/synchronization_modes.md diff --git a/docs/user/README.md b/docs/user/README.md new file mode 100644 index 0000000000..906a2cf6ee --- /dev/null +++ b/docs/user/README.md @@ -0,0 +1,19 @@ +# User Documentation + +This section contains documentation for end-users and operators of `btcwallet`. + +--- + +## 🔄 Synchronization Modes + +A detailed guide on choosing between Compact Filters (`SyncMethodCFilters`) and Full Blocks (`SyncMethodFullBlocks`) based on your transaction density. Includes performance benchmarks and strategy recommendations. + +**[➡️ Read the Synchronization Modes Guide](./synchronization_modes.md)** + +--- + +## 🛠️ Rebuilding Transaction History + +Instructions on how to force a full wallet rescan using the `dropwtxmgr` tool. Useful for recovering from database corruption or missing history. + +**[➡️ Learn about Forced Rescans](./force_rescans.md)** diff --git a/docs/user/synchronization_modes.md b/docs/user/synchronization_modes.md new file mode 100644 index 0000000000..351779a53c --- /dev/null +++ b/docs/user/synchronization_modes.md @@ -0,0 +1,65 @@ +# Synchronization Modes: Compact Filters vs. Full Blocks + +When connecting to a chain backend, `btcwallet` offers two distinct synchronization strategies, configured via the `SyncMethod` parameter: + +1. **`SyncMethodCFilters`**: Uses lightweight Compact Filters (Neutrino) to scan for relevant transactions. +2. **`SyncMethodFullBlocks`**: Downloads full blocks (or batches of blocks) to scan locally. + +Choosing the right mode can significantly impact your wallet's startup time, bandwidth usage, and CPU load. This guide explains the differences and provides performance benchmarks to help you decide which mode fits your use case. + +## Summary Recommendation + +| Transaction Density | Recommended Mode | Why? | +| :--- | :--- | :--- | +| **Low Frequency** (< 1 hit per 100 blocks) | **`SyncMethodCFilters`** | **~4x Faster.** Optimized for sparse histories by avoiding block data entirely. | +| **Moderate Frequency** (1 hit per 10-100 blocks) | **`SyncMethodCFilters`** | **~2x Faster.** Still faster than full blocks due to reduced data transfer. | +| **High Frequency** (> 1 hit per 10 blocks) | **`SyncMethodFullBlocks`** | **High Throughput.** Most efficient when hits are dense, avoiding matching overhead. | + +--- + +## 1. Compact Filters (CFilter) +**Default & Recommended for 99% of Users.** + +In this mode (`SyncMethodCFilters`), the wallet downloads lightweight **Neutrino Compact Filters** (Golomb-Rice filters) for each block to check if the block contains any relevant transactions. +* **Process**: Fetch Filter -> Match Filter -> (Only if Match) Fetch Block. +* **Pros**: + * Extremely fast for "empty" blocks. + * Minimal bandwidth usage (filters are ~15KB vs blocks ~1-4MB). +* **Cons**: + * Slower if every block is a "hit" (requires double round-trips: get filter, then get block). + +## 2. Full Blocks +**Recommended for High-Density Wallets.** + +In this mode (`SyncMethodFullBlocks`), the wallet indiscriminately downloads every full block (or batches of blocks) and scans them locally. +* **Process**: Fetch Batch of Blocks -> Scan All. +* **Pros**: + * Linear scaling for high-traffic wallets. + * Eliminates the "Match Filter -> Fetch Block" latency penalty when hits are frequent. +* **Cons**: + * High bandwidth (downloads the entire blockchain history during rescan). + * High CPU/Memory usage (parsing gigabytes of JSON/Hex block data). + +--- + +## Performance Analysis & UTXO Density + +We benchmarked both modes against a standard `bitcoind` node over a range of 1,000 blocks. The performance depends heavily on **UTXO Density**: how often your wallet sends or receives a transaction relative to the number of blocks. + +### Benchmark Results (1,000 Blocks) + +| Wallet Activity (Density) | Real-World Equivalent | `SyncMethodFullBlocks` | `SyncMethodCFilters` | Winner | +| :--- | :--- | :--- | :--- | :--- | +| **0.001 (0.1%)** | **1 Tx / Week** | ~1.9x Faster | **~3.8x Faster** | **`SyncMethodCFilters`** | +| **0.01 (1.0%)** | **1 Tx / 16 Hours** | ~1.5x Faster | **~2.1x Faster** | **`SyncMethodCFilters`** | +| **0.1 (10%)** | **10 Txs / Hour** | **~1.8x Faster** | Slower | **`SyncMethodFullBlocks`** | + +* *Speedups are compared against the legacy synchronization API.* + +### Interpreting Density +* **Low to Moderate Frequency (Density < 0.1)**: This represents the vast majority of wallet usage. If your wallet receives or sends funds a few times a day or less, your transaction history is "sparse" relative to the blockchain (~144 blocks/day). **Use `SyncMethodCFilters`** to save bandwidth and reduce sync time. +* **High Frequency (Density > 0.1)**: This represents "dense" wallets that are active in **1 out of every 10 blocks** (or more). For these high-traffic scenarios, the overhead of double-fetching (filter match then block fetch) exceeds the cost of just fetching everything. **Use `SyncMethodFullBlocks`** for maximum throughput. + +## Conclusion + +The new synchronization architecture in `btcwallet` is designed to be adaptive. By default, **`SyncMethodCFilters`** provides a superior experience for typical users, offering massive speedups and bandwidth savings. However, for high-workload scenarios where the wallet effectively indexes a significant portion of the chain, **`SyncMethodFullBlocks`** mode provides a robust, high-throughput alternative that outperforms the legacy implementation. From 6b880c407f7a561d409077cb2a2af271da2a6ad6 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 18:11:15 -0300 Subject: [PATCH 310/691] tools: add sqlc tool --- tools/Dockerfile | 3 +- tools/go.mod | 45 ++++++++++++++-- tools/go.sum | 138 ++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/tools/Dockerfile b/tools/Dockerfile index 03f84094a6..52d530928e 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -2,7 +2,7 @@ FROM golang:1.24.6-alpine ENV GOFLAGS="-buildvcs=false" -RUN apk update && apk add --no-cache git +RUN apk update && apk add --no-cache git bash WORKDIR /build @@ -11,6 +11,7 @@ COPY tools/go.mod tools/go.sum ./ RUN go install -trimpath \ github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ github.com/rinchsan/gosimports/cmd/gosimports \ + github.com/sqlc-dev/sqlc/cmd/sqlc \ && rm -rf /go/pkg/mod \ && rm -rf /root/.cache/go-build \ && rm -rf /go/src \ diff --git a/tools/go.mod b/tools/go.mod index 71dde0a57e..3e07101e85 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -5,14 +5,17 @@ go 1.24.6 tool ( github.com/golangci/golangci-lint/v2/cmd/golangci-lint github.com/rinchsan/gosimports/cmd/gosimports + github.com/sqlc-dev/sqlc/cmd/sqlc ) require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect + cel.dev/expr v0.24.0 // indirect codeberg.org/chavacava/garif v0.2.0 // indirect dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect dev.gaijin.team/go/golib v0.6.0 // indirect + filippo.io/edwards25519 v1.1.0 // indirect github.com/4meepo/tagalign v1.4.3 // indirect github.com/Abirdcfly/dupword v0.1.6 // indirect github.com/AlwxSin/noinlineerr v1.0.5 // indirect @@ -30,6 +33,7 @@ require ( github.com/alfatraining/structtag v1.0.0 // indirect github.com/alingse/asasalint v0.0.11 // indirect github.com/alingse/nilnesserr v0.2.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/ashanbrown/forbidigo/v2 v2.1.0 // indirect github.com/ashanbrown/makezero/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -52,12 +56,14 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect + github.com/cubicdaiya/gonp v1.0.4 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect github.com/dave/dst v0.27.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect @@ -66,6 +72,7 @@ require ( github.com/fzipp/gocyclo v0.6.0 // indirect github.com/ghostiam/protogetter v0.3.15 // indirect github.com/go-critic/go-critic v0.13.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -77,7 +84,7 @@ require ( github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.12.1 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/golangci/asciicheck v0.5.0 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/go-printf-func-name v0.1.0 // indirect @@ -89,7 +96,9 @@ require ( github.com/golangci/revgrep v0.8.0 // indirect github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect + github.com/google/cel-go v0.26.1 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gordonklaus/ineffassign v0.1.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect @@ -101,8 +110,13 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jgautheron/goconst v1.8.2 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect github.com/jjti/go-spancheck v0.6.5 // indirect github.com/julz/importas v0.2.0 // indirect github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect @@ -135,11 +149,17 @@ require ( github.com/moricho/tparallel v0.3.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/nakabonne/nestif v0.3.1 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.20.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect + github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect + github.com/pingcap/log v1.1.0 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polyfloyd/go-errorlint v1.8.0 // indirect github.com/prometheus/client_golang v1.12.1 // indirect @@ -152,8 +172,10 @@ require ( github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect github.com/raeperd/recvcheck v0.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rinchsan/gosimports v0.3.8 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/riza-io/grpc-go v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/ryancurrah/gomodguard v1.4.1 // indirect github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect @@ -172,12 +194,15 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/viper v1.12.0 // indirect + github.com/sqlc-dev/sqlc v1.30.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tetafro/godot v1.5.1 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect @@ -186,6 +211,8 @@ require ( github.com/ultraware/whitespace v0.2.0 // indirect github.com/uudashr/gocognit v1.2.0 // indirect github.com/uudashr/iface v1.4.1 // indirect + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/xen0n/gosmopolitan v1.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yagipy/maintidx v1.0.0 // indirect @@ -196,20 +223,32 @@ require ( go-simpler.org/sloglint v0.11.1 // indirect go.augendre.info/arangolint v0.2.0 // indirect go.augendre.info/fatcontext v0.8.1 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect - go.uber.org/multierr v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/tools v0.36.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.6.1 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.2 // indirect mvdan.cc/gofumpt v0.8.0 // indirect mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect ) diff --git a/tools/go.sum b/tools/go.sum index 58b944c06d..945fcce995 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -2,6 +2,8 @@ 4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= 4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= 4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -41,6 +43,8 @@ dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88 dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= github.com/Abirdcfly/dupword v0.1.6 h1:qeL6u0442RPRe3mcaLcbaCi2/Y/hOcdtw6DE9odjz9c= @@ -86,12 +90,15 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/ashanbrown/forbidigo/v2 v2.1.0 h1:NAxZrWqNUQiDz19FKScQ/xvwzmij6BiOw3S0+QUQ+Hs= github.com/ashanbrown/forbidigo/v2 v2.1.0/go.mod h1:0zZfdNAuZIL7rSComLGthgc/9/n2FqspBOH90xlCHdA= github.com/ashanbrown/makezero/v2 v2.0.1 h1:r8GtKetWOgoJ4sLyUx97UTwyt2dO7WkGFHizn/Lo8TY= github.com/ashanbrown/makezero/v2 v2.0.1/go.mod h1:kKU4IMxmYW1M4fiEHMb2vc5SFoPzXvgbMR9gIp5pjSw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -141,6 +148,8 @@ github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nW github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= +github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= @@ -156,6 +165,8 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -189,8 +200,12 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -249,8 +264,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= @@ -275,6 +290,8 @@ github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqt github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -301,6 +318,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= @@ -336,10 +355,20 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -433,6 +462,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= @@ -454,6 +485,17 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= +github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= +github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= +github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -497,11 +539,15 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rinchsan/gosimports v0.3.8 h1:X4Pb9yFf6teHvogorT04yK/0W2Df7eHO79biCcYrA4c= github.com/rinchsan/gosimports v0.3.8/go.mod h1:t0567k69sUHjLvJMPDsV31THZC+8UIbY1oL7NW+0I2c= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= +github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -549,10 +595,14 @@ github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= +github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c= +github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -560,6 +610,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -571,6 +622,8 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpR github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4= github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= @@ -587,6 +640,10 @@ github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYR github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU= github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -621,12 +678,33 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -638,6 +716,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -648,8 +728,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b h1:KdrhdYPDUvJTvrDK9gdjfFd6JTk8vA1WJoldYSi0kHo= @@ -835,6 +915,8 @@ golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -884,6 +966,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -935,6 +1019,10 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f h1:OiFuztEyBivVKDvguQJYWq1yDcfAHIID/FVrPR4oiI0= +google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f/go.mod h1:kprOiu9Tr0JYyD6DORrc4Hfyk3RFXqkQ3ctHEum3ZbM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -947,6 +1035,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -959,8 +1049,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -970,14 +1061,19 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -989,6 +1085,32 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k= mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg= mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8= From 4ced712c70cb5aa9e8ca60dad7b02eb4591eb1a5 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:50:56 -0300 Subject: [PATCH 311/691] internal: add sqlc config --- internal/db/sqldb/sqlc/sqlc.yaml | 28 ++++++++++ internal/db/sqldb/sqlc/sqlc_generate.sh | 74 +++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 internal/db/sqldb/sqlc/sqlc.yaml create mode 100755 internal/db/sqldb/sqlc/sqlc_generate.sh diff --git a/internal/db/sqldb/sqlc/sqlc.yaml b/internal/db/sqldb/sqlc/sqlc.yaml new file mode 100644 index 0000000000..738c4cc336 --- /dev/null +++ b/internal/db/sqldb/sqlc/sqlc.yaml @@ -0,0 +1,28 @@ +# sqlc configuration file for generating type-safe Go code from SQL. +# For full documentation, see: https://docs.sqlc.dev/en/stable/reference/config.html +version: "2" +sql: + - engine: "postgresql" + schema: "../migrations" + queries: "queries" + gen: + go: + out: . + package: sqlc + + # This is the driver package that sqlc will use in the generated code. + # The default value already is `database/sql`, but we are being + # explicit here that we want multiple database drivers to be supported. + sql_package: database/sql + + # Generate a `Querier` interface of all query methods. It's useful for + # mocking in tests. + emit_interface: true + + # Export generated SQL statements so they’re usable from other packages. + emit_exported_queries: true + + # Use prepared statements to reuse plans and cut parse overhead. + # The pgx driver already does this internally, but for other drivers + # like sqlite this can provide a performance boost. + emit_prepared_queries: true diff --git a/internal/db/sqldb/sqlc/sqlc_generate.sh b/internal/db/sqldb/sqlc/sqlc_generate.sh new file mode 100755 index 0000000000..1bfeb0c69c --- /dev/null +++ b/internal/db/sqldb/sqlc/sqlc_generate.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +set -e + +# Directory of the script file, independent of where it's called from. +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# restore_files restore original schema files. +function restore_files() { + echo "Restoring SQLite bigint patch..." + for file in "$DIR"/../migrations/*.up.sql.original; do + mv "$file" "${file%.original}" + done +} + +# Set trap to call restore_files on script exit. This makes sure the old files +# are always restored. +trap restore_files EXIT + +# SQLite requires `INTEGER PRIMARY KEY` for autoincrement, but SQLC emits Go +# int32 for that type. We temporarily patch schemas to `BIGINT PRIMARY KEY` so +# SQLC generates int64, then restore the originals via the trap. When migrating +# in postgres, the type should be replaced to `BIGINT` to support int64. +echo "Applying SQLite bigint patch..." +for file in "$DIR"/../migrations/*.up.sql; do + echo "Patching $file" + sed -i.original -E 's/INTEGER PRIMARY KEY/BIGINT PRIMARY KEY/g' "$file" +done + +echo "Generating sql models and queries in go..." + +# Generate code via sqlc +sqlc generate -f "$DIR"/sqlc.yaml + +# Because we're using the Postgres dialect of SQLC, we can't use sqlc.slice() +# normally, because SQLC just thinks it can pass the Golang slice directly to +# the database driver. So it doesn't put the /*SLICE:*/ workaround +# comment into the actual SQL query. But we add the comment ourselves and now +# just need to replace the '$X/*SLICE:*/' placeholders with the +# actual placeholder that's going to be replaced by the SQLC generated code. +echo "Applying sqlc.slice() workaround..." +for file in "$DIR"/*.sql.go; do + echo "Patching $file" + + # This sed invocation transforms SQLC placeholders for slices. SQLC writes + # placeholders such as '$1/*SLICE:ids*/' where '$1' is a numeric placeholder + # and 'ids' is the slice name. + # The search pattern looks for a dollar sign and number followed by the + # '/*SLICE:name*/' comment. + # In the pattern: + # \$([0-9]+) captures the number; we ignore this capture. + # /\*SLICE: matches the literal comment start. + # ([a-zA-Z_][a-zA-Z0-9_]*) captures the slice name. + # \*/ matches the end of the comment. + # The replacement rebuilds the comment using the captured name and appends a + # '?' so that makeQueryParams can replace it. + # We pick '#' as the sed delimiter to avoid escaping the slashes in the + # comment markers. + sed -i.original -E "s#\$([0-9]+)/\*SLICE:([a-zA-Z_][a-zA-Z0-9_]*)\*/#/*SLICE:\2*/?#g" "$file" + + # Next we replace the code that uses strings.Repeat to build a list of '?' + # markers for an IN clause with a call to makeQueryParams. + # The search pattern `strings\.Repeat\(",\?", len\(([^)]+)\)\)\[1:\]` + # matches expressions like `strings.Repeat(",?", len(arg.Scids))[1:]`. + # strings\.Repeat\(",\?", len\( matches the literal prefix. + # ([^)]+) captures the expression inside len(), such as arg.Scids. + # \)\)\[1:\] matches the closing brackets and slice notation. + # The replacement `makeQueryParams(len(queryParams), len(\1))` constructs a + # call to our helper using the captured argument and the current parameter + # count. + sed -i.original -E 's/strings\.Repeat\(",\?", len\(([^)]+)\)\)\[1:\]/makeQueryParams(len(queryParams), len(\1))/g' "$file" + + rm "$file.original" +done From a03f65dd6c0eff637cc3070da5caa974dc9fc945 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:52:19 -0300 Subject: [PATCH 312/691] internal: add dummy migration tables --- internal/db/sqldb/migrations/000001__dummy.down.sql | 1 + internal/db/sqldb/migrations/000001__dummy.up.sql | 5 +++++ internal/db/sqldb/sqlc/queries/dummy.sql | 8 ++++++++ 3 files changed, 14 insertions(+) create mode 100644 internal/db/sqldb/migrations/000001__dummy.down.sql create mode 100644 internal/db/sqldb/migrations/000001__dummy.up.sql create mode 100644 internal/db/sqldb/sqlc/queries/dummy.sql diff --git a/internal/db/sqldb/migrations/000001__dummy.down.sql b/internal/db/sqldb/migrations/000001__dummy.down.sql new file mode 100644 index 0000000000..eb3025b868 --- /dev/null +++ b/internal/db/sqldb/migrations/000001__dummy.down.sql @@ -0,0 +1 @@ +DROP TABLE "dummy"; diff --git a/internal/db/sqldb/migrations/000001__dummy.up.sql b/internal/db/sqldb/migrations/000001__dummy.up.sql new file mode 100644 index 0000000000..b9d37859d0 --- /dev/null +++ b/internal/db/sqldb/migrations/000001__dummy.up.sql @@ -0,0 +1,5 @@ +-- Dummy migration to provide initial SQLC material. +-- Should be removed once we have real migrations. +CREATE TABLE "dummy" ( + "id" INTEGER PRIMARY KEY +); diff --git a/internal/db/sqldb/sqlc/queries/dummy.sql b/internal/db/sqldb/sqlc/queries/dummy.sql new file mode 100644 index 0000000000..db969d5e0f --- /dev/null +++ b/internal/db/sqldb/sqlc/queries/dummy.sql @@ -0,0 +1,8 @@ +-- name: GetDummyById :one +SELECT id FROM dummy WHERE id = $1; + +-- name: InsertDummy :exec +INSERT INTO dummy (id) VALUES ($1); + +-- name: DeleteDummy :exec +DELETE FROM dummy WHERE id = $1; From baafb0a0acf4a7298200266387f88efaa99fcb4d Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:55:03 -0300 Subject: [PATCH 313/691] internal: first sqlc run --- internal/db/sqldb/sqlc/db.go | 108 ++++++++++++++++++++++++++++ internal/db/sqldb/sqlc/dummy.sql.go | 38 ++++++++++ internal/db/sqldb/sqlc/models.go | 9 +++ internal/db/sqldb/sqlc/querier.go | 17 +++++ 4 files changed, 172 insertions(+) create mode 100644 internal/db/sqldb/sqlc/db.go create mode 100644 internal/db/sqldb/sqlc/dummy.sql.go create mode 100644 internal/db/sqldb/sqlc/models.go create mode 100644 internal/db/sqldb/sqlc/querier.go diff --git a/internal/db/sqldb/sqlc/db.go b/internal/db/sqldb/sqlc/db.go new file mode 100644 index 0000000000..57245f3902 --- /dev/null +++ b/internal/db/sqldb/sqlc/db.go @@ -0,0 +1,108 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "context" + "database/sql" + "fmt" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +func Prepare(ctx context.Context, db DBTX) (*Queries, error) { + q := Queries{db: db} + var err error + if q.deleteDummyStmt, err = db.PrepareContext(ctx, DeleteDummy); err != nil { + return nil, fmt.Errorf("error preparing query DeleteDummy: %w", err) + } + if q.getDummyByIdStmt, err = db.PrepareContext(ctx, GetDummyById); err != nil { + return nil, fmt.Errorf("error preparing query GetDummyById: %w", err) + } + if q.insertDummyStmt, err = db.PrepareContext(ctx, InsertDummy); err != nil { + return nil, fmt.Errorf("error preparing query InsertDummy: %w", err) + } + return &q, nil +} + +func (q *Queries) Close() error { + var err error + if q.deleteDummyStmt != nil { + if cerr := q.deleteDummyStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteDummyStmt: %w", cerr) + } + } + if q.getDummyByIdStmt != nil { + if cerr := q.getDummyByIdStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getDummyByIdStmt: %w", cerr) + } + } + if q.insertDummyStmt != nil { + if cerr := q.insertDummyStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertDummyStmt: %w", cerr) + } + } + return err +} + +func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) + case stmt != nil: + return stmt.ExecContext(ctx, args...) + default: + return q.db.ExecContext(ctx, query, args...) + } +} + +func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) + case stmt != nil: + return stmt.QueryContext(ctx, args...) + default: + return q.db.QueryContext(ctx, query, args...) + } +} + +func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) + case stmt != nil: + return stmt.QueryRowContext(ctx, args...) + default: + return q.db.QueryRowContext(ctx, query, args...) + } +} + +type Queries struct { + db DBTX + tx *sql.Tx + deleteDummyStmt *sql.Stmt + getDummyByIdStmt *sql.Stmt + insertDummyStmt *sql.Stmt +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + tx: tx, + deleteDummyStmt: q.deleteDummyStmt, + getDummyByIdStmt: q.getDummyByIdStmt, + insertDummyStmt: q.insertDummyStmt, + } +} diff --git a/internal/db/sqldb/sqlc/dummy.sql.go b/internal/db/sqldb/sqlc/dummy.sql.go new file mode 100644 index 0000000000..c8c5fb1bfb --- /dev/null +++ b/internal/db/sqldb/sqlc/dummy.sql.go @@ -0,0 +1,38 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: dummy.sql + +package sqlc + +import ( + "context" +) + +const DeleteDummy = `-- name: DeleteDummy :exec +DELETE FROM dummy WHERE id = $1 +` + +func (q *Queries) DeleteDummy(ctx context.Context, id int64) error { + _, err := q.exec(ctx, q.deleteDummyStmt, DeleteDummy, id) + return err +} + +const GetDummyById = `-- name: GetDummyById :one +SELECT id FROM dummy WHERE id = $1 +` + +func (q *Queries) GetDummyById(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getDummyByIdStmt, GetDummyById, id) + err := row.Scan(&id) + return id, err +} + +const InsertDummy = `-- name: InsertDummy :exec +INSERT INTO dummy (id) VALUES ($1) +` + +func (q *Queries) InsertDummy(ctx context.Context, id int64) error { + _, err := q.exec(ctx, q.insertDummyStmt, InsertDummy, id) + return err +} diff --git a/internal/db/sqldb/sqlc/models.go b/internal/db/sqldb/sqlc/models.go new file mode 100644 index 0000000000..3712e68fff --- /dev/null +++ b/internal/db/sqldb/sqlc/models.go @@ -0,0 +1,9 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +type Dummy struct { + ID int64 +} diff --git a/internal/db/sqldb/sqlc/querier.go b/internal/db/sqldb/sqlc/querier.go new file mode 100644 index 0000000000..5feaa3b077 --- /dev/null +++ b/internal/db/sqldb/sqlc/querier.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "context" +) + +type Querier interface { + DeleteDummy(ctx context.Context, id int64) error + GetDummyById(ctx context.Context, id int64) (int64, error) + InsertDummy(ctx context.Context, id int64) error +} + +var _ Querier = (*Queries)(nil) From 3d54554ae6e31a0f213b5d7645f1f3c46df692cb Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:55:38 -0300 Subject: [PATCH 314/691] Makefile: add sqlc and sqlc-check commands --- Makefile | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Makefile b/Makefile index def43ca079..5a27468d0d 100644 --- a/Makefile +++ b/Makefile @@ -159,6 +159,16 @@ tidy-module: tidy-module-check: tidy-module if test -n "$$(git status --porcelain)"; then echo "modules not updated, please run `make tidy-module` again!"; git status; exit 1; fi +#? sqlc: Generate sql models and queries in Go +sqlc: + @$(call print, "Generating sql models and queries in Go") + $(DOCKER_TOOLS) internal/db/sqldb/sqlc/sqlc_generate.sh + +#? sqlc-check: Make sure sql models and queries are up to date +sqlc-check: sqlc + @$(call print, "Verifying sql code generation.") + if test -n "$$(git status --porcelain '*.go')"; then echo "SQL models not properly generated!"; git status --porcelain '*.go'; exit 1; fi + .PHONY: all \ default \ build \ @@ -172,6 +182,8 @@ tidy-module-check: tidy-module fmt-check \ tidy-module \ tidy-module-check \ + sqlc \ + sqlc-check \ rpc-format \ lint \ lint-config-check \ From a351813bc985810f6f6e8d6eb6fb1bcb572cfb0c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Cardoso Filho Date: Tue, 30 Sep 2025 17:55:51 -0300 Subject: [PATCH 315/691] CI: add SQL models check --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7591492088..bbdd5257c4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -63,6 +63,9 @@ jobs: - name: Check RPC format run: make rpc-check + - name: Check SQL models + run: make sqlc-check + - name: compile code run: go install -v ./... From f6f85c9e2e9cdb467c1772aaf7ec59120ceb59ea Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Wed, 5 Nov 2025 18:36:21 +0000 Subject: [PATCH 316/691] internal: remove non-functional sql files --- .../sqldb/migrations/000001__dummy.down.sql | 1 - .../db/sqldb/migrations/000001__dummy.up.sql | 5 - internal/db/sqldb/sqlc/db.go | 108 ------------------ internal/db/sqldb/sqlc/dummy.sql.go | 38 ------ internal/db/sqldb/sqlc/models.go | 9 -- internal/db/sqldb/sqlc/querier.go | 17 --- internal/db/sqldb/sqlc/queries/dummy.sql | 8 -- 7 files changed, 186 deletions(-) delete mode 100644 internal/db/sqldb/migrations/000001__dummy.down.sql delete mode 100644 internal/db/sqldb/migrations/000001__dummy.up.sql delete mode 100644 internal/db/sqldb/sqlc/db.go delete mode 100644 internal/db/sqldb/sqlc/dummy.sql.go delete mode 100644 internal/db/sqldb/sqlc/models.go delete mode 100644 internal/db/sqldb/sqlc/querier.go delete mode 100644 internal/db/sqldb/sqlc/queries/dummy.sql diff --git a/internal/db/sqldb/migrations/000001__dummy.down.sql b/internal/db/sqldb/migrations/000001__dummy.down.sql deleted file mode 100644 index eb3025b868..0000000000 --- a/internal/db/sqldb/migrations/000001__dummy.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE "dummy"; diff --git a/internal/db/sqldb/migrations/000001__dummy.up.sql b/internal/db/sqldb/migrations/000001__dummy.up.sql deleted file mode 100644 index b9d37859d0..0000000000 --- a/internal/db/sqldb/migrations/000001__dummy.up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Dummy migration to provide initial SQLC material. --- Should be removed once we have real migrations. -CREATE TABLE "dummy" ( - "id" INTEGER PRIMARY KEY -); diff --git a/internal/db/sqldb/sqlc/db.go b/internal/db/sqldb/sqlc/db.go deleted file mode 100644 index 57245f3902..0000000000 --- a/internal/db/sqldb/sqlc/db.go +++ /dev/null @@ -1,108 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package sqlc - -import ( - "context" - "database/sql" - "fmt" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -func Prepare(ctx context.Context, db DBTX) (*Queries, error) { - q := Queries{db: db} - var err error - if q.deleteDummyStmt, err = db.PrepareContext(ctx, DeleteDummy); err != nil { - return nil, fmt.Errorf("error preparing query DeleteDummy: %w", err) - } - if q.getDummyByIdStmt, err = db.PrepareContext(ctx, GetDummyById); err != nil { - return nil, fmt.Errorf("error preparing query GetDummyById: %w", err) - } - if q.insertDummyStmt, err = db.PrepareContext(ctx, InsertDummy); err != nil { - return nil, fmt.Errorf("error preparing query InsertDummy: %w", err) - } - return &q, nil -} - -func (q *Queries) Close() error { - var err error - if q.deleteDummyStmt != nil { - if cerr := q.deleteDummyStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing deleteDummyStmt: %w", cerr) - } - } - if q.getDummyByIdStmt != nil { - if cerr := q.getDummyByIdStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing getDummyByIdStmt: %w", cerr) - } - } - if q.insertDummyStmt != nil { - if cerr := q.insertDummyStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing insertDummyStmt: %w", cerr) - } - } - return err -} - -func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { - switch { - case stmt != nil && q.tx != nil: - return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) - case stmt != nil: - return stmt.ExecContext(ctx, args...) - default: - return q.db.ExecContext(ctx, query, args...) - } -} - -func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { - switch { - case stmt != nil && q.tx != nil: - return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) - case stmt != nil: - return stmt.QueryContext(ctx, args...) - default: - return q.db.QueryContext(ctx, query, args...) - } -} - -func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { - switch { - case stmt != nil && q.tx != nil: - return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) - case stmt != nil: - return stmt.QueryRowContext(ctx, args...) - default: - return q.db.QueryRowContext(ctx, query, args...) - } -} - -type Queries struct { - db DBTX - tx *sql.Tx - deleteDummyStmt *sql.Stmt - getDummyByIdStmt *sql.Stmt - insertDummyStmt *sql.Stmt -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - tx: tx, - deleteDummyStmt: q.deleteDummyStmt, - getDummyByIdStmt: q.getDummyByIdStmt, - insertDummyStmt: q.insertDummyStmt, - } -} diff --git a/internal/db/sqldb/sqlc/dummy.sql.go b/internal/db/sqldb/sqlc/dummy.sql.go deleted file mode 100644 index c8c5fb1bfb..0000000000 --- a/internal/db/sqldb/sqlc/dummy.sql.go +++ /dev/null @@ -1,38 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: dummy.sql - -package sqlc - -import ( - "context" -) - -const DeleteDummy = `-- name: DeleteDummy :exec -DELETE FROM dummy WHERE id = $1 -` - -func (q *Queries) DeleteDummy(ctx context.Context, id int64) error { - _, err := q.exec(ctx, q.deleteDummyStmt, DeleteDummy, id) - return err -} - -const GetDummyById = `-- name: GetDummyById :one -SELECT id FROM dummy WHERE id = $1 -` - -func (q *Queries) GetDummyById(ctx context.Context, id int64) (int64, error) { - row := q.queryRow(ctx, q.getDummyByIdStmt, GetDummyById, id) - err := row.Scan(&id) - return id, err -} - -const InsertDummy = `-- name: InsertDummy :exec -INSERT INTO dummy (id) VALUES ($1) -` - -func (q *Queries) InsertDummy(ctx context.Context, id int64) error { - _, err := q.exec(ctx, q.insertDummyStmt, InsertDummy, id) - return err -} diff --git a/internal/db/sqldb/sqlc/models.go b/internal/db/sqldb/sqlc/models.go deleted file mode 100644 index 3712e68fff..0000000000 --- a/internal/db/sqldb/sqlc/models.go +++ /dev/null @@ -1,9 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package sqlc - -type Dummy struct { - ID int64 -} diff --git a/internal/db/sqldb/sqlc/querier.go b/internal/db/sqldb/sqlc/querier.go deleted file mode 100644 index 5feaa3b077..0000000000 --- a/internal/db/sqldb/sqlc/querier.go +++ /dev/null @@ -1,17 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package sqlc - -import ( - "context" -) - -type Querier interface { - DeleteDummy(ctx context.Context, id int64) error - GetDummyById(ctx context.Context, id int64) (int64, error) - InsertDummy(ctx context.Context, id int64) error -} - -var _ Querier = (*Queries)(nil) diff --git a/internal/db/sqldb/sqlc/queries/dummy.sql b/internal/db/sqldb/sqlc/queries/dummy.sql deleted file mode 100644 index db969d5e0f..0000000000 --- a/internal/db/sqldb/sqlc/queries/dummy.sql +++ /dev/null @@ -1,8 +0,0 @@ --- name: GetDummyById :one -SELECT id FROM dummy WHERE id = $1; - --- name: InsertDummy :exec -INSERT INTO dummy (id) VALUES ($1); - --- name: DeleteDummy :exec -DELETE FROM dummy WHERE id = $1; From 389c03f66d9027cfbf377ae0813a0138b49c1a5e Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Wed, 5 Nov 2025 18:37:47 +0000 Subject: [PATCH 317/691] internal+Makefile: simplify the SQL queries and migrations management --- Makefile | 4 +- internal/db/sqlc/sqlc.yaml | 45 +++++++++++++++ internal/db/sqldb/sqlc/sqlc.yaml | 28 ---------- internal/db/sqldb/sqlc/sqlc_generate.sh | 74 ------------------------- 4 files changed, 47 insertions(+), 104 deletions(-) create mode 100644 internal/db/sqlc/sqlc.yaml delete mode 100644 internal/db/sqldb/sqlc/sqlc.yaml delete mode 100755 internal/db/sqldb/sqlc/sqlc_generate.sh diff --git a/Makefile b/Makefile index 5a27468d0d..aaf745e416 100644 --- a/Makefile +++ b/Makefile @@ -160,9 +160,9 @@ tidy-module-check: tidy-module if test -n "$$(git status --porcelain)"; then echo "modules not updated, please run `make tidy-module` again!"; git status; exit 1; fi #? sqlc: Generate sql models and queries in Go -sqlc: +sqlc: docker-tools @$(call print, "Generating sql models and queries in Go") - $(DOCKER_TOOLS) internal/db/sqldb/sqlc/sqlc_generate.sh + $(DOCKER_TOOLS) sqlc generate -f internal/db/sqlc/sqlc.yaml #? sqlc-check: Make sure sql models and queries are up to date sqlc-check: sqlc diff --git a/internal/db/sqlc/sqlc.yaml b/internal/db/sqlc/sqlc.yaml new file mode 100644 index 0000000000..158d518a3b --- /dev/null +++ b/internal/db/sqlc/sqlc.yaml @@ -0,0 +1,45 @@ +# sqlc configuration file for generating type-safe Go code from SQL. +# For full documentation, see: https://docs.sqlc.dev/en/stable/reference/config.html +version: "2" +sql: + - engine: "postgresql" + schema: "../migrations/postgres" + queries: "../queries/postgres" + gen: + go: + out: "postgres" + package: "sqlcpg" + + # This is the driver package that sqlc will use in the generated code. + sql_package: database/sql + + # Generate a `Querier` interface of all query methods. It's useful for + # mocking in tests. + emit_interface: true + + # Export generated SQL statements so they're usable from other packages. + emit_exported_queries: true + + # Generate prepared statements for better performance with database/sql. + emit_prepared_queries: true + + - engine: "sqlite" + schema: "../migrations/sqlite" + queries: "../queries/sqlite" + gen: + go: + out: "sqlite" + package: "sqlcsqlite" + + # This is the driver package that sqlc will use in the generated code. + sql_package: database/sql + + # Generate a `Querier` interface of all query methods. It's useful for + # mocking in tests. + emit_interface: true + + # Export generated SQL statements so they're usable from other packages. + emit_exported_queries: true + + # Generate prepared statements for better performance with database/sql. + emit_prepared_queries: true diff --git a/internal/db/sqldb/sqlc/sqlc.yaml b/internal/db/sqldb/sqlc/sqlc.yaml deleted file mode 100644 index 738c4cc336..0000000000 --- a/internal/db/sqldb/sqlc/sqlc.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# sqlc configuration file for generating type-safe Go code from SQL. -# For full documentation, see: https://docs.sqlc.dev/en/stable/reference/config.html -version: "2" -sql: - - engine: "postgresql" - schema: "../migrations" - queries: "queries" - gen: - go: - out: . - package: sqlc - - # This is the driver package that sqlc will use in the generated code. - # The default value already is `database/sql`, but we are being - # explicit here that we want multiple database drivers to be supported. - sql_package: database/sql - - # Generate a `Querier` interface of all query methods. It's useful for - # mocking in tests. - emit_interface: true - - # Export generated SQL statements so they’re usable from other packages. - emit_exported_queries: true - - # Use prepared statements to reuse plans and cut parse overhead. - # The pgx driver already does this internally, but for other drivers - # like sqlite this can provide a performance boost. - emit_prepared_queries: true diff --git a/internal/db/sqldb/sqlc/sqlc_generate.sh b/internal/db/sqldb/sqlc/sqlc_generate.sh deleted file mode 100755 index 1bfeb0c69c..0000000000 --- a/internal/db/sqldb/sqlc/sqlc_generate.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash - -set -e - -# Directory of the script file, independent of where it's called from. -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# restore_files restore original schema files. -function restore_files() { - echo "Restoring SQLite bigint patch..." - for file in "$DIR"/../migrations/*.up.sql.original; do - mv "$file" "${file%.original}" - done -} - -# Set trap to call restore_files on script exit. This makes sure the old files -# are always restored. -trap restore_files EXIT - -# SQLite requires `INTEGER PRIMARY KEY` for autoincrement, but SQLC emits Go -# int32 for that type. We temporarily patch schemas to `BIGINT PRIMARY KEY` so -# SQLC generates int64, then restore the originals via the trap. When migrating -# in postgres, the type should be replaced to `BIGINT` to support int64. -echo "Applying SQLite bigint patch..." -for file in "$DIR"/../migrations/*.up.sql; do - echo "Patching $file" - sed -i.original -E 's/INTEGER PRIMARY KEY/BIGINT PRIMARY KEY/g' "$file" -done - -echo "Generating sql models and queries in go..." - -# Generate code via sqlc -sqlc generate -f "$DIR"/sqlc.yaml - -# Because we're using the Postgres dialect of SQLC, we can't use sqlc.slice() -# normally, because SQLC just thinks it can pass the Golang slice directly to -# the database driver. So it doesn't put the /*SLICE:*/ workaround -# comment into the actual SQL query. But we add the comment ourselves and now -# just need to replace the '$X/*SLICE:*/' placeholders with the -# actual placeholder that's going to be replaced by the SQLC generated code. -echo "Applying sqlc.slice() workaround..." -for file in "$DIR"/*.sql.go; do - echo "Patching $file" - - # This sed invocation transforms SQLC placeholders for slices. SQLC writes - # placeholders such as '$1/*SLICE:ids*/' where '$1' is a numeric placeholder - # and 'ids' is the slice name. - # The search pattern looks for a dollar sign and number followed by the - # '/*SLICE:name*/' comment. - # In the pattern: - # \$([0-9]+) captures the number; we ignore this capture. - # /\*SLICE: matches the literal comment start. - # ([a-zA-Z_][a-zA-Z0-9_]*) captures the slice name. - # \*/ matches the end of the comment. - # The replacement rebuilds the comment using the captured name and appends a - # '?' so that makeQueryParams can replace it. - # We pick '#' as the sed delimiter to avoid escaping the slashes in the - # comment markers. - sed -i.original -E "s#\$([0-9]+)/\*SLICE:([a-zA-Z_][a-zA-Z0-9_]*)\*/#/*SLICE:\2*/?#g" "$file" - - # Next we replace the code that uses strings.Repeat to build a list of '?' - # markers for an IN clause with a call to makeQueryParams. - # The search pattern `strings\.Repeat\(",\?", len\(([^)]+)\)\)\[1:\]` - # matches expressions like `strings.Repeat(",?", len(arg.Scids))[1:]`. - # strings\.Repeat\(",\?", len\( matches the literal prefix. - # ([^)]+) captures the expression inside len(), such as arg.Scids. - # \)\)\[1:\] matches the closing brackets and slice notation. - # The replacement `makeQueryParams(len(queryParams), len(\1))` constructs a - # call to our helper using the captured argument and the current parameter - # count. - sed -i.original -E 's/strings\.Repeat\(",\?", len\(([^)]+)\)\)\[1:\]/makeQueryParams(len(queryParams), len(\1))/g' "$file" - - rm "$file.original" -done From 70be7da927e22e4cab844242b9319a4d6db154cd Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Wed, 5 Nov 2025 19:03:39 +0000 Subject: [PATCH 318/691] internal/db: design `blocks` table schema --- .../postgres/000001_blocks.down.sql | 3 + .../migrations/postgres/000001_blocks.up.sql | 17 +++ .../migrations/sqlite/000001_blocks.down.sql | 3 + .../db/migrations/sqlite/000001_blocks.up.sql | 17 +++ internal/db/queries/postgres/blocks.sql | 12 ++ internal/db/queries/sqlite/blocks.sql | 12 ++ internal/db/sqlc/postgres/blocks.sql.go | 49 ++++++++ internal/db/sqlc/postgres/db.go | 108 ++++++++++++++++++ internal/db/sqlc/postgres/models.go | 11 ++ internal/db/sqlc/postgres/querier.go | 17 +++ internal/db/sqlc/sqlite/blocks.sql.go | 49 ++++++++ internal/db/sqlc/sqlite/db.go | 108 ++++++++++++++++++ internal/db/sqlc/sqlite/models.go | 11 ++ internal/db/sqlc/sqlite/querier.go | 17 +++ 14 files changed, 434 insertions(+) create mode 100644 internal/db/migrations/postgres/000001_blocks.down.sql create mode 100644 internal/db/migrations/postgres/000001_blocks.up.sql create mode 100644 internal/db/migrations/sqlite/000001_blocks.down.sql create mode 100644 internal/db/migrations/sqlite/000001_blocks.up.sql create mode 100644 internal/db/queries/postgres/blocks.sql create mode 100644 internal/db/queries/sqlite/blocks.sql create mode 100644 internal/db/sqlc/postgres/blocks.sql.go create mode 100644 internal/db/sqlc/postgres/db.go create mode 100644 internal/db/sqlc/postgres/models.go create mode 100644 internal/db/sqlc/postgres/querier.go create mode 100644 internal/db/sqlc/sqlite/blocks.sql.go create mode 100644 internal/db/sqlc/sqlite/db.go create mode 100644 internal/db/sqlc/sqlite/models.go create mode 100644 internal/db/sqlc/sqlite/querier.go diff --git a/internal/db/migrations/postgres/000001_blocks.down.sql b/internal/db/migrations/postgres/000001_blocks.down.sql new file mode 100644 index 0000000000..1dec60e3be --- /dev/null +++ b/internal/db/migrations/postgres/000001_blocks.down.sql @@ -0,0 +1,3 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if table already dropped or database in unexpected state. +DROP TABLE IF EXISTS blocks; diff --git a/internal/db/migrations/postgres/000001_blocks.up.sql b/internal/db/migrations/postgres/000001_blocks.up.sql new file mode 100644 index 0000000000..f85ce44559 --- /dev/null +++ b/internal/db/migrations/postgres/000001_blocks.up.sql @@ -0,0 +1,17 @@ +-- Block metadata - tracks blocks containing wallet transactions +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE blocks ( + -- Natural key - blockchain height (genesis = 0). + -- The height value itself is immutable (100 is always 100), + -- but during reorgs the block at this height can be replaced + -- (DELETE old row, INSERT new row with same height, different hash). + block_height INTEGER PRIMARY KEY CHECK (block_height >= 0), + + -- Block header hash - unique identifier for this specific block (32 bytes). + header_hash BYTEA NOT NULL UNIQUE CHECK (length(header_hash) = 32), + + -- Unix timestamp - when the block was mined (seconds since epoch). + timestamp BIGINT NOT NULL CHECK (timestamp >= 0) +); diff --git a/internal/db/migrations/sqlite/000001_blocks.down.sql b/internal/db/migrations/sqlite/000001_blocks.down.sql new file mode 100644 index 0000000000..1dec60e3be --- /dev/null +++ b/internal/db/migrations/sqlite/000001_blocks.down.sql @@ -0,0 +1,3 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if table already dropped or database in unexpected state. +DROP TABLE IF EXISTS blocks; diff --git a/internal/db/migrations/sqlite/000001_blocks.up.sql b/internal/db/migrations/sqlite/000001_blocks.up.sql new file mode 100644 index 0000000000..26a6397681 --- /dev/null +++ b/internal/db/migrations/sqlite/000001_blocks.up.sql @@ -0,0 +1,17 @@ +-- Block metadata - tracks blocks containing wallet transactions +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE blocks ( + -- Natural key - blockchain height (genesis = 0). + -- The height value itself is immutable (100 is always 100), + -- but during reorgs the block at this height can be replaced + -- (DELETE old row, INSERT new row with same height, different hash). + block_height INTEGER PRIMARY KEY CHECK (block_height >= 0), + + -- Block header hash - unique identifier for this specific block (32 bytes). + header_hash BLOB NOT NULL UNIQUE CHECK (length(header_hash) = 32), + + -- Unix timestamp - when the block was mined (seconds since epoch). + timestamp INTEGER NOT NULL CHECK (timestamp >= 0) +); diff --git a/internal/db/queries/postgres/blocks.sql b/internal/db/queries/postgres/blocks.sql new file mode 100644 index 0000000000..dc9884ba32 --- /dev/null +++ b/internal/db/queries/postgres/blocks.sql @@ -0,0 +1,12 @@ +-- name: GetBlockByHeight :one +SELECT block_height, header_hash, timestamp +FROM blocks +WHERE block_height = $1; + +-- name: InsertBlock :exec +INSERT INTO blocks (block_height, header_hash, timestamp) +VALUES ($1, $2, $3); + +-- name: DeleteBlock :exec +DELETE FROM blocks +WHERE block_height = $1; diff --git a/internal/db/queries/sqlite/blocks.sql b/internal/db/queries/sqlite/blocks.sql new file mode 100644 index 0000000000..f37744df57 --- /dev/null +++ b/internal/db/queries/sqlite/blocks.sql @@ -0,0 +1,12 @@ +-- name: GetBlockByHeight :one +SELECT block_height, header_hash, timestamp +FROM blocks +WHERE block_height = ?; + +-- name: InsertBlock :exec +INSERT INTO blocks (block_height, header_hash, timestamp) +VALUES (?, ?, ?); + +-- name: DeleteBlock :exec +DELETE FROM blocks +WHERE block_height = ?; diff --git a/internal/db/sqlc/postgres/blocks.sql.go b/internal/db/sqlc/postgres/blocks.sql.go new file mode 100644 index 0000000000..8ca86ef812 --- /dev/null +++ b/internal/db/sqlc/postgres/blocks.sql.go @@ -0,0 +1,49 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: blocks.sql + +package sqlcpg + +import ( + "context" +) + +const DeleteBlock = `-- name: DeleteBlock :exec +DELETE FROM blocks +WHERE block_height = $1 +` + +func (q *Queries) DeleteBlock(ctx context.Context, blockHeight int32) error { + _, err := q.exec(ctx, q.deleteBlockStmt, DeleteBlock, blockHeight) + return err +} + +const GetBlockByHeight = `-- name: GetBlockByHeight :one +SELECT block_height, header_hash, timestamp +FROM blocks +WHERE block_height = $1 +` + +func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) { + row := q.queryRow(ctx, q.getBlockByHeightStmt, GetBlockByHeight, blockHeight) + var i Block + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.Timestamp) + return i, err +} + +const InsertBlock = `-- name: InsertBlock :exec +INSERT INTO blocks (block_height, header_hash, timestamp) +VALUES ($1, $2, $3) +` + +type InsertBlockParams struct { + BlockHeight int32 + HeaderHash []byte + Timestamp int64 +} + +func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error { + _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.Timestamp) + return err +} diff --git a/internal/db/sqlc/postgres/db.go b/internal/db/sqlc/postgres/db.go new file mode 100644 index 0000000000..852f9a65f3 --- /dev/null +++ b/internal/db/sqlc/postgres/db.go @@ -0,0 +1,108 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcpg + +import ( + "context" + "database/sql" + "fmt" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +func Prepare(ctx context.Context, db DBTX) (*Queries, error) { + q := Queries{db: db} + var err error + if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { + return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) + } + if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { + return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) + } + if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { + return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) + } + return &q, nil +} + +func (q *Queries) Close() error { + var err error + if q.deleteBlockStmt != nil { + if cerr := q.deleteBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) + } + } + if q.getBlockByHeightStmt != nil { + if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) + } + } + if q.insertBlockStmt != nil { + if cerr := q.insertBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) + } + } + return err +} + +func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) + case stmt != nil: + return stmt.ExecContext(ctx, args...) + default: + return q.db.ExecContext(ctx, query, args...) + } +} + +func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) + case stmt != nil: + return stmt.QueryContext(ctx, args...) + default: + return q.db.QueryContext(ctx, query, args...) + } +} + +func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) + case stmt != nil: + return stmt.QueryRowContext(ctx, args...) + default: + return q.db.QueryRowContext(ctx, query, args...) + } +} + +type Queries struct { + db DBTX + tx *sql.Tx + deleteBlockStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + insertBlockStmt *sql.Stmt +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + tx: tx, + deleteBlockStmt: q.deleteBlockStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + insertBlockStmt: q.insertBlockStmt, + } +} diff --git a/internal/db/sqlc/postgres/models.go b/internal/db/sqlc/postgres/models.go new file mode 100644 index 0000000000..3570205637 --- /dev/null +++ b/internal/db/sqlc/postgres/models.go @@ -0,0 +1,11 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcpg + +type Block struct { + BlockHeight int32 + HeaderHash []byte + Timestamp int64 +} diff --git a/internal/db/sqlc/postgres/querier.go b/internal/db/sqlc/postgres/querier.go new file mode 100644 index 0000000000..77972c1619 --- /dev/null +++ b/internal/db/sqlc/postgres/querier.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcpg + +import ( + "context" +) + +type Querier interface { + DeleteBlock(ctx context.Context, blockHeight int32) error + GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) + InsertBlock(ctx context.Context, arg InsertBlockParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/sqlc/sqlite/blocks.sql.go b/internal/db/sqlc/sqlite/blocks.sql.go new file mode 100644 index 0000000000..62f6afa0f5 --- /dev/null +++ b/internal/db/sqlc/sqlite/blocks.sql.go @@ -0,0 +1,49 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: blocks.sql + +package sqlcsqlite + +import ( + "context" +) + +const DeleteBlock = `-- name: DeleteBlock :exec +DELETE FROM blocks +WHERE block_height = ? +` + +func (q *Queries) DeleteBlock(ctx context.Context, blockHeight int64) error { + _, err := q.exec(ctx, q.deleteBlockStmt, DeleteBlock, blockHeight) + return err +} + +const GetBlockByHeight = `-- name: GetBlockByHeight :one +SELECT block_height, header_hash, timestamp +FROM blocks +WHERE block_height = ? +` + +func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) { + row := q.queryRow(ctx, q.getBlockByHeightStmt, GetBlockByHeight, blockHeight) + var i Block + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.Timestamp) + return i, err +} + +const InsertBlock = `-- name: InsertBlock :exec +INSERT INTO blocks (block_height, header_hash, timestamp) +VALUES (?, ?, ?) +` + +type InsertBlockParams struct { + BlockHeight int64 + HeaderHash []byte + Timestamp int64 +} + +func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error { + _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.Timestamp) + return err +} diff --git a/internal/db/sqlc/sqlite/db.go b/internal/db/sqlc/sqlite/db.go new file mode 100644 index 0000000000..44f87a934f --- /dev/null +++ b/internal/db/sqlc/sqlite/db.go @@ -0,0 +1,108 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcsqlite + +import ( + "context" + "database/sql" + "fmt" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +func Prepare(ctx context.Context, db DBTX) (*Queries, error) { + q := Queries{db: db} + var err error + if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { + return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) + } + if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { + return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) + } + if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { + return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) + } + return &q, nil +} + +func (q *Queries) Close() error { + var err error + if q.deleteBlockStmt != nil { + if cerr := q.deleteBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) + } + } + if q.getBlockByHeightStmt != nil { + if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) + } + } + if q.insertBlockStmt != nil { + if cerr := q.insertBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) + } + } + return err +} + +func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) + case stmt != nil: + return stmt.ExecContext(ctx, args...) + default: + return q.db.ExecContext(ctx, query, args...) + } +} + +func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) + case stmt != nil: + return stmt.QueryContext(ctx, args...) + default: + return q.db.QueryContext(ctx, query, args...) + } +} + +func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) + case stmt != nil: + return stmt.QueryRowContext(ctx, args...) + default: + return q.db.QueryRowContext(ctx, query, args...) + } +} + +type Queries struct { + db DBTX + tx *sql.Tx + deleteBlockStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + insertBlockStmt *sql.Stmt +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + tx: tx, + deleteBlockStmt: q.deleteBlockStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + insertBlockStmt: q.insertBlockStmt, + } +} diff --git a/internal/db/sqlc/sqlite/models.go b/internal/db/sqlc/sqlite/models.go new file mode 100644 index 0000000000..99e67f9744 --- /dev/null +++ b/internal/db/sqlc/sqlite/models.go @@ -0,0 +1,11 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcsqlite + +type Block struct { + BlockHeight int64 + HeaderHash []byte + Timestamp int64 +} diff --git a/internal/db/sqlc/sqlite/querier.go b/internal/db/sqlc/sqlite/querier.go new file mode 100644 index 0000000000..8f91c3cb17 --- /dev/null +++ b/internal/db/sqlc/sqlite/querier.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlcsqlite + +import ( + "context" +) + +type Querier interface { + DeleteBlock(ctx context.Context, blockHeight int64) error + GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) + InsertBlock(ctx context.Context, arg InsertBlockParams) error +} + +var _ Querier = (*Queries)(nil) From 81f75ef161de46a6a9826078a8a86cd59eb0c652 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 12 Nov 2025 10:00:54 -0300 Subject: [PATCH 319/691] wallet+Makefile: move sql db to wallet/internal --- Makefile | 2 +- .../internal}/db/migrations/postgres/000001_blocks.down.sql | 0 .../internal}/db/migrations/postgres/000001_blocks.up.sql | 0 .../internal}/db/migrations/sqlite/000001_blocks.down.sql | 0 .../internal}/db/migrations/sqlite/000001_blocks.up.sql | 0 {internal => wallet/internal}/db/queries/postgres/blocks.sql | 0 {internal => wallet/internal}/db/queries/sqlite/blocks.sql | 0 {internal => wallet/internal}/db/sqlc/postgres/blocks.sql.go | 0 {internal => wallet/internal}/db/sqlc/postgres/db.go | 0 {internal => wallet/internal}/db/sqlc/postgres/models.go | 0 {internal => wallet/internal}/db/sqlc/postgres/querier.go | 0 {internal => wallet/internal}/db/sqlc/sqlc.yaml | 0 {internal => wallet/internal}/db/sqlc/sqlite/blocks.sql.go | 0 {internal => wallet/internal}/db/sqlc/sqlite/db.go | 0 {internal => wallet/internal}/db/sqlc/sqlite/models.go | 0 {internal => wallet/internal}/db/sqlc/sqlite/querier.go | 0 16 files changed, 1 insertion(+), 1 deletion(-) rename {internal => wallet/internal}/db/migrations/postgres/000001_blocks.down.sql (100%) rename {internal => wallet/internal}/db/migrations/postgres/000001_blocks.up.sql (100%) rename {internal => wallet/internal}/db/migrations/sqlite/000001_blocks.down.sql (100%) rename {internal => wallet/internal}/db/migrations/sqlite/000001_blocks.up.sql (100%) rename {internal => wallet/internal}/db/queries/postgres/blocks.sql (100%) rename {internal => wallet/internal}/db/queries/sqlite/blocks.sql (100%) rename {internal => wallet/internal}/db/sqlc/postgres/blocks.sql.go (100%) rename {internal => wallet/internal}/db/sqlc/postgres/db.go (100%) rename {internal => wallet/internal}/db/sqlc/postgres/models.go (100%) rename {internal => wallet/internal}/db/sqlc/postgres/querier.go (100%) rename {internal => wallet/internal}/db/sqlc/sqlc.yaml (100%) rename {internal => wallet/internal}/db/sqlc/sqlite/blocks.sql.go (100%) rename {internal => wallet/internal}/db/sqlc/sqlite/db.go (100%) rename {internal => wallet/internal}/db/sqlc/sqlite/models.go (100%) rename {internal => wallet/internal}/db/sqlc/sqlite/querier.go (100%) diff --git a/Makefile b/Makefile index aaf745e416..d4829dd895 100644 --- a/Makefile +++ b/Makefile @@ -162,7 +162,7 @@ tidy-module-check: tidy-module #? sqlc: Generate sql models and queries in Go sqlc: docker-tools @$(call print, "Generating sql models and queries in Go") - $(DOCKER_TOOLS) sqlc generate -f internal/db/sqlc/sqlc.yaml + $(DOCKER_TOOLS) sqlc generate -f wallet/internal/db/sqlc/sqlc.yaml #? sqlc-check: Make sure sql models and queries are up to date sqlc-check: sqlc diff --git a/internal/db/migrations/postgres/000001_blocks.down.sql b/wallet/internal/db/migrations/postgres/000001_blocks.down.sql similarity index 100% rename from internal/db/migrations/postgres/000001_blocks.down.sql rename to wallet/internal/db/migrations/postgres/000001_blocks.down.sql diff --git a/internal/db/migrations/postgres/000001_blocks.up.sql b/wallet/internal/db/migrations/postgres/000001_blocks.up.sql similarity index 100% rename from internal/db/migrations/postgres/000001_blocks.up.sql rename to wallet/internal/db/migrations/postgres/000001_blocks.up.sql diff --git a/internal/db/migrations/sqlite/000001_blocks.down.sql b/wallet/internal/db/migrations/sqlite/000001_blocks.down.sql similarity index 100% rename from internal/db/migrations/sqlite/000001_blocks.down.sql rename to wallet/internal/db/migrations/sqlite/000001_blocks.down.sql diff --git a/internal/db/migrations/sqlite/000001_blocks.up.sql b/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql similarity index 100% rename from internal/db/migrations/sqlite/000001_blocks.up.sql rename to wallet/internal/db/migrations/sqlite/000001_blocks.up.sql diff --git a/internal/db/queries/postgres/blocks.sql b/wallet/internal/db/queries/postgres/blocks.sql similarity index 100% rename from internal/db/queries/postgres/blocks.sql rename to wallet/internal/db/queries/postgres/blocks.sql diff --git a/internal/db/queries/sqlite/blocks.sql b/wallet/internal/db/queries/sqlite/blocks.sql similarity index 100% rename from internal/db/queries/sqlite/blocks.sql rename to wallet/internal/db/queries/sqlite/blocks.sql diff --git a/internal/db/sqlc/postgres/blocks.sql.go b/wallet/internal/db/sqlc/postgres/blocks.sql.go similarity index 100% rename from internal/db/sqlc/postgres/blocks.sql.go rename to wallet/internal/db/sqlc/postgres/blocks.sql.go diff --git a/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go similarity index 100% rename from internal/db/sqlc/postgres/db.go rename to wallet/internal/db/sqlc/postgres/db.go diff --git a/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go similarity index 100% rename from internal/db/sqlc/postgres/models.go rename to wallet/internal/db/sqlc/postgres/models.go diff --git a/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go similarity index 100% rename from internal/db/sqlc/postgres/querier.go rename to wallet/internal/db/sqlc/postgres/querier.go diff --git a/internal/db/sqlc/sqlc.yaml b/wallet/internal/db/sqlc/sqlc.yaml similarity index 100% rename from internal/db/sqlc/sqlc.yaml rename to wallet/internal/db/sqlc/sqlc.yaml diff --git a/internal/db/sqlc/sqlite/blocks.sql.go b/wallet/internal/db/sqlc/sqlite/blocks.sql.go similarity index 100% rename from internal/db/sqlc/sqlite/blocks.sql.go rename to wallet/internal/db/sqlc/sqlite/blocks.sql.go diff --git a/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go similarity index 100% rename from internal/db/sqlc/sqlite/db.go rename to wallet/internal/db/sqlc/sqlite/db.go diff --git a/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go similarity index 100% rename from internal/db/sqlc/sqlite/models.go rename to wallet/internal/db/sqlc/sqlite/models.go diff --git a/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go similarity index 100% rename from internal/db/sqlc/sqlite/querier.go rename to wallet/internal/db/sqlc/sqlite/querier.go From 6dc2e00e6e94cad6347c75ce42d785006b54bdad Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 4 Nov 2025 14:07:50 +0800 Subject: [PATCH 320/691] wallet: introduce db layer interfaces --- wallet/internal/db/data_types.go | 886 +++++++++++++++++++++++++++++++ wallet/internal/db/interface.go | 210 ++++++++ 2 files changed, 1096 insertions(+) create mode 100644 wallet/internal/db/data_types.go create mode 100644 wallet/internal/db/interface.go diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go new file mode 100644 index 0000000000..57cf54b2a3 --- /dev/null +++ b/wallet/internal/db/data_types.go @@ -0,0 +1,886 @@ +// Package db provides a database-agnostic interface for wallet data storage, +// defining the core data types and store interfaces for wallets, accounts, +// addresses, transactions, and UTXOs. +package db + +import ( + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" +) + +// ============================================================================ +// Data Types & Method Parameters +// ============================================================================ + +// KeyScope represents the BIP-44 key scope as defined in BIP-43. It is used +// to organize keys based on their purpose and coin type, providing a +// hierarchical structure for key derivation. +type KeyScope struct { + // Purpose is the purpose number for the scope, as defined in BIP-43. + Purpose uint32 + + // Coin is the coin type number for the scope, as defined in BIP-44. + Coin uint32 +} + +// AddressType specifies the type of a managed address. This is used to +// identify the script type of an address, such as P2PKH, P2SH, P2WKH, etc. +type AddressType uint8 + +const ( + // PubKeyHash represents a pay-to-pubkey-hash (P2PKH) address. + PubKeyHash AddressType = iota + + // ScriptHash represents a pay-to-script-hash (P2SH) address. + ScriptHash + + // WitnessPubKey represents a pay-to-witness-pubkey-hash (P2WKH) + // address. + WitnessPubKey + + // NestedWitnessPubKey represents a P2WKH output nested within a P2SH + // address. + NestedWitnessPubKey + + // TaprootPubKey represents a pay-to-taproot (P2TR) address. + TaprootPubKey +) + +const ( + // BIP0044Purpose is the purpose field for BIP0044 derivation. + BIP0044Purpose = 44 + + // BIP0049Purpose is the purpose field for BIP0049 derivation. + BIP0049Purpose = 49 + + // BIP0084Purpose is the purpose field for BIP0084 derivation. + BIP0084Purpose = 84 + + // BIP0086Purpose is the purpose field for BIP0086 derivation. + BIP0086Purpose = 86 +) + +var ( + // KeyScopeBIP0049Plus is the key scope of our modified BIP0049 + // derivation. We say this is BIP0049 "plus", as it acts as an + // optimization for fee savings. Standard BIP0049 uses P2SH-wrapped + // SegWit for both external (receive) and internal (change) addresses. + // This scheme uses P2SH-wrapped SegWit for external addresses (to + // ensure backward compatibility with senders using legacy wallets) but + // uses Native SegWit (P2WKH) for internal change addresses. Since the + // wallet controls its own change, it can use the more efficient Native + // SegWit format to reduce transaction weight and save on fees when + // spending that change later. + KeyScopeBIP0049Plus = KeyScope{ + Purpose: BIP0049Purpose, + Coin: 0, + } + + // KeyScopeBIP0084 is the key scope for BIP0084 derivation. BIP0084 + // will be used to derive all p2wkh addresses. + KeyScopeBIP0084 = KeyScope{ + Purpose: BIP0084Purpose, + Coin: 0, + } + + // KeyScopeBIP0086 is the key scope for BIP0086 derivation. BIP0086 + // will be used to derive all p2tr addresses. + KeyScopeBIP0086 = KeyScope{ + Purpose: BIP0086Purpose, + Coin: 0, + } + + // KeyScopeBIP0044 is the key scope for BIP0044 derivation. Legacy + // wallets will only be able to use this key scope, and no keys beyond + // it. + KeyScopeBIP0044 = KeyScope{ + Purpose: BIP0044Purpose, + Coin: 0, + } + + // ScopeAddrMap is a map from the default key scopes to the scope + // address schema for each scope type. + ScopeAddrMap = map[KeyScope]ScopeAddrSchema{ + KeyScopeBIP0049Plus: { + ExternalAddrType: NestedWitnessPubKey, + InternalAddrType: WitnessPubKey, + }, + KeyScopeBIP0084: { + ExternalAddrType: WitnessPubKey, + InternalAddrType: WitnessPubKey, + }, + KeyScopeBIP0086: { + ExternalAddrType: TaprootPubKey, + InternalAddrType: TaprootPubKey, + }, + KeyScopeBIP0044: { + InternalAddrType: PubKeyHash, + ExternalAddrType: PubKeyHash, + }, + } +) + +// Tapscript represents a Taproot script leaf, which includes the script itself +// and its corresponding control block. This is used for spending Taproot +// outputs. +type Tapscript struct { + // ControlBlock is the control block for the Taproot script, which is + // required to reveal the script path during spending. + ControlBlock []byte + + // Script is the actual script code of the Taproot leaf. + Script []byte +} + +// -------------------- +// WalletStore Types +// -------------------- + +// WalletInfo contains the static properties of a wallet. This struct provides a +// summary of the wallet's configuration and state. +type WalletInfo struct { + // ID is the unique identifier for the wallet. + // + // NOTE: This is a uint32 rather than a uint64 to ensure compatibility + // with standard SQL databases (PostgreSQL, SQLite) which typically use + // signed 64-bit integers for their BIGINT/INTEGER types. A uint64 can + // overflow a signed 64-bit integer, whereas a uint32 fits comfortably. + ID uint32 + + // Name is the human-readable name of the wallet. + Name string + + // IsImported indicates whether the wallet was created from an existing + // seed or was created as a new wallet. + IsImported bool + + // IsWatchOnly indicates whether the wallet is in watch-only mode, + // meaning it does not have private keys and cannot sign transactions. + IsWatchOnly bool + + // Birthday is the timestamp of the wallet's creation, used as a + // starting point for rescans. + Birthday time.Time + + // BirthdayBlock is the block hash and height from which to start a + // rescan. + BirthdayBlock Block + + // SyncedTo represents the wallet's current synchronization state with + // the blockchain. + SyncedTo *Block +} + +// Block defines a block's hash, height, and timestamp. This is used to +// represent a block's identity and position in the blockchain. +type Block struct { + // Hash is the 32-byte hash of the block. + Hash chainhash.Hash + + // Height is the height of the block in the blockchain. + Height uint32 + + // Timestamp is the timestamp of the block, which is used for wallet + // synchronization and rescan operations. + Timestamp time.Time +} + +// CreateWalletParams contains the parameters required to create a new wallet. +type CreateWalletParams struct { + // Name is the name of the new wallet. + Name string + + // IsImported should be set to true if the wallet is being created from + // an existing seed. + IsImported bool + + // IsWatchOnly indicates whether the wallet is being created in + // watch-only mode. + IsWatchOnly bool + + // EncryptedMasterPrivKey is the encrypted master HD private key. + EncryptedMasterPrivKey []byte + + // EncryptedMasterPubKey is the encrypted master HD public key. + EncryptedMasterPubKey []byte + + // MasterKeyPubParams are the parameters (e.g. salt, scrypt N/R/P) used + // to derive the master public key. + MasterKeyPubParams []byte + + // MasterKeyPrivParams are the parameters (e.g. salt, scrypt N/R/P) used + // to derive the master private key. + MasterKeyPrivParams []byte + + // EncryptedCryptoPrivKey is the encrypted private crypto key, used to + // protect private keys in the database. + EncryptedCryptoPrivKey []byte + + // EncryptedCryptoPubKey is the encrypted public crypto key, used to + // protect public keys in the database. + EncryptedCryptoPubKey []byte + + // EncryptedCryptoScriptKey is the encrypted script crypto key, used to + // protect scripts in the database. + EncryptedCryptoScriptKey []byte +} + +// UpdateWalletParams contains the parameters for updating a wallet's +// properties. Fields are pointers to allow for partial updates. +type UpdateWalletParams struct { + // WalletID is the ID of the wallet to update. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Birthday is the new birthday for the wallet. + Birthday *time.Time + + // BirthdayBlock is the new birthday block for the wallet. + BirthdayBlock *Block + + // SyncedTo is the new synchronization state for the wallet. + SyncedTo *Block +} + +// ChangePassphraseParams contains the parameters for changing a wallet's +// passphrase. +type ChangePassphraseParams struct { + // WalletID is the ID of the wallet to update. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // OldPassphrase is the current passphrase. + OldPassphrase []byte + + // NewPassphrase is the new passphrase to set. + NewPassphrase []byte + + // IsPrivate specifies whether to change the private (true) or public + // (false) passphrase. + IsPrivate bool +} + +// -------------------- +// AccountStore Types +// -------------------- + +// AccountInfo contains all information about a single account, including its +// properties and balances. +type AccountInfo struct { + // AccountNumber is the unique identifier for the account. + AccountNumber uint32 + + // AccountName is the human-readable name of the account. + AccountName string + + // ExternalKeyCount is the number of external keys that have been + // derived. + ExternalKeyCount uint32 + + // InternalKeyCount is the number of internal (change) keys that have + // been derived. + InternalKeyCount uint32 + + // ImportedKeyCount is the number of imported keys in the account. + ImportedKeyCount uint32 + + // ConfirmedBalance is the total balance of the account from confirmed + // transactions. + ConfirmedBalance btcutil.Amount + + // UnconfirmedBalance is the total balance of the account from + // unconfirmed transactions. + UnconfirmedBalance btcutil.Amount + + // IsWatchOnly indicates whether the account is in watch-only mode. + IsWatchOnly bool + + // KeyScope is the key scope the account belongs to. This determines the + // derivation path and the default address schema. + KeyScope KeyScope +} + +// ScopeAddrSchema is the address schema of a particular KeyScope. This will be +// persisted within the database, and will be consulted when deriving any keys +// for a particular scope to know how to encode the public keys as addresses. +type ScopeAddrSchema struct { + // ExternalAddrType is the address type for all keys within branch 0. + ExternalAddrType AddressType + + // InternalAddrType is the address type for all keys within branch 1 + // (change addresses). + InternalAddrType AddressType +} + +// CreateAccountParams contains the parameters for creating a new account. +type CreateAccountParams struct { + // WalletID is the ID of the wallet to create the account in. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Scope is the key scope for the new account. + Scope KeyScope + + // Name is the name of the new account. + Name string +} + +// ImportAccountParams contains the data required to import an account from an +// extended key. +type ImportAccountParams struct { + // WalletID is the ID of the wallet to import the account into. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Name is the name of the account to import. + Name string + + // AccountKey is the extended key for the account. + AccountKey *hdkeychain.ExtendedKey + + // MasterKeyFingerprint is the fingerprint of the master key. + MasterKeyFingerprint uint32 + + // Scope is the key scope for the account. The address schema for the + // account will be determined by the default mapping for this scope. + Scope KeyScope +} + +// ImportAccountResult holds the results of an account import operation. +type ImportAccountResult struct { + // AccountProperties contains the properties of the imported account. + AccountProperties *AccountProperties + + // ExternalAddrs contains the derived external addresses if the import + // was a dry run. + ExternalAddrs []AddressInfo + + // InternalAddrs contains the derived internal addresses if the import + // was a dry run. + InternalAddrs []AddressInfo +} + +// AccountProperties contains properties associated with each account, such as +// the account name, number, and the nubmer of derived and imported keys. +type AccountProperties struct { + // AccountNumber is the internal number used to reference the account. + AccountNumber uint32 + + // AccountName is the user-identifying name of the account. + AccountName string + + // ExternalKeyCount is the number of internal keys that have been + // derived for the account. + ExternalKeyCount uint32 + + // InternalKeyCount is the number of internal keys that have been + // derived for the account. + InternalKeyCount uint32 + + // ImportedKeyCount is the number of imported keys found within the + // account. + ImportedKeyCount uint32 + + // AccountPubKey is the account's public key that can be used to + // derive any address relevant to said account. + // + // NOTE: This may be nil for imported accounts. + AccountPubKey *hdkeychain.ExtendedKey + + // MasterKeyFingerprint represents the fingerprint of the root key + // corresponding to the master public key (also known as the key with + // derivation path m/). This may be required by some hardware wallets + // for proper identification and signing. + MasterKeyFingerprint uint32 + + // KeyScope is the key scope the account belongs to. + KeyScope KeyScope + + // IsWatchOnly indicates whether the is set up as watch-only, i.e., it + // doesn't contain any private key information. + IsWatchOnly bool + + // AddrSchema, if non-nil, specifies an address schema override for + // address generation only applicable to the account. + AddrSchema *ScopeAddrSchema +} + +// GetAccountQuery contains the parameters for querying a single account. The +// query must specify either the account name or the account number. Using +// pointers for these fields allows the query to be unambiguous, as a nil value +// indicates that the field should not be used for filtering. This avoids the +// "zero value" problem, where 0 or an empty string could be valid query +// targets. +type GetAccountQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Scope is the key scope of the account. + Scope KeyScope + + // Name is the name of the account to query. If nil, the query will be + // performed using the AccountNumber. + Name *string + + // AccountNumber is the number of the account to query. If nil, the + // query will be performed using the Name. + AccountNumber *uint32 +} + +// ListAccountsQuery holds the set of options for a ListAccounts query. +type ListAccountsQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Scope is an optional filter to list accounts only for a specific key + // scope. + Scope *KeyScope + + // Name is an optional filter to list accounts only with a specific + // name. + Name *string +} + +// RenameAccountParams contains the parameters for renaming an account. The +// account can be identified by either its old name or its account number. +type RenameAccountParams struct { + // WalletID is the ID of the wallet containing the account. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Scope is the key scope of the account. + Scope KeyScope + + // OldName is the current name of the account. This is used to identify + // the account if AccountNumber is not provided. + OldName string + + // AccountNumber is the number of the account to rename. This is used + // to identify the account if OldName is not provided. + AccountNumber *uint32 + + // NewName is the new name for the account. + NewName string +} + +// -------------------- +// AddressStore Types +// -------------------- + +// AddressInfo represents a wallet-managed address, including its properties and +// derivation information. +type AddressInfo struct { + // Address is the human-readable address string. + Address btcutil.Address + + // Internal indicates whether the address is for internal (change) use. + Internal bool + + // Compressed indicates whether the address is compressed. + Compressed bool + + // Used indicates whether the address has been used in a transaction. + Used bool + + // IsWatchOnly indicates whether the wallet has the private key for + // this address. + IsWatchOnly bool + + // AddrType is the type of the address (P2PKH, P2SH, etc.). + AddrType AddressType + + // DerivationInfo contains the BIP-32 derivation path information for + // the address. This will be nil for imported addresses that are not + // part of an HD account. + DerivationInfo *DerivationInfo + + // Script is the script associated with the address, if any. + Script []byte +} + +// NewAddressParams contains the parameters for creating a new address. +type NewAddressParams struct { + // WalletID is the ID of the wallet to create the address in. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // AccountName is the name of the account to create the address for. + AccountName string + + // Scope is the key scope for the new address. + Scope KeyScope + + // Change indicates whether to create a change address (true) or an + // external address (false). + Change bool +} + +// ImportAddressParams encapsulates all the data needed to store a new, imported +// address, script, or private key. All imported addresses are automatically +// assigned to the wallet's logical "imported" account. The presence of a +// private key determines whether the address will be spendable or watch-only. +type ImportAddressParams struct { + // WalletID is the ID of the wallet to import the address into. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // PrivateKey is the private key to import, in WIF format. If this is + // provided, the address will be spendable. If nil, the import will be + // watch-only. + PrivateKey *btcutil.WIF + + // PubKey is the public key to import for a watch-only address. This + // field is only used if PrivateKey is nil. + PubKey *btcec.PublicKey + + // Tapscript is the Taproot script to import for a watch-only address. + // This field is only used if PrivateKey is nil. + Tapscript *Tapscript + + // Script is the generic script to import for a watch-only address. + // This field is only used if PrivateKey is nil. + Script []byte +} + +// GetPrivateKeyParams contains the parameters for retrieving a private key. +type GetPrivateKeyParams struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Address is the address for which to retrieve the private key. + Address btcutil.Address +} + +// GetAddressQuery contains the parameters for querying an address. +type GetAddressQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Address is the address to query. + Address btcutil.Address +} + +// ListAddressesQuery contains the parameters for listing addresses. +type ListAddressesQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // AccountName is the name of the account to list addresses for. + AccountName string + + // Scope is the key scope of the account. + Scope KeyScope +} + +// MarkAddressAsUsedParams contains the parameters for marking an address as +// used. +type MarkAddressAsUsedParams struct { + // WalletID is the ID of the wallet containing the address. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Address is the address to mark as used. + Address btcutil.Address +} + +// DerivationInfo contains the BIP-32 derivation path information for a key. +type DerivationInfo struct { + // KeyScope is the key scope of the derivation path. + KeyScope KeyScope + + // MasterKeyFingerprint is the fingerprint of the master key. + MasterKeyFingerprint uint32 + + // Account is the account number of the derivation path. + Account uint32 + + // Branch is the branch number of the derivation path (0 for external, + // 1 for internal). + Branch uint32 + + // Index is the index of the key in the branch. + Index uint32 +} + +// -------------------- +// TxStore Types +// -------------------- + +// TxInfo represents the details of a transaction relevant to the wallet. +type TxInfo struct { + // Hash is the transaction hash. + Hash chainhash.Hash + + // SerializedTx is the serialized transaction. + SerializedTx []byte + + // Received is the timestamp when the transaction was received. + Received time.Time + + // Block contains metadata about the block that includes the + // transaction. This will be nil for unmined (unconfirmed) transactions + // and non-nil for mined (confirmed) transactions. + Block *Block + + // Label is a user-defined label for the transaction. + Label string +} + +// CreateTxParams contains the parameters for creating a new transaction record. +type CreateTxParams struct { + // WalletID is the ID of the wallet to create the transaction in. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Tx is the transaction to record. + Tx *wire.MsgTx + + // Label is an optional label for the transaction. + Label string + + // Credits lists the outputs of the transaction that are controlled by + // the wallet. + Credits []CreditData +} + +// CreditData contains the information needed to record a transaction credit. +// It acts as an explicit instruction to the CreateTx method, identifying which +// of the transaction's outputs belongs to the wallet and should be recorded as +// a new UTXO. This serves as a performance optimization, preventing the +// database layer from needing to parse every transaction output and query the +// address manager to determine ownership. +type CreditData struct { + // Index is the output index of the credit. + Index uint32 + + // Address is the address that received the credit. + Address btcutil.Address +} + +// UpdateTxParams contains the parameters for updating a transaction record. +// Fields are pointers to allow for partial updates. +type UpdateTxParams struct { + // WalletID is the ID of the wallet containing the transaction. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Txid is the hash of the transaction to update. + Txid chainhash.Hash + + // Block is the new block metadata for the transaction. + Block *Block + + // Label is the new label for the transaction. + Label *string +} + +// GetTxQuery contains the parameters for querying a transaction. While a +// transaction hash (TxHash) is globally unique on the blockchain, the WalletID +// is necessary to retrieve wallet-specific metadata (e.g., labels, credits, +// debits) associated with that transaction. In a multi-wallet database, the +// same transaction might be relevant to multiple wallets, but its context +// (e.g., whether it's a credit or debit, and any custom labels) will differ +// for each wallet. The WalletID ensures the query returns the transaction's +// details from the correct wallet's perspective. +type GetTxQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Txid is the hash of the transaction to query. + Txid chainhash.Hash +} + +// ListTxnsQuery contains the parameters for listing transactions. +type ListTxnsQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // StartHeight is the starting block height for the query. + StartHeight uint32 + + // EndHeight is the ending block height for the query. + EndHeight uint32 + + // UnminedOnly, if true, will return only unmined (unconfirmed) + // transactions. If this is set, StartHeight and EndHeight will be + // ignored. + UnminedOnly bool +} + +// DeleteTxParams contains the parameters for the DeleteTx method. +type DeleteTxParams struct { + // WalletID is the ID of the wallet containing the transaction. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Txid is the hash of the transaction to delete. + Txid chainhash.Hash +} + +// -------------------- +// UTXOStore Types +// -------------------- + +// UtxoInfo represents an unspent transaction output (UTXO). +type UtxoInfo struct { + // OutPoint is the outpoint of the UTXO. + OutPoint wire.OutPoint + + // Amount is the value of the UTXO. + Amount btcutil.Amount + + // PkScript is the public key script of the UTXO. + PkScript []byte + + // Received is the timestamp when the UTXO was received. + Received time.Time + + // FromCoinBase indicates whether the UTXO is from a coinbase + // transaction. + FromCoinBase bool + + // Height is the block height of the UTXO. + Height uint32 +} + +// GetUtxoQuery contains the parameters for querying a UTXO. +type GetUtxoQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // OutPoint is the outpoint of the UTXO to query. + OutPoint wire.OutPoint +} + +// ListUtxosQuery holds the set of options for a ListUTXOs query. +type ListUtxosQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Account is an optional filter to list UTXOs only for a specific + // account. + Account *uint32 + + // MinConfs is the minimum number of confirmations for a UTXO to be + // included. + MinConfs int32 + + // MaxConfs is the maximum number of confirmations for a UTXO to be + // included. + MaxConfs int32 +} + +// LeaseOutputParams contains the parameters for leasing a UTXO. +type LeaseOutputParams struct { + // WalletID is the ID of the wallet containing the UTXO. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // ID is the lock ID for the UTXO. + ID [32]byte + + // OutPoint is the outpoint of the UTXO to lock. + OutPoint wire.OutPoint + + // Duration is the duration to lock the UTXO for. + Duration time.Duration +} + +// ReleaseOutputParams contains the parameters for releasing a UTXO lease. +type ReleaseOutputParams struct { + // WalletID is the ID of the wallet containing the UTXO. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // ID is the lock ID of the UTXO to unlock. + ID [32]byte + + // OutPoint is the outpoint of the UTXO to unlock. + OutPoint wire.OutPoint +} + +// LeasedOutput represents a UTXO that is currently locked. +type LeasedOutput struct { + // OutPoint is the outpoint of the locked UTXO. + OutPoint wire.OutPoint + + // LockID is the ID of the lock. + LockID LockID + + // Expiration is the time when the lock expires. + Expiration time.Time +} + +// BalanceParams contains the parameters for the Balance method. +type BalanceParams struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // MinConfirms is the minimum number of confirmations a UTXO must have + // to be included in the balance calculation. + MinConfirms int32 +} + +// LockID represents a unique context-specific ID assigned to an output lock. +type LockID [32]byte diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go new file mode 100644 index 0000000000..cfde710641 --- /dev/null +++ b/wallet/internal/db/interface.go @@ -0,0 +1,210 @@ +package db + +import ( + "context" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" +) + +// WalletStore defines the methods for wallet-level operations. +type WalletStore interface { + // CreateWallet creates a new wallet in the database with the provided + // parameters. It returns the ID of the newly created wallet or an error + // if the creation fails. + CreateWallet(ctx context.Context, params CreateWalletParams) ( + *WalletInfo, error) + + // GetWallet retrieves information about a wallet given its name. It + // returns a WalletInfo struct containing the wallet's properties or an + // error if the wallet is not found. + GetWallet(ctx context.Context, name string) (*WalletInfo, error) + + // ListWallets returns a slice of WalletInfo for all wallets stored in + // the database. It returns an empty slice if no wallets are found, or + // an error if the retrieval fails. + ListWallets(ctx context.Context) ([]WalletInfo, error) + + // UpdateWallet updates various properties of a wallet, such as its + // birthday, birthday block, or sync state. The specific fields to + // update are provided in the UpdateWalletParams struct. It returns an + // error if the update fails. + UpdateWallet(ctx context.Context, params UpdateWalletParams) error + + // GetEncryptedHDSeed retrieves the encrypted Hierarchical + // Deterministic (HD) seed of the wallet. This seed is sensitive + // information and is returned in its encrypted form. It returns the + // encrypted seed as a byte slice or an error if the retrieval fails. + GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) + + // ChangePassphrase changes the passphrase for the wallet. It takes the + // old and new passphrases as byte slices, and a boolean indicating + // whether to change the private passphrase (true) or the public + // passphrase (false). It returns an error if the passphrase change + // fails (e.g., incorrect old passphrase). + ChangePassphrase(ctx context.Context, + params ChangePassphraseParams) error +} + +// AccountStore defines the database actions for managing accounts. +type AccountStore interface { + // CreateAccount creates a new account with the given name and scope. It + // returns the properties of the newly created account or an error if + // the + // creation fails. + CreateAccount(ctx context.Context, params CreateAccountParams) ( + *AccountInfo, error) + + // ImportAccount imports an account from an extended key. This method + // supports normal imports, imports with a specific scope, and dry-run + // imports. The behavior is controlled by the fields in the + // ImportAccountParams struct. It returns the properties of the imported + // account and derived addresses (for dry runs) or an error if the + // import fails. The returned addresses are of type AddressInfo. + ImportAccount(ctx context.Context, params ImportAccountParams) ( + *ImportAccountResult, error) + + // GetAccount retrieves information about a specific account, + // identified by its name or account number within a given key scope. + // It returns an AccountInfo struct containing the account's properties + // or an error if the account is not found. + GetAccount(ctx context.Context, query GetAccountQuery) ( + *AccountInfo, error) + + // ListAccounts returns a slice of AccountInfo for all accounts, + // optionally filtered by name or key scope. It returns an empty slice + // if no accounts are found. + ListAccounts(ctx context.Context, query ListAccountsQuery) ( + []AccountInfo, error) + + // RenameAccount changes the name of an account. The account can be + // identified by its old name or its account number. It returns an + // error if the renaming fails. + RenameAccount(ctx context.Context, params RenameAccountParams) error +} + +// AddressStore defines the database actions for managing addresses. +type AddressStore interface { + // NewAddress creates a new address for a given account and key scope. + // It returns the newly created address or an error if the creation + // fails. + NewAddress(ctx context.Context, params NewAddressParams) ( + btcutil.Address, error) + + // ImportAddress imports a new address, script, or private key. If a + // private key is provided in the parameters, the address will be + // spendable. Otherwise, it will be imported as watch-only. It returns + // information about the imported address or an error if the import + // fails. + ImportAddress(ctx context.Context, params ImportAddressParams) ( + *AddressInfo, error) + + // GetAddress retrieves information about a specific address. It + // returns an AddressInfo struct containing the address's properties or + // an error if the address is not found. + GetAddress(ctx context.Context, query GetAddressQuery) ( + *AddressInfo, error) + + // ListAddresses returns a slice of AddressInfo for all addresses in a + // given account. It returns an empty slice if no addresses are found. + ListAddresses(ctx context.Context, query ListAddressesQuery) ( + []AddressInfo, error) + + // MarkAddressAsUsed marks a given address as used. This is used to + // ensure that the address is not reused. + MarkAddressAsUsed(ctx context.Context, + params MarkAddressAsUsedParams) error + + // GetPrivateKey retrieves the private key for a given address. This + // method is ONLY valid for addresses that were imported with a private + // key. It will return an error for derived HD addresses and watch-only + // imports. + GetPrivateKey(ctx context.Context, params GetPrivateKeyParams) ( + *btcec.PrivateKey, error) +} + +// TxStore defines the database actions for managing transaction records. +type TxStore interface { + // CreateTx atomically records a transaction and its associated credits + // in the database. This is a single atomic operation that also handles + // the corresponding UTXO state changes: it will delete any UTXOs spent + // by the new transaction's inputs and create new UTXOs for any of its + // outputs that are spendable by the wallet. This ensures that the + // transaction record and the UTXO set are always consistent. + CreateTx(ctx context.Context, params CreateTxParams) error + + // UpdateTx updates an existing transaction record in the database. It + // takes a context and UpdateTxParams, returning an error if the + // transaction cannot be found or updated. + UpdateTx(ctx context.Context, params UpdateTxParams) error + + // GetTx retrieves a transaction record by its hash. It takes a context + // and GetTxQuery, returning a TxInfo struct or an error if the + // transaction is not found. Note that the `Credits` and `Debits` fields + // of the returned `TxInfo` are not stored directly in the transaction + // record; they are derived by querying the UTXO store and represent + // wallet-specific information about the transaction's impact on the + // UTXO set. + GetTx(ctx context.Context, query GetTxQuery) (*TxInfo, error) + + // ListTxns returns a slice of transaction information based on the + // provided query parameters. It takes a context and ListTxnsQuery, + // returning a slice of TxInfo or an error if the retrieval fails. + ListTxns(ctx context.Context, query ListTxnsQuery) ([]TxInfo, error) + + // DeleteTx removes an unmined transaction from the store. It takes a + // context and DeleteTxParams, returning an error if the transaction is + // not found or the deletion fails. + DeleteTx(ctx context.Context, params DeleteTxParams) error + + // RollbackToBlock removes all blocks at and after a given height, + // moving any transactions within those blocks back to the unconfirmed + // pool. This operation is performed as a single, atomic database + // transaction to ensure data integrity. Breaking it into smaller, + // separate interface methods would risk leaving the database in an + // inconsistent state if an error occurred mid-process. The current + // approach guarantees that the rollback is either fully completed or + // not at all. + // + // TODO(yy): explore performance improvement for this method. + RollbackToBlock(ctx context.Context, height uint32) error +} + +// UTXOStore defines the database actions for managing the UTXO set. +type UTXOStore interface { + // GetUtxo retrieves a single unspent transaction output (UTXO) by its + // outpoint. It returns a UtxoInfo struct containing the UTXO's details + // or an error if the UTXO is not found or has been spent. + GetUtxo(ctx context.Context, query GetUtxoQuery) (*UtxoInfo, error) + + // ListUTXOs returns a slice of all unspent transaction outputs (UTXOs) + // that match the provided query parameters. This can be used to list + // all UTXOs or filter them by account or confirmation status. + ListUTXOs(ctx context.Context, query ListUtxosQuery) ([]UtxoInfo, error) + + // LeaseOutput locks a specific UTXO for a given duration, preventing + // it from being used in coin selection. This is useful for reserving + // UTXOs for a specific purpose, such as a pending transaction. The + // method returns the full lease information, including its expiration + // time. + LeaseOutput(ctx context.Context, params LeaseOutputParams) ( + *LeasedOutput, error) + + // ReleaseOutput unlocks a previously leased UTXO, making it available + // for coin selection again. The lock ID must match the one used to + // lease the output. + ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error + + // ListLeasedOutputs returns a slice of all currently leased UTXOs. + // This can be used to inspect which outputs are currently locked and + // when their leases expire. + ListLeasedOutputs(ctx context.Context, walletID uint32) ( + []LeasedOutput, error) + + // Balance returns the total spendable balance of the wallet, + // calculated from the UTXO set. The minConfirms parameter specifies + // the minimum number of confirmations a UTXO must have to be included + // in the balance calculation. + Balance(ctx context.Context, params BalanceParams) ( + btcutil.Amount, error) +} From f1c510436ef9f40b6c042af11b4f1189d61e5bb3 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 22 Nov 2025 13:04:21 -0300 Subject: [PATCH 321/691] wallet: add ManagerVersion to db WalletInfo --- wallet/internal/db/data_types.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 57cf54b2a3..51e97201ea 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -159,6 +159,10 @@ type WalletInfo struct { // seed or was created as a new wallet. IsImported bool + // ManagerVersion is the version of the wallet manager that created this + // wallet. + ManagerVersion int32 + // IsWatchOnly indicates whether the wallet is in watch-only mode, // meaning it does not have private keys and cannot sign transactions. IsWatchOnly bool @@ -199,6 +203,10 @@ type CreateWalletParams struct { // an existing seed. IsImported bool + // ManagerVersion is the version of the wallet manager that created this + // wallet. + ManagerVersion int32 + // IsWatchOnly indicates whether the wallet is being created in // watch-only mode. IsWatchOnly bool From 50924906bb9221a874f464427a1a4ba7c6710f51 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 22 Nov 2025 13:06:40 -0300 Subject: [PATCH 322/691] wallet: refactor ChangePassphrase to UpdateWalletSecrets on db interface --- wallet/internal/db/data_types.go | 25 +++++++++++++++---------- wallet/internal/db/interface.go | 10 +++------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 51e97201ea..b6ce94e3bf 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -257,24 +257,29 @@ type UpdateWalletParams struct { SyncedTo *Block } -// ChangePassphraseParams contains the parameters for changing a wallet's -// passphrase. -type ChangePassphraseParams struct { +// UpdateWalletSecretsParams contains the parameters for updating a wallet's +// secrets. +type UpdateWalletSecretsParams struct { // WalletID is the ID of the wallet to update. // // NOTE: uint32 is used to ensure compatibility with standard SQL // databases (signed 64-bit integers). WalletID uint32 - // OldPassphrase is the current passphrase. - OldPassphrase []byte + // MasterPrivParams are the parameters (e.g. salt, scrypt N/R/P) used + // to derive the master private key. + MasterPrivParams []byte + + // EncryptedCryptoPrivKey is the encrypted private crypto key, used to + // protect private keys in the database. + EncryptedCryptoPrivKey []byte - // NewPassphrase is the new passphrase to set. - NewPassphrase []byte + // EncryptedCryptoScriptKey is the encrypted script crypto key, used to + // protect scripts in the database. + EncryptedCryptoScriptKey []byte - // IsPrivate specifies whether to change the private (true) or public - // (false) passphrase. - IsPrivate bool + // EncryptedMasterHdPrivKey is the encrypted master HD private key. + EncryptedMasterHdPrivKey []byte } // -------------------- diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index cfde710641..b362bb3099 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -37,13 +37,9 @@ type WalletStore interface { // encrypted seed as a byte slice or an error if the retrieval fails. GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) - // ChangePassphrase changes the passphrase for the wallet. It takes the - // old and new passphrases as byte slices, and a boolean indicating - // whether to change the private passphrase (true) or the public - // passphrase (false). It returns an error if the passphrase change - // fails (e.g., incorrect old passphrase). - ChangePassphrase(ctx context.Context, - params ChangePassphraseParams) error + // UpdateWalletSecrets updates the secrets for the wallet. + UpdateWalletSecrets(ctx context.Context, + params UpdateWalletSecretsParams) error } // AccountStore defines the database actions for managing accounts. From 2b19b4bc0acb93d463f3f5f8e0b108a922ccfdc6 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 25 Nov 2025 01:35:09 -0300 Subject: [PATCH 323/691] wallet: add safecasting functions --- wallet/internal/db/safecasting.go | 88 +++++++++ wallet/internal/db/safecasting_test.go | 236 +++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 wallet/internal/db/safecasting.go create mode 100644 wallet/internal/db/safecasting_test.go diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go new file mode 100644 index 0000000000..1ddc2b0827 --- /dev/null +++ b/wallet/internal/db/safecasting.go @@ -0,0 +1,88 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "math" +) + +var ( + // ErrCastingOverflow is returned when a value cannot be safely + // cast to the desired type. + ErrCastingOverflow = errors.New("casting overflow") + + // ErrInvalidNullInt is returned when an invalid sql.NullInt is + // tried to be cast to an integer type. + ErrInvalidNullInt = errors.New("invalid NullInt") +) + +// int64ToUint32 safely casts an int64 to an uint32, returning an error +// if the value is out of range. +func int64ToUint32(v int64) (uint32, error) { + if v < 0 || v > math.MaxUint32 { + return 0, fmt.Errorf( + "could not cast %d to uint32: %w", + v, ErrCastingOverflow, + ) + } + + return uint32(v), nil +} + +// int64ToInt32 safely casts an int64 to an int32, returning an error +// if the value is out of range. +func int64ToInt32(v int64) (int32, error) { + if v < math.MinInt32 || v > math.MaxInt32 { + return 0, fmt.Errorf( + "could not cast %d to int32: %w", + v, ErrCastingOverflow, + ) + } + + return int32(v), nil +} + +// uint32ToInt32 safely casts an uint32 to an int32, returning an error +// if the value is out of range. +func uint32ToInt32(v uint32) (int32, error) { + if v > math.MaxInt32 { + return 0, fmt.Errorf( + "could not cast %d to int32: %w", + v, ErrCastingOverflow, + ) + } + + return int32(v), nil +} + +// uint32ToNullInt32 safely casts an uint32 to a sql.NullInt32, returning +// an error if the value is out of range. +func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { + toInt32, err := uint32ToInt32(v) + if err != nil { + return sql.NullInt32{}, err + } + + return sql.NullInt32{Int32: toInt32, Valid: true}, nil +} + +// nullInt32ToUint32 safely casts a sql.NullInt32 to an uint32, returning +// an error if the value is out of range or invalid. +func nullInt32ToUint32(n sql.NullInt32) (uint32, error) { + if !n.Valid { + return 0, fmt.Errorf( + "could not cast invalid NullInt32 to uint32: %w", + ErrInvalidNullInt, + ) + } + + if n.Int32 < 0 { + return 0, fmt.Errorf( + "could not cast %d to uint32: %w", + n.Int32, ErrCastingOverflow, + ) + } + + return uint32(n.Int32), nil +} diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go new file mode 100644 index 0000000000..d87fb89466 --- /dev/null +++ b/wallet/internal/db/safecasting_test.go @@ -0,0 +1,236 @@ +package db + +import ( + "database/sql" + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestInt64ToUint32 checks that an int64 value is converted to uint32 only +// when it is non-negative and fits within the uint32 range. It should fail +// loudly for any value outside those bounds. +func TestInt64ToUint32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int64 + want uint32 + wantErr bool + }{ + {name: "zero", val: 0, want: 0}, + { + name: "max uint32", + val: int64(math.MaxUint32), + want: math.MaxUint32, + }, + {name: "negative", val: -1, wantErr: true}, + { + name: "too large", + val: int64(math.MaxUint32) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int64ToUint32(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestInt64ToInt32 checks that an int64 value is converted to int32 only +// when it fits within the signed 32 bit range. It should fail loudly for +// any value outside those limits. +func TestInt64ToInt32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int64 + want int32 + wantErr bool + }{ + { + name: "min int32", + val: int64(math.MinInt32), + want: math.MinInt32, + }, + { + name: "max int32", + val: int64(math.MaxInt32), + want: math.MaxInt32, + }, + { + name: "below min", + val: int64(math.MinInt32) - 1, + wantErr: true, + }, + { + name: "above max", + val: int64(math.MaxInt32) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int64ToInt32(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestUint32ToInt32 checks that an uint32 value is safely converted to int32 +// only when it fits within the signed 32 bit range. It should fail loudly +// for any value that exceeds those limits. +func TestUint32ToInt32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val uint32 + want int32 + wantErr bool + }{ + {name: "zero", val: 0, want: 0}, + {name: "max int32", val: math.MaxInt32, want: math.MaxInt32}, + { + name: "overflow", + val: uint32(math.MaxInt32) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := uint32ToInt32(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestUint32ToNullInt32 checks that we respect the signed 32 bit limits +// before converting an uint32 value into sql.NullInt32. It should fail +// loudly when the value is out of range or when valid is false. +func TestUint32ToNullInt32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val uint32 + want sql.NullInt32 + wantErr bool + }{ + { + name: "zero", + val: 0, + want: sql.NullInt32{Int32: 0, Valid: true}, + }, + { + name: "max int32", + val: math.MaxInt32, + want: sql.NullInt32{ + Int32: math.MaxInt32, + Valid: true, + }, + }, + { + name: "overflow", + val: uint32(math.MaxInt32) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := uint32ToNullInt32(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestNullInt32ToUint32 checks that we convert a sql.NullInt32 to uint32 +// only when the value is marked as valid and fits within the uint32 range. +// It should fail loudly for any out of range or invalid value. +func TestNullInt32ToUint32(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val sql.NullInt32 + want uint32 + wantErr error + }{ + { + name: "zero", + val: sql.NullInt32{Int32: 0, Valid: true}, + want: 0, + }, + { + name: "positive", + val: sql.NullInt32{Int32: 42, Valid: true}, + want: 42, + }, + { + name: "negative overflow", + val: sql.NullInt32{Int32: -1, Valid: true}, + wantErr: ErrCastingOverflow, + }, + { + name: "invalid null", + val: sql.NullInt32{Valid: false}, + wantErr: ErrInvalidNullInt, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := nullInt32ToUint32(tc.val) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} From a4e03a6626536d1d3742d46d51118c01e0ea1d81 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 25 Nov 2025 02:16:13 -0300 Subject: [PATCH 324/691] wallet: add database structs --- wallet/internal/db/interface.go | 9 +++++++++ wallet/internal/db/pg.go | 26 ++++++++++++++++++++++++++ wallet/internal/db/sqlite.go | 25 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 wallet/internal/db/pg.go create mode 100644 wallet/internal/db/sqlite.go diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index b362bb3099..5f9c318a3c 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -2,11 +2,20 @@ package db import ( "context" + "errors" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" ) +var ( + // ErrNilDB is returned when a nil database connection pointer is + // provided to the wallet. + ErrNilDB = errors.New( + "wallet requires a non-nil database connection", + ) +) + // WalletStore defines the methods for wallet-level operations. type WalletStore interface { // CreateWallet creates a new wallet in the database with the provided diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go new file mode 100644 index 0000000000..125aa51f19 --- /dev/null +++ b/wallet/internal/db/pg.go @@ -0,0 +1,26 @@ +package db + +import ( + "database/sql" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// PostgresWalletDB is the PostgreSQL implementation of the +// WalletStore interface. +type PostgresWalletDB struct { + db *sql.DB + queries *sqlcpg.Queries +} + +// NewPostgresWalletDB creates a new PostgreSQL-based WalletStore. +func NewPostgresWalletDB(db *sql.DB) (*PostgresWalletDB, error) { + if db == nil { + return nil, ErrNilDB + } + + return &PostgresWalletDB{ + db: db, + queries: sqlcpg.New(db), + }, nil +} diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go new file mode 100644 index 0000000000..819ccca4c6 --- /dev/null +++ b/wallet/internal/db/sqlite.go @@ -0,0 +1,25 @@ +package db + +import ( + "database/sql" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// SQLiteWalletDB is the SQLite implementation of the WalletStore interface. +type SQLiteWalletDB struct { + db *sql.DB + queries *sqlcsqlite.Queries +} + +// NewSQLiteWalletDB creates a new SQLite-based WalletStore. +func NewSQLiteWalletDB(db *sql.DB) (*SQLiteWalletDB, error) { + if db == nil { + return nil, ErrNilDB + } + + return &SQLiteWalletDB{ + db: db, + queries: sqlcsqlite.New(db), + }, nil +} From 167a2d1dcf87e9989b7f84792da9e60f214073cb Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 27 Nov 2025 15:11:28 -0300 Subject: [PATCH 325/691] wallet: add database constructors test --- wallet/internal/db/db_connectors_test.go | 116 +++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 wallet/internal/db/db_connectors_test.go diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go new file mode 100644 index 0000000000..48a4bdd47e --- /dev/null +++ b/wallet/internal/db/db_connectors_test.go @@ -0,0 +1,116 @@ +package db + +import ( + "database/sql" + "database/sql/driver" + "sync" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const testDriverName = "wallet-test-driver" + +var ( + registerDriverOnce sync.Once + testDriver *mockDriver +) + +// newMockedTestDB returns a *sql.DB backed by a mock driver. It avoids any +// network or disk usage, so it works well for constructor tests that only +// need a non nil database handle. It should be used only in very simple +// scenarios, since it does not implement real behavior and cannot confirm +// that the issued queries works as expected. +func newMockedTestDB(t *testing.T) *sql.DB { + t.Helper() + + registerDriverOnce.Do(func() { + testDriver = &mockDriver{} + testDriver.On("Open", mock.Anything).Return(mockConn{}, nil) + + sql.Register(testDriverName, testDriver) + }) + + db, err := sql.Open(testDriverName, "") + require.NoError(t, err) + + t.Cleanup(func() { + _ = db.Close() + }) + + return db +} + +// TestNewPostgresWalletDB checks that the PostgresWalletDB constructor +// properly guards against nil *sql.DB inputs and wires up the queries +// correctly. +func TestNewPostgresWalletDB(t *testing.T) { + t.Parallel() + + t.Run("nil db", func(t *testing.T) { + t.Parallel() + + db, err := NewPostgresWalletDB(nil) + require.ErrorIs(t, err, ErrNilDB) + require.Nil(t, db) + }) + + t.Run("valid db", func(t *testing.T) { + t.Parallel() + + sqlDB := newMockedTestDB(t) + + db, err := NewPostgresWalletDB(sqlDB) + require.NoError(t, err) + require.NotNil(t, db) + require.Equal(t, sqlDB, db.db) + require.NotNil(t, db.queries) + }) +} + +// TestNewSQLiteWalletDB checks that the SQLiteWalletDB constructor +// properly guards against nil *sql.DB inputs and wires up the queries +// correctly. +func TestNewSQLiteWalletDB(t *testing.T) { + t.Parallel() + + t.Run("nil db", func(t *testing.T) { + t.Parallel() + + db, err := NewSQLiteWalletDB(nil) + require.ErrorIs(t, err, ErrNilDB) + require.Nil(t, db) + }) + + t.Run("valid db", func(t *testing.T) { + t.Parallel() + + sqlDB := newMockedTestDB(t) + + db, err := NewSQLiteWalletDB(sqlDB) + require.NoError(t, err) + require.NotNil(t, db) + require.Equal(t, sqlDB, db.db) + require.NotNil(t, db.queries) + }) +} + +// mockDriver implements a bare-bones SQL driver so tests can obtain a *sql.DB +// without depending on an external database. +type mockDriver struct { + mock.Mock +} + +func (m *mockDriver) Open(name string) (driver.Conn, error) { + args := m.Called(name) + conn, _ := args.Get(0).(driver.Conn) + + return conn, args.Error(1) +} + +// mockConn is a mock implementation of a database connection. It does not +// implement any real behavior. Used to be returned by the mockDriver. +type mockConn struct { + mock.Mock +} From 2c1593704a9a75798498d6518d846fa789b0921d Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 1 Dec 2025 14:55:34 -0300 Subject: [PATCH 326/691] wallet: add simple migration with golang-migrate --- go.mod | 42 +++++--- go.sum | 170 +++++++++++++++++++++++++------ wallet/internal/db/migrations.go | 84 +++++++++++++++ 3 files changed, 253 insertions(+), 43 deletions(-) create mode 100644 wallet/internal/db/migrations.go diff --git a/go.mod b/go.mod index 7ea12c13c5..f994484998 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,9 @@ require ( github.com/btcsuite/btcwallet/walletdb v1.5.1 github.com/btcsuite/btcwallet/wtxmgr v1.5.6 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 - github.com/davecgh/go-spew v1.1.1 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 + github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang/protobuf v1.5.4 github.com/jessevdk/go-flags v1.6.1 github.com/jrick/logrotate v1.1.2 @@ -25,12 +26,12 @@ require ( github.com/lightningnetwork/lnd/ticker v1.1.1 github.com/lightningnetwork/lnd/tlv v1.3.2 github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.41.0 - golang.org/x/net v0.43.0 - golang.org/x/sync v0.16.0 - golang.org/x/term v0.34.0 - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 + golang.org/x/crypto v0.45.0 + golang.org/x/net v0.47.0 + golang.org/x/sync v0.18.0 + golang.org/x/term v0.37.0 + google.golang.org/grpc v1.74.2 + google.golang.org/protobuf v1.36.7 ) require ( @@ -38,21 +39,36 @@ require ( github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/lru v1.1.2 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kkdai/bstream v1.0.0 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/lightningnetwork/lnd/clock v1.0.1 // indirect github.com/lightningnetwork/lnd/queue v1.0.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/stretchr/objx v0.5.2 // indirect go.etcd.io/bbolt v1.3.11 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/uint128 v1.2.0 // indirect + modernc.org/cc/v3 v3.36.3 // indirect + modernc.org/ccgo/v3 v3.16.9 // indirect + modernc.org/libc v1.17.1 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.2.1 // indirect + modernc.org/opt v0.1.3 // indirect + modernc.org/sqlite v1.18.1 // indirect + modernc.org/strutil v1.1.3 // indirect + modernc.org/token v1.0.0 // indirect ) // If you change this please run `make lint` to see where else it needs to be diff --git a/go.sum b/go.sum index 6ac11cfed7..39c61905e4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= @@ -40,11 +44,16 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= @@ -54,12 +63,30 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjY github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/decred/dcrd/lru v1.1.2 h1:KdCzlkxppuoIDGEvCGah1fZRicrDH36IipvlB1ROkFY= github.com/decred/dcrd/lru v1.1.2/go.mod h1:gEdCVgXs1/YoBvFWt7Scgknbhwik3FgVSzlnCcXL2N8= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -74,8 +101,10 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= @@ -88,6 +117,8 @@ github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPG github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/jrick/logrotate v1.1.2 h1:6ePk462NCX7TfKtNp5JJ7MbA2YIslkpfgP03TlTYMN0= github.com/jrick/logrotate v1.1.2/go.mod h1:f9tdWggSVK3iqavGpyvegq5IhNois7KXmasU6/N96OQ= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= @@ -95,6 +126,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk= github.com/lightninglabs/neutrino v0.16.2 h1:jHMMDLPX8asfwgN0/C4BY8uVaYupFzZYuWQkX8Go3fk= @@ -112,6 +145,17 @@ github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6 github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0= github.com/lightningnetwork/lnd/tlv v1.3.2/go.mod h1:pJuiBj1ecr1WWLOtcZ+2+hu9Ey25aJWFIsjmAoPPnmc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -121,9 +165,18 @@ github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -140,70 +193,91 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 h1:3yiSh9fhy5/RhCSntf4Sy0Tnx50DmMpQ4MQdKKk4yg4= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -215,5 +289,41 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg= +modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM= +modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= +modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI= +modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= +modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= +modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs= +modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8= +modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= +modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= +modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao= +modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= +modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= +modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM= +modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/wallet/internal/db/migrations.go b/wallet/internal/db/migrations.go new file mode 100644 index 0000000000..cc929d1f35 --- /dev/null +++ b/wallet/internal/db/migrations.go @@ -0,0 +1,84 @@ +package db + +import ( + "database/sql" + "embed" + "errors" + "fmt" + "io/fs" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/database/sqlite" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/sqlite/*.sql +var sqliteFS embed.FS + +//go:embed migrations/postgres/*.sql +var postgresFS embed.FS + +type driverFactory func(*sql.DB) (database.Driver, error) + +// applyMigrations is a simple function that applies all migrations found in the +// given migrationFS at the given path to the provided database using the given +// driver factory. +// +// TODO(gustavostingelin): enhance migrations to be like sqldb/v2 before +// production use. This is a simplified migration system suitable for +// integration tests but lacks features required for production: +// - No migration version tracking or status checks +// - No migration history table or audit trail +// - No protection against concurrent migrations +// +// For production use, this should be enhanced to match the patterns in +// lnd/sqldb/v2, which provides a more robust migration framework. +func applyMigrations(db *sql.DB, migrationFS fs.FS, path string, dbName string, + newDriver driverFactory) error { + + sourceDriver, err := iofs.New(migrationFS, path) + if err != nil { + return fmt.Errorf("create source driver: %w", err) + } + + driver, err := newDriver(db) + if err != nil { + return fmt.Errorf("create %s driver: %w", dbName, err) + } + + m, err := migrate.NewWithInstance("iofs", sourceDriver, dbName, driver) + if err != nil { + return fmt.Errorf("create migrate instance: %w", err) + } + + err = m.Up() + if err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + + return nil +} + +// ApplySQLiteMigrations applies all SQLite migrations to the database. +// +// NOTE: not ready for production use. +func ApplySQLiteMigrations(db *sql.DB) error { + return applyMigrations(db, sqliteFS, "migrations/sqlite", "sqlite", + func(db *sql.DB) (database.Driver, error) { + return sqlite.WithInstance(db, &sqlite.Config{}) + }, + ) +} + +// ApplyPostgresMigrations applies all PostgreSQL migrations to the database. +// +// NOTE: not ready for production use. +func ApplyPostgresMigrations(db *sql.DB) error { + return applyMigrations(db, postgresFS, "migrations/postgres", + "postgres", func(db *sql.DB) (database.Driver, error) { + return postgres.WithInstance(db, &postgres.Config{}) + }, + ) +} From 329e7d3ee016d4347f94e2aa96e674bf39ee3aa0 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 1 Dec 2025 14:58:48 -0300 Subject: [PATCH 327/691] wallet: add pg and sqlite integration test setup --- go.mod | 82 ++++++--- go.sum | 230 +++++++++++++++--------- wallet/internal/db/itest/pg_test.go | 179 ++++++++++++++++++ wallet/internal/db/itest/sqlite_test.go | 52 ++++++ 4 files changed, 436 insertions(+), 107 deletions(-) create mode 100644 wallet/internal/db/itest/pg_test.go create mode 100644 wallet/internal/db/itest/sqlite_test.go diff --git a/go.mod b/go.mod index f994484998..b43104eabf 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang/protobuf v1.5.4 + github.com/jackc/pgx/v5 v5.5.4 github.com/jessevdk/go-flags v1.6.1 github.com/jrick/logrotate v1.1.2 github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf @@ -25,50 +26,91 @@ require ( github.com/lightningnetwork/lnd/fn/v2 v2.0.8 github.com/lightningnetwork/lnd/ticker v1.1.1 github.com/lightningnetwork/lnd/tlv v1.3.2 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 golang.org/x/crypto v0.45.0 golang.org/x/net v0.47.0 golang.org/x/sync v0.18.0 golang.org/x/term v0.37.0 - google.golang.org/grpc v1.74.2 - google.golang.org/protobuf v1.36.7 + google.golang.org/grpc v1.75.1 + google.golang.org/protobuf v1.36.10 + modernc.org/sqlite v1.40.1 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aead/siphash v1.0.1 // indirect github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/lru v1.1.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/kkdai/bstream v1.0.0 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lightningnetwork/lnd/clock v1.0.1 // indirect github.com/lightningnetwork/lnd/queue v1.0.1 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.3.11 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect - golang.org/x/mod v0.29.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - lukechampine.com/uint128 v1.2.0 // indirect - modernc.org/cc/v3 v3.36.3 // indirect - modernc.org/ccgo/v3 v3.16.9 // indirect - modernc.org/libc v1.17.1 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.2.1 // indirect - modernc.org/opt v0.1.3 // indirect - modernc.org/sqlite v1.18.1 // indirect - modernc.org/strutil v1.1.3 // indirect - modernc.org/token v1.0.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) // If you change this please run `make lint` to see where else it needs to be diff --git a/go.sum b/go.sum index 39c61905e4..1b51fc4723 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -44,11 +48,20 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -67,24 +80,27 @@ github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -101,15 +117,26 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= @@ -117,11 +144,11 @@ github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPG github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/jrick/logrotate v1.1.2 h1:6ePk462NCX7TfKtNp5JJ7MbA2YIslkpfgP03TlTYMN0= github.com/jrick/logrotate v1.1.2/go.mod h1:f9tdWggSVK3iqavGpyvegq5IhNois7KXmasU6/N96OQ= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -145,17 +172,34 @@ github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6 github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0= github.com/lightningnetwork/lnd/tlv v1.3.2/go.mod h1:pJuiBj1ecr1WWLOtcZ+2+hu9Ey25aJWFIsjmAoPPnmc= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -167,33 +211,47 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -202,52 +260,55 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6h go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 h1:3yiSh9fhy5/RhCSntf4Sy0Tnx50DmMpQ4MQdKKk4yg4= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= @@ -257,27 +318,30 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -289,41 +353,33 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= -lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= -modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg= -modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= -modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM= -modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= -modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= -modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= -modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= -modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= -modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= -modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI= -modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= -modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= -modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs= -modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8= -modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= -modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= -modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= -modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= -modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao= -modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= -modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= -modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM= -modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go new file mode 100644 index 0000000000..41065c45b7 --- /dev/null +++ b/wallet/internal/db/itest/pg_test.go @@ -0,0 +1,179 @@ +//go:build itest && test_db_postgres + +package itest + +import ( + "context" + "database/sql" + "fmt" + "os" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +var ( + // Shared container instance, reused across tests for performance. + // This is safe to use concurrently because we only share the container + // and not the database inside it. Each test gets its own database. + pgContainer *postgres.PostgresContainer + + // Ensure the container is created only once. + pgContainerOnce sync.Once + + // Error returned by the container creation operation. We need to store + // it to return when the error already occurred during test setup. + pgContainerErr error + + // Timeout for waiting for the postgres container to start. Needs to + // consider container image download time. + pgInitTimeout = 2 * time.Minute + + // Timeout for terminating the postgres container after the test suite. + pgTerminateTimeout = 1 * time.Minute +) + +// TestMain ensures the shared postgres container is terminated after the +// integration test suite completes to avoid leaking docker resources. +func TestMain(m *testing.M) { + code := m.Run() + + // Terminate the container after the test suite completes. + if pgContainer != nil { + ctx, cancel := context.WithTimeout(context.Background(), pgTerminateTimeout) + defer cancel() + + err := pgContainer.Terminate(ctx) + if err != nil { + fmt.Printf("failed to terminate postgres container: %v\n", err) + } + } + + os.Exit(code) +} + +// PostgresConfig holds configuration for the test PostgreSQL database. +type PostgresConfig struct { + // Image is the Docker image to use. + Image string + + // Database is the database name. + Database string + + // Username is the database user. + Username string + + // Password is the database password. + Password string +} + +// DefaultPostgresConfig returns the default PostgreSQL test configuration. +func DefaultPostgresConfig() PostgresConfig { + return PostgresConfig{ + Image: "postgres:18-alpine", + Database: "postgres", + Username: "postgres", + Password: "postgres", + } +} + +// GetPostgresContainer returns the shared PostgreSQL container instance. +// The container is created once and reused across all tests for performance. +// +// Note: postgres:18-alpine defaults max_connections to 100. +func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, error) { + pgContainerOnce.Do(func() { + cfg := DefaultPostgresConfig() + + pgContainer, pgContainerErr = postgres.RunContainer(ctx, + testcontainers.WithImage(cfg.Image), + postgres.WithDatabase(cfg.Database), + postgres.WithUsername(cfg.Username), + postgres.WithPassword(cfg.Password), + testcontainers.WithWaitStrategyAndDeadline( + pgInitTimeout, wait.ForListeningPort("5432/tcp"), + ), + ) + }) + + return pgContainer, pgContainerErr +} + +// sanitizedPgDBName converts a test name to a valid PostgreSQL database name. +// It converts to lowercase and replaces special characters with underscores. +func sanitizedPgDBName(t *testing.T) string { + // Convert to lowercase. + dbName := strings.ToLower(t.Name()) + + // Replace slashes and other special chars with underscores. + reg := regexp.MustCompile(`[^a-z0-9_]`) + dbName = reg.ReplaceAllString(dbName, "_") + + // PostgreSQL database names are limited to 63 characters. + if len(dbName) > 63 { + dbName = dbName[:63] + t.Logf("database name truncated to %d characters: %s", 63, dbName) + } + + return dbName +} + +// NewPostgresDB creates a new PostgreSQL database connection with migrations +// applied. Each test gets its own database for isolation. +func NewPostgresDB(t *testing.T) *sql.DB { + t.Helper() + ctx := t.Context() + + container, err := GetPostgresContainer(ctx) + require.NoError(t, err, "failed to get postgres container") + + connStr, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err, "failed to get connection string") + + // Connect to the default database to create our test database. + adminDB, err := sql.Open("pgx", connStr) + require.NoError(t, err, "failed to open admin connection") + + // Create a database name based on the test name. + dbName := sanitizedPgDBName(t) + + // Create the test database. + createDBStmt := fmt.Sprintf("CREATE DATABASE %s", dbName) + _, err = adminDB.ExecContext(ctx, createDBStmt) + require.NoError(t, err, "failed to create test database") + + // Build the connection string for the test database. + testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) + + // TODO(gustavostingelin): replace with the real PostgreSQL database + // connection constructor when available. + dbConn, err := sql.Open("pgx", testConnStr) + require.NoError(t, err, "failed to open test database connection") + + err = db.ApplyPostgresMigrations(dbConn) + require.NoError(t, err, "failed to apply migrations") + + return dbConn +} + +// NewTestStore creates a PostgreSQL wallet store and returns it along with the +// underlying database connection for tests that also need direct DB access. +func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sql.DB) { + t.Helper() + + dbConn := NewPostgresDB(t) + + store, err := db.NewPostgresWalletDB(dbConn) + require.NoError(t, err, "failed to create wallet store") + + return store, dbConn +} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go new file mode 100644 index 0000000000..30b6c648a2 --- /dev/null +++ b/wallet/internal/db/itest/sqlite_test.go @@ -0,0 +1,52 @@ +//go:build itest && !test_db_postgres + +package itest + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" +) + +// NewSQLiteDB creates a new SQLite database for testing with migrations +// applied. Each test gets its own temporary database file. +func NewSQLiteDB(t *testing.T) *sql.DB { + t.Helper() + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // Enable foreign keys (required for proper constraint enforcement). + dsn := dbPath + "?_pragma=foreign_keys=on" + + // TODO(gustavostingelin): replace with the real SQLite database + // connection constructor when available. + dbConn, err := sql.Open("sqlite", dsn) + require.NoError(t, err, "failed to open sqlite database") + + err = db.ApplySQLiteMigrations(dbConn) + require.NoError(t, err, "failed to apply migrations") + + t.Cleanup(func() { + _ = dbConn.Close() + }) + + return dbConn +} + +// NewTestStore creates the SQLite wallet store and returns it along with the +// underlying database connection for tests that also need direct DB access. +func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sql.DB) { + t.Helper() + + dbConn := NewSQLiteDB(t) + + store, err := db.NewSQLiteWalletDB(dbConn) + require.NoError(t, err, "failed to create wallet store") + + return store, dbConn +} From 22c1568d7cd88c5a60d5a20d7bdc117ef987a3f1 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 1 Dec 2025 14:44:23 -0300 Subject: [PATCH 328/691] Makefile + make: add itest cmd --- Makefile | 20 ++++++++++++++++++++ make/testing_flags.mk | 14 ++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Makefile b/Makefile index d4829dd895..c88fb154aa 100644 --- a/Makefile +++ b/Makefile @@ -84,6 +84,24 @@ unit-bench: @$(call print, "Running benchmark tests.") $(UNIT_BENCH) +#? itest-db: Run integration tests for wallet database +itest-db: + @if [ -z "$(IT_DB_LABEL)" ]; then \ + echo "Unknown integration test database '$(db)'. Use db=sqlite or db=postgres." ; \ + exit 1 ; \ + fi + @$(call print, "Running $(IT_DB_LABEL) integration tests.") + $(ITEST_DB) + +#? itest-db-race: Run integration tests for wallet database with race detector +itest-db-race: + @if [ -z "$(IT_DB_LABEL)" ]; then \ + echo "Unknown integration test database '$(db)'. Use db=sqlite or db=postgres." ; \ + exit 1 ; \ + fi + @$(call print, "Running $(IT_DB_LABEL) integration tests (race).") + env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(ITEST_DB_RACE) + # ========= # UTILITIES # ========= @@ -178,6 +196,8 @@ sqlc-check: sqlc unit-race \ unit-debug \ unit-bench \ + itest-db \ + itest-db-race \ fmt \ fmt-check \ tidy-module \ diff --git a/make/testing_flags.mk b/make/testing_flags.mk index a2266c0642..86b9d1e5f8 100644 --- a/make/testing_flags.mk +++ b/make/testing_flags.mk @@ -1,5 +1,16 @@ DEV_TAGS = dev LOG_TAGS = +IT_TAGS ?= + +# Integration test DB selections derived from the `db` variable (sqlite, postgres). +# Defaults to sqlite if not specified. +ifeq ($(db),postgres) +IT_TAGS += test_db_postgres +IT_DB_LABEL := PostgreSQL +else +IT_TAGS := +IT_DB_LABEL := SQLite +endif GOCC ?= go GOLIST := $(GOCC) list -tags="$(DEV_TAGS)" -deps $(PKG)/... | grep '$(PKG)' @@ -75,3 +86,6 @@ UNIT_RACE := $(UNIT) -race endif UNIT_COVER := $(GOTEST) $(COVER_FLAGS) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) $(COVER_PKG) + +ITEST_DB := $(GOTEST) -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest +ITEST_DB_RACE := $(GOTEST) -race -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest From 317e1eaa405c005be40cf8b3b2fb9c69cd2a7784 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 9 Dec 2025 14:25:20 -0300 Subject: [PATCH 329/691] CI: add integration test check for pg and sqlite on ubuntu --- .github/workflows/main.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bbdd5257c4..ad81d16d43 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -152,3 +152,40 @@ jobs: with: path-to-profile: coverage.txt parallel: true + + ######################## + # run integration tests (SQLite + Postgres) + ######################## + itest-db: + name: integration tests (${{ matrix.db }}, ${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + db: [sqlite, postgres] + target: [itest-db, itest-db-race] + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: go cache + uses: actions/cache@v4 + with: + path: /home/runner/work/go + key: btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} + btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}- + btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}- + btcwallet-${{ runner.os }}-go- + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: run ${{ matrix.db }} ${{ matrix.target }} + run: make ${{ matrix.target }} db=${{ matrix.db }} From 91803640eee7a6bf4fc506acc75572bdbfcc391c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 9 Dec 2025 00:56:36 -0300 Subject: [PATCH 330/691] wallet: add database transaction helper --- wallet/internal/db/pg.go | 14 ++++++++++++ wallet/internal/db/sqlite.go | 14 ++++++++++++ wallet/internal/db/tx.go | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 wallet/internal/db/tx.go diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go index 125aa51f19..1de6f34f9c 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -24,3 +25,16 @@ func NewPostgresWalletDB(db *sql.DB) (*PostgresWalletDB, error) { queries: sqlcpg.New(db), }, nil } + +// ExecuteTx executes a function within a database transaction. The function +// receives a transactional query executor and should perform all database +// operations using it. The transaction will be automatically committed on +// success or rolled back on error. +func (w *PostgresWalletDB) ExecuteTx(ctx context.Context, + fn func(*sqlcpg.Queries) error) error { + + return execInTx(ctx, w.db, func(tx *sql.Tx) error { + qtx := w.queries.WithTx(tx) + return fn(qtx) + }) +} diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index 819ccca4c6..15b8409f8a 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -23,3 +24,16 @@ func NewSQLiteWalletDB(db *sql.DB) (*SQLiteWalletDB, error) { queries: sqlcsqlite.New(db), }, nil } + +// ExecuteTx executes a function within a database transaction. The function +// receives a transactional query executor and should perform all database +// operations using it. The transaction will be automatically committed on +// success or rolled back on error. +func (w *SQLiteWalletDB) ExecuteTx(ctx context.Context, + fn func(*sqlcsqlite.Queries) error) error { + + return execInTx(ctx, w.db, func(tx *sql.Tx) error { + qtx := w.queries.WithTx(tx) + return fn(qtx) + }) +} diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go new file mode 100644 index 0000000000..da65c17deb --- /dev/null +++ b/wallet/internal/db/tx.go @@ -0,0 +1,41 @@ +package db + +import ( + "context" + "database/sql" + "fmt" +) + +// execInTx executes a function within a database transaction. It handles +// the transaction lifecycle: begin, commit, and rollback on error. +// +// This is a helper function used by the public ExecuteTx methods on +// PostgresWalletDB and SQLiteWalletDB. It guarantees that the transaction +// will be either committed (on success) or rolled back (on error or panic). +func execInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + + // Rollback can be called safely even when the transaction is already + // closed. If the transaction commits, this call does nothing. If the + // rollback fails because of a connection issue, it is still fine since + // the transaction was never committed, and the database remains + // unchanged. + defer func() { + _ = tx.Rollback() + }() + + err = fn(tx) + if err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return fmt.Errorf("commit tx: %w", err) + } + + return nil +} From 1ddb28411a679e3ef1d2ac2e0a68b9311ac1f652 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sun, 16 Nov 2025 00:34:39 -0300 Subject: [PATCH 331/691] wallet: add wallets table migration --- .../postgres/000002_wallets.down.sql | 5 + .../migrations/postgres/000002_wallets.up.sql | 108 ++++++++++++++++++ .../migrations/sqlite/000002_wallets.down.sql | 5 + .../migrations/sqlite/000002_wallets.up.sql | 108 ++++++++++++++++++ wallet/internal/db/sqlc/postgres/models.go | 32 ++++++ wallet/internal/db/sqlc/sqlite/models.go | 32 ++++++ 6 files changed, 290 insertions(+) create mode 100644 wallet/internal/db/migrations/postgres/000002_wallets.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000002_wallets.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000002_wallets.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000002_wallets.up.sql diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.down.sql b/wallet/internal/db/migrations/postgres/000002_wallets.down.sql new file mode 100644 index 0000000000..89cfc08c06 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000002_wallets.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database in unexpected state. +DROP TABLE IF EXISTS wallet_sync_states; +DROP TABLE IF EXISTS wallet_secrets; +DROP TABLE IF EXISTS wallets; diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql new file mode 100644 index 0000000000..b53bea515f --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -0,0 +1,108 @@ +-- Wallet metadata and related state tables. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE wallets ( + -- DB ID of the wallet, primary key. Only used for DB level relations. + id BIGSERIAL PRIMARY KEY, + + -- Human friendly name for the wallet. + name TEXT NOT NULL, + + -- Defines if the wallet was imported, all its accounts would also be imported. + is_imported BOOLEAN NOT NULL, + + -- Version of the wallet manager that created this wallet. + manager_version INTEGER NOT NULL, + + -- Defines if the wallet is a watch-only wallet. + is_watch_only BOOLEAN NOT NULL, + + -- Params to derive the public master key. + master_pub_params BYTEA NOT NULL, + + -- Encrypted key used to encrypt/decrypt wallet data related to public keys. + encrypted_crypto_pub_key BYTEA NOT NULL, + + -- Encrypted HD public key of the wallet. NULL for certain wallet types + -- that don't store extended public keys. + encrypted_master_hd_pub_key BYTEA +); + +-- Unique index to prevent duplicate wallet names. +CREATE UNIQUE INDEX uidx_wallets_name ON wallets (name); + +-- Wallet Secrets table to store rarely accessed, highly sensitive encrypted +-- material with a strict one-to-one relationship with the wallets table. +-- Separated from the main wallets table for security and access pattern isolation. +-- Watch-only wallets may have no corresponding row in this table or have all +-- private key fields with no data. +CREATE TABLE wallet_secrets ( + -- Reference to the wallet these secrets belong to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + wallet_id BIGINT NOT NULL, + + -- Params to derive the private master key. NULL for watch-only wallets. + master_priv_params BYTEA, + + -- Encrypted key used to encrypt/decrypt wallet data related to private keys. + -- NULL for watch-only wallets. + encrypted_crypto_priv_key BYTEA, + + -- Encrypted key used to encrypt/decrypt wallet data related to scripts. + -- NULL for watch-only wallets. + encrypted_crypto_script_key BYTEA, + + -- Encrypted HD private key of the wallet. NULL for watch-only wallets. + encrypted_master_hd_priv_key BYTEA, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if secrets still exist. + FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each wallet has at most one secrets record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_wallet_secrets_wallet ON wallet_secrets (wallet_id); + +-- Wallet Sync States table to store the synchronization state of each wallet. +-- This is kept separate from the wallets table to avoid write amplification on +-- frequently updated sync data. Each wallet has exactly one sync state record. +CREATE TABLE wallet_sync_states ( + -- Reference to the wallet this sync state belongs to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + wallet_id BIGINT NOT NULL, + + -- Current sync status of the wallet (references blocks table). NULL for wallets + -- that haven't synced any blocks yet. + synced_height INTEGER, + + -- Birthday block height of the wallet (references blocks table). NULL if the + -- wallet has no known birthday block. + birthday_height INTEGER, + + -- Indicates if the birthday block has been verified. + birthday_verified BOOLEAN NOT NULL, + + -- Last updated timestamp stored in UTC without timezone info. + updated_at TIMESTAMP NOT NULL, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if sync state still exists. + FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT, + + -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure + -- that the block cannot be deleted if it is referenced by the sync state. + FOREIGN KEY (synced_height) REFERENCES blocks(block_height) + ON DELETE RESTRICT, + + -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure + -- that the block cannot be deleted if it is referenced by the sync state. + FOREIGN KEY (birthday_height) REFERENCES blocks(block_height) + ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each wallet has exactly one sync state record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet + ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.down.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.down.sql new file mode 100644 index 0000000000..89cfc08c06 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database in unexpected state. +DROP TABLE IF EXISTS wallet_sync_states; +DROP TABLE IF EXISTS wallet_secrets; +DROP TABLE IF EXISTS wallets; diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql new file mode 100644 index 0000000000..77582601e5 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -0,0 +1,108 @@ +-- Wallet metadata and related state tables. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE wallets ( + -- DB ID of the wallet, primary key. Only used for DB level relations. + id INTEGER PRIMARY KEY, + + -- Human friendly name for the wallet. + name TEXT NOT NULL, + + -- Defines if the wallet was imported, all its accounts would also be imported. + is_imported BOOLEAN NOT NULL, + + -- Version of the wallet manager that created this wallet. + manager_version INTEGER NOT NULL, + + -- Defines if the wallet is a watch-only wallet. + is_watch_only BOOLEAN NOT NULL, + + -- Params to derive the public master key. + master_pub_params BLOB NOT NULL, + + -- Encrypted key used to encrypt/decrypt wallet data related to public keys. + encrypted_crypto_pub_key BLOB NOT NULL, + + -- Encrypted HD public key of the wallet. NULL for certain wallet types + -- that don't store extended public keys. + encrypted_master_hd_pub_key BLOB +); + +-- Unique index to prevent duplicate wallet names. +CREATE UNIQUE INDEX uidx_wallets_name ON wallets (name); + +-- Wallet Secrets table to store rarely accessed, highly sensitive encrypted +-- material with a strict one-to-one relationship with the wallets table. +-- Separated from the main wallets table for security and access pattern isolation. +-- Watch-only wallets may have no corresponding row in this table or have all +-- private key fields with no data. +CREATE TABLE wallet_secrets ( + -- Reference to the wallet these secrets belong to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + wallet_id INTEGER NOT NULL, + + -- Params to derive the private master key. NULL for watch-only wallets. + master_priv_params BLOB, + + -- Encrypted key used to encrypt/decrypt wallet data related to private keys. + -- NULL for watch-only wallets. + encrypted_crypto_priv_key BLOB, + + -- Encrypted key used to encrypt/decrypt wallet data related to scripts. + -- NULL for watch-only wallets. + encrypted_crypto_script_key BLOB, + + -- Encrypted HD private key of the wallet. NULL for watch-only wallets. + encrypted_master_hd_priv_key BLOB, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if secrets still exist. + FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each wallet has at most one secrets record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_wallet_secrets_wallet ON wallet_secrets (wallet_id); + +-- Wallet Sync States table to store the synchronization state of each wallet. +-- This is kept separate from the wallets table to avoid write amplification on +-- frequently updated sync data. Each wallet has exactly one sync state record. +CREATE TABLE wallet_sync_states ( + -- Reference to the wallet this sync state belongs to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + wallet_id INTEGER NOT NULL, + + -- Current sync status of the wallet (references blocks table). NULL for wallets + -- that haven't synced any blocks yet. + synced_height INTEGER, + + -- Birthday block height of the wallet (references blocks table). NULL if the + -- wallet has no known birthday block. + birthday_height INTEGER, + + -- Indicates if the birthday block has been verified. + birthday_verified BOOLEAN NOT NULL, + + -- Last updated timestamp stored in UTC without timezone info. + updated_at DATETIME NOT NULL, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if sync state still exists. + FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT, + + -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure + -- that the block cannot be deleted if it is referenced by the sync state. + FOREIGN KEY (synced_height) REFERENCES blocks(block_height) + ON DELETE RESTRICT, + + -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure + -- that the block cannot be deleted if it is referenced by the sync state. + FOREIGN KEY (birthday_height) REFERENCES blocks(block_height) + ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each wallet has exactly one sync state record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet + ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 3570205637..a2817f36fe 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -4,8 +4,40 @@ package sqlcpg +import ( + "database/sql" + "time" +) + type Block struct { BlockHeight int32 HeaderHash []byte Timestamp int64 } + +type Wallet struct { + ID int64 + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + MasterPubParams []byte + EncryptedCryptoPubKey []byte + EncryptedMasterHdPubKey []byte +} + +type WalletSecret struct { + WalletID int64 + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte +} + +type WalletSyncState struct { + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified bool + UpdatedAt time.Time +} diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 99e67f9744..5a4568589d 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -4,8 +4,40 @@ package sqlcsqlite +import ( + "database/sql" + "time" +) + type Block struct { BlockHeight int64 HeaderHash []byte Timestamp int64 } + +type Wallet struct { + ID int64 + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + MasterPubParams []byte + EncryptedCryptoPubKey []byte + EncryptedMasterHdPubKey []byte +} + +type WalletSecret struct { + WalletID int64 + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte +} + +type WalletSyncState struct { + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified bool + UpdatedAt time.Time +} From c7d707f337dc501f9fbebe625d6282c2c98d6a55 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 17 Nov 2025 13:21:46 -0300 Subject: [PATCH 332/691] wallet: add wallets table queries --- .../internal/db/queries/postgres/wallets.sql | 134 ++++++ wallet/internal/db/queries/sqlite/wallets.sql | 134 ++++++ wallet/internal/db/sqlc/postgres/db.go | 110 ++++- wallet/internal/db/sqlc/postgres/querier.go | 9 + .../internal/db/sqlc/postgres/wallets.sql.go | 398 ++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 110 ++++- wallet/internal/db/sqlc/sqlite/querier.go | 9 + wallet/internal/db/sqlc/sqlite/wallets.sql.go | 398 ++++++++++++++++++ 8 files changed, 1282 insertions(+), 20 deletions(-) create mode 100644 wallet/internal/db/queries/postgres/wallets.sql create mode 100644 wallet/internal/db/queries/sqlite/wallets.sql create mode 100644 wallet/internal/db/sqlc/postgres/wallets.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/wallets.sql.go diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql new file mode 100644 index 0000000000..bec4bcc149 --- /dev/null +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -0,0 +1,134 @@ +-- name: CreateWallet :one +INSERT INTO wallets ( + name, + is_imported, + manager_version, + is_watch_only, + master_pub_params, + encrypted_crypto_pub_key, + encrypted_master_hd_pub_key +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) +RETURNING id; + +-- name: InsertWalletSyncState :exec +INSERT INTO wallet_sync_states ( + wallet_id, + synced_height, + birthday_height, + birthday_verified, + updated_at +) VALUES ( + $1, $2, $3, $4, CURRENT_TIMESTAMP +); + +-- name: UpdateWalletSyncState :execrows +UPDATE wallet_sync_states +SET + -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. + synced_height = COALESCE(sqlc.narg('synced_height'), synced_height), + + -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. + birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), + + -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. + birthday_verified = COALESCE(sqlc.narg('birthday_verified'), birthday_verified), + + -- Always update timestamp to current database time. + updated_at = CURRENT_TIMESTAMP +WHERE + wallet_id = $1; + +-- name: GetWalletByName :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.name = $1; + +-- name: ListWallets :many +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +ORDER BY w.id; + +-- name: GetWalletByID :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.id = $1; + +-- name: InsertWalletSecrets :exec +INSERT INTO wallet_secrets ( + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +) VALUES ( + $1, $2, $3, $4, $5 +); + +-- name: GetWalletSecrets :one +SELECT + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +FROM wallet_secrets +WHERE wallet_id = $1; + +-- name: UpdateWalletSecrets :execrows +UPDATE wallet_secrets +SET + master_priv_params = $1, + encrypted_crypto_priv_key = $2, + encrypted_crypto_script_key = $3, + encrypted_master_hd_priv_key = $4 +WHERE wallet_id = $5; diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql new file mode 100644 index 0000000000..2253bc69d5 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -0,0 +1,134 @@ +-- name: CreateWallet :one +INSERT INTO wallets ( + name, + is_imported, + manager_version, + is_watch_only, + master_pub_params, + encrypted_crypto_pub_key, + encrypted_master_hd_pub_key +) VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING id; + +-- name: InsertWalletSyncState :exec +INSERT INTO wallet_sync_states ( + wallet_id, + synced_height, + birthday_height, + birthday_verified, + updated_at +) VALUES ( + ?, ?, ?, ?, CURRENT_TIMESTAMP +); + +-- name: UpdateWalletSyncState :execrows +UPDATE wallet_sync_states +SET + -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. + synced_height = COALESCE(sqlc.narg('synced_height'), synced_height), + + -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. + birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), + + -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. + birthday_verified = COALESCE(sqlc.narg('birthday_verified'), birthday_verified), + + -- Always update timestamp to current database time. + updated_at = CURRENT_TIMESTAMP +WHERE + wallet_id = sqlc.arg('wallet_id'); + +-- name: GetWalletByName :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.name = ?; + +-- name: ListWallets :many +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +ORDER BY w.id; + +-- name: GetWalletByID :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.id = ?; + +-- name: InsertWalletSecrets :exec +INSERT INTO wallet_secrets ( + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +) VALUES ( + ?, ?, ?, ?, ? +); + +-- name: GetWalletSecrets :one +SELECT + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +FROM wallet_secrets +WHERE wallet_id = ?; + +-- name: UpdateWalletSecrets :execrows +UPDATE wallet_secrets +SET + master_priv_params = ?, + encrypted_crypto_priv_key = ?, + encrypted_crypto_script_key = ?, + encrypted_master_hd_priv_key = ? +WHERE wallet_id = ?; diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 852f9a65f3..57320c40fb 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -24,20 +24,52 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createWalletStmt, err = db.PrepareContext(ctx, CreateWallet); err != nil { + return nil, fmt.Errorf("error preparing query CreateWallet: %w", err) + } if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } + if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) + } + if q.getWalletByNameStmt, err = db.PrepareContext(ctx, GetWalletByName); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletByName: %w", err) + } + if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) + } if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } + if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) + } + if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { + return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) + } + if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { + return nil, fmt.Errorf("error preparing query ListWallets: %w", err) + } + if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) + } + if q.updateWalletSyncStateStmt, err = db.PrepareContext(ctx, UpdateWalletSyncState); err != nil { + return nil, fmt.Errorf("error preparing query UpdateWalletSyncState: %w", err) + } return &q, nil } func (q *Queries) Close() error { var err error + if q.createWalletStmt != nil { + if cerr := q.createWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createWalletStmt: %w", cerr) + } + } if q.deleteBlockStmt != nil { if cerr := q.deleteBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) @@ -48,11 +80,51 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) } } + if q.getWalletByIDStmt != nil { + if cerr := q.getWalletByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) + } + } + if q.getWalletByNameStmt != nil { + if cerr := q.getWalletByNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletByNameStmt: %w", cerr) + } + } + if q.getWalletSecretsStmt != nil { + if cerr := q.getWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) + } + } if q.insertBlockStmt != nil { if cerr := q.insertBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) } } + if q.insertWalletSecretsStmt != nil { + if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) + } + } + if q.insertWalletSyncStateStmt != nil { + if cerr := q.insertWalletSyncStateStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) + } + } + if q.listWalletsStmt != nil { + if cerr := q.listWalletsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) + } + } + if q.updateWalletSecretsStmt != nil { + if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) + } + } + if q.updateWalletSyncStateStmt != nil { + if cerr := q.updateWalletSyncStateStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateWalletSyncStateStmt: %w", cerr) + } + } return err } @@ -90,19 +162,37 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - deleteBlockStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - insertBlockStmt *sql.Stmt + db DBTX + tx *sql.Tx + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - deleteBlockStmt: q.deleteBlockStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - insertBlockStmt: q.insertBlockStmt, + db: tx, + tx: tx, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listWalletsStmt: q.listWalletsStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 77972c1619..771a6ebdf2 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -9,9 +9,18 @@ import ( ) type Querier interface { + CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int32) error GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) + GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) + GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) + GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error + InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error + InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + ListWallets(ctx context.Context) ([]ListWalletsRow, error) + UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) + UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } var _ Querier = (*Queries)(nil) diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go new file mode 100644 index 0000000000..ed8b84fa59 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -0,0 +1,398 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: wallets.sql + +package sqlcpg + +import ( + "context" + "database/sql" +) + +const CreateWallet = `-- name: CreateWallet :one +INSERT INTO wallets ( + name, + is_imported, + manager_version, + is_watch_only, + master_pub_params, + encrypted_crypto_pub_key, + encrypted_master_hd_pub_key +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) +RETURNING id +` + +type CreateWalletParams struct { + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + MasterPubParams []byte + EncryptedCryptoPubKey []byte + EncryptedMasterHdPubKey []byte +} + +func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) { + row := q.queryRow(ctx, q.createWalletStmt, CreateWallet, + arg.Name, + arg.IsImported, + arg.ManagerVersion, + arg.IsWatchOnly, + arg.MasterPubParams, + arg.EncryptedCryptoPubKey, + arg.EncryptedMasterHdPubKey, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const GetWalletByID = `-- name: GetWalletByID :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.id = $1 +` + +type GetWalletByIDRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) { + row := q.queryRow(ctx, q.getWalletByIDStmt, GetWalletByID, id) + var i GetWalletByIDRow + err := row.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ) + return i, err +} + +const GetWalletByName = `-- name: GetWalletByName :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.name = $1 +` + +type GetWalletByNameRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) { + row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, name) + var i GetWalletByNameRow + err := row.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ) + return i, err +} + +const GetWalletSecrets = `-- name: GetWalletSecrets :one +SELECT + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +FROM wallet_secrets +WHERE wallet_id = $1 +` + +func (q *Queries) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) { + row := q.queryRow(ctx, q.getWalletSecretsStmt, GetWalletSecrets, walletID) + var i WalletSecret + err := row.Scan( + &i.WalletID, + &i.MasterPrivParams, + &i.EncryptedCryptoPrivKey, + &i.EncryptedCryptoScriptKey, + &i.EncryptedMasterHdPrivKey, + ) + return i, err +} + +const InsertWalletSecrets = `-- name: InsertWalletSecrets :exec +INSERT INTO wallet_secrets ( + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +) VALUES ( + $1, $2, $3, $4, $5 +) +` + +type InsertWalletSecretsParams struct { + WalletID int64 + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte +} + +func (q *Queries) InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error { + _, err := q.exec(ctx, q.insertWalletSecretsStmt, InsertWalletSecrets, + arg.WalletID, + arg.MasterPrivParams, + arg.EncryptedCryptoPrivKey, + arg.EncryptedCryptoScriptKey, + arg.EncryptedMasterHdPrivKey, + ) + return err +} + +const InsertWalletSyncState = `-- name: InsertWalletSyncState :exec +INSERT INTO wallet_sync_states ( + wallet_id, + synced_height, + birthday_height, + birthday_verified, + updated_at +) VALUES ( + $1, $2, $3, $4, CURRENT_TIMESTAMP +) +` + +type InsertWalletSyncStateParams struct { + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified bool +} + +func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { + _, err := q.exec(ctx, q.insertWalletSyncStateStmt, InsertWalletSyncState, + arg.WalletID, + arg.SyncedHeight, + arg.BirthdayHeight, + arg.BirthdayVerified, + ) + return err +} + +const ListWallets = `-- name: ListWallets :many +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +ORDER BY w.id +` + +type ListWalletsRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int32 + IsWatchOnly bool + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { + rows, err := q.query(ctx, q.listWalletsStmt, ListWallets) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListWalletsRow + for rows.Next() { + var i ListWalletsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ); 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 UpdateWalletSecrets = `-- name: UpdateWalletSecrets :execrows +UPDATE wallet_secrets +SET + master_priv_params = $1, + encrypted_crypto_priv_key = $2, + encrypted_crypto_script_key = $3, + encrypted_master_hd_priv_key = $4 +WHERE wallet_id = $5 +` + +type UpdateWalletSecretsParams struct { + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte + WalletID int64 +} + +func (q *Queries) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) { + result, err := q.exec(ctx, q.updateWalletSecretsStmt, UpdateWalletSecrets, + arg.MasterPrivParams, + arg.EncryptedCryptoPrivKey, + arg.EncryptedCryptoScriptKey, + arg.EncryptedMasterHdPrivKey, + arg.WalletID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateWalletSyncState = `-- name: UpdateWalletSyncState :execrows +UPDATE wallet_sync_states +SET + -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. + synced_height = COALESCE($2, synced_height), + + -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. + birthday_height = COALESCE($3, birthday_height), + + -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. + birthday_verified = COALESCE($4, birthday_verified), + + -- Always update timestamp to current database time. + updated_at = CURRENT_TIMESTAMP +WHERE + wallet_id = $1 +` + +type UpdateWalletSyncStateParams struct { + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayVerified sql.NullBool +} + +func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { + result, err := q.exec(ctx, q.updateWalletSyncStateStmt, UpdateWalletSyncState, + arg.WalletID, + arg.SyncedHeight, + arg.BirthdayHeight, + arg.BirthdayVerified, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 44f87a934f..c7080a445a 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,20 +24,52 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createWalletStmt, err = db.PrepareContext(ctx, CreateWallet); err != nil { + return nil, fmt.Errorf("error preparing query CreateWallet: %w", err) + } if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } + if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) + } + if q.getWalletByNameStmt, err = db.PrepareContext(ctx, GetWalletByName); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletByName: %w", err) + } + if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) + } if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } + if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) + } + if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { + return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) + } + if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { + return nil, fmt.Errorf("error preparing query ListWallets: %w", err) + } + if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { + return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) + } + if q.updateWalletSyncStateStmt, err = db.PrepareContext(ctx, UpdateWalletSyncState); err != nil { + return nil, fmt.Errorf("error preparing query UpdateWalletSyncState: %w", err) + } return &q, nil } func (q *Queries) Close() error { var err error + if q.createWalletStmt != nil { + if cerr := q.createWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createWalletStmt: %w", cerr) + } + } if q.deleteBlockStmt != nil { if cerr := q.deleteBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) @@ -48,11 +80,51 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) } } + if q.getWalletByIDStmt != nil { + if cerr := q.getWalletByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) + } + } + if q.getWalletByNameStmt != nil { + if cerr := q.getWalletByNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletByNameStmt: %w", cerr) + } + } + if q.getWalletSecretsStmt != nil { + if cerr := q.getWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) + } + } if q.insertBlockStmt != nil { if cerr := q.insertBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) } } + if q.insertWalletSecretsStmt != nil { + if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) + } + } + if q.insertWalletSyncStateStmt != nil { + if cerr := q.insertWalletSyncStateStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) + } + } + if q.listWalletsStmt != nil { + if cerr := q.listWalletsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) + } + } + if q.updateWalletSecretsStmt != nil { + if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) + } + } + if q.updateWalletSyncStateStmt != nil { + if cerr := q.updateWalletSyncStateStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateWalletSyncStateStmt: %w", cerr) + } + } return err } @@ -90,19 +162,37 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - deleteBlockStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - insertBlockStmt *sql.Stmt + db DBTX + tx *sql.Tx + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - deleteBlockStmt: q.deleteBlockStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - insertBlockStmt: q.insertBlockStmt, + db: tx, + tx: tx, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listWalletsStmt: q.listWalletsStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 8f91c3cb17..61cc0cbcfb 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -9,9 +9,18 @@ import ( ) type Querier interface { + CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int64) error GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) + GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) + GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) + GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error + InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error + InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + ListWallets(ctx context.Context) ([]ListWalletsRow, error) + UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) + UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } var _ Querier = (*Queries)(nil) diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go new file mode 100644 index 0000000000..289896088a --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -0,0 +1,398 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: wallets.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" +) + +const CreateWallet = `-- name: CreateWallet :one +INSERT INTO wallets ( + name, + is_imported, + manager_version, + is_watch_only, + master_pub_params, + encrypted_crypto_pub_key, + encrypted_master_hd_pub_key +) VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING id +` + +type CreateWalletParams struct { + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + MasterPubParams []byte + EncryptedCryptoPubKey []byte + EncryptedMasterHdPubKey []byte +} + +func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) { + row := q.queryRow(ctx, q.createWalletStmt, CreateWallet, + arg.Name, + arg.IsImported, + arg.ManagerVersion, + arg.IsWatchOnly, + arg.MasterPubParams, + arg.EncryptedCryptoPubKey, + arg.EncryptedMasterHdPubKey, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const GetWalletByID = `-- name: GetWalletByID :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.id = ? +` + +type GetWalletByIDRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) { + row := q.queryRow(ctx, q.getWalletByIDStmt, GetWalletByID, id) + var i GetWalletByIDRow + err := row.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ) + return i, err +} + +const GetWalletByName = `-- name: GetWalletByName :one +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.name = ? +` + +type GetWalletByNameRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) { + row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, name) + var i GetWalletByNameRow + err := row.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ) + return i, err +} + +const GetWalletSecrets = `-- name: GetWalletSecrets :one +SELECT + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +FROM wallet_secrets +WHERE wallet_id = ? +` + +func (q *Queries) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) { + row := q.queryRow(ctx, q.getWalletSecretsStmt, GetWalletSecrets, walletID) + var i WalletSecret + err := row.Scan( + &i.WalletID, + &i.MasterPrivParams, + &i.EncryptedCryptoPrivKey, + &i.EncryptedCryptoScriptKey, + &i.EncryptedMasterHdPrivKey, + ) + return i, err +} + +const InsertWalletSecrets = `-- name: InsertWalletSecrets :exec +INSERT INTO wallet_secrets ( + wallet_id, + master_priv_params, + encrypted_crypto_priv_key, + encrypted_crypto_script_key, + encrypted_master_hd_priv_key +) VALUES ( + ?, ?, ?, ?, ? +) +` + +type InsertWalletSecretsParams struct { + WalletID int64 + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte +} + +func (q *Queries) InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error { + _, err := q.exec(ctx, q.insertWalletSecretsStmt, InsertWalletSecrets, + arg.WalletID, + arg.MasterPrivParams, + arg.EncryptedCryptoPrivKey, + arg.EncryptedCryptoScriptKey, + arg.EncryptedMasterHdPrivKey, + ) + return err +} + +const InsertWalletSyncState = `-- name: InsertWalletSyncState :exec +INSERT INTO wallet_sync_states ( + wallet_id, + synced_height, + birthday_height, + birthday_verified, + updated_at +) VALUES ( + ?, ?, ?, ?, CURRENT_TIMESTAMP +) +` + +type InsertWalletSyncStateParams struct { + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified bool +} + +func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { + _, err := q.exec(ctx, q.insertWalletSyncStateStmt, InsertWalletSyncState, + arg.WalletID, + arg.SyncedHeight, + arg.BirthdayHeight, + arg.BirthdayVerified, + ) + return err +} + +const ListWallets = `-- name: ListWallets :many +SELECT + w.id, + w.name, + w.is_imported, + w.manager_version, + w.is_watch_only, + s.synced_height, + s.birthday_height, + s.birthday_verified, + s.updated_at, + b_synced.header_hash AS synced_block_hash, + b_synced.timestamp AS synced_block_timestamp, + b_birthday.header_hash AS birthday_block_hash, + b_birthday.timestamp AS birthday_block_timestamp +FROM wallets w +LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id +LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height +ORDER BY w.id +` + +type ListWalletsRow struct { + ID int64 + Name string + IsImported bool + ManagerVersion int64 + IsWatchOnly bool + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified sql.NullBool + UpdatedAt sql.NullTime + SyncedBlockHash []byte + SyncedBlockTimestamp sql.NullInt64 + BirthdayBlockHash []byte + BirthdayBlockTimestamp sql.NullInt64 +} + +func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { + rows, err := q.query(ctx, q.listWalletsStmt, ListWallets) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListWalletsRow + for rows.Next() { + var i ListWalletsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.IsImported, + &i.ManagerVersion, + &i.IsWatchOnly, + &i.SyncedHeight, + &i.BirthdayHeight, + &i.BirthdayVerified, + &i.UpdatedAt, + &i.SyncedBlockHash, + &i.SyncedBlockTimestamp, + &i.BirthdayBlockHash, + &i.BirthdayBlockTimestamp, + ); 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 UpdateWalletSecrets = `-- name: UpdateWalletSecrets :execrows +UPDATE wallet_secrets +SET + master_priv_params = ?, + encrypted_crypto_priv_key = ?, + encrypted_crypto_script_key = ?, + encrypted_master_hd_priv_key = ? +WHERE wallet_id = ? +` + +type UpdateWalletSecretsParams struct { + MasterPrivParams []byte + EncryptedCryptoPrivKey []byte + EncryptedCryptoScriptKey []byte + EncryptedMasterHdPrivKey []byte + WalletID int64 +} + +func (q *Queries) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) { + result, err := q.exec(ctx, q.updateWalletSecretsStmt, UpdateWalletSecrets, + arg.MasterPrivParams, + arg.EncryptedCryptoPrivKey, + arg.EncryptedCryptoScriptKey, + arg.EncryptedMasterHdPrivKey, + arg.WalletID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateWalletSyncState = `-- name: UpdateWalletSyncState :execrows +UPDATE wallet_sync_states +SET + -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. + synced_height = COALESCE(?1, synced_height), + + -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. + birthday_height = COALESCE(?2, birthday_height), + + -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. + birthday_verified = COALESCE(?3, birthday_verified), + + -- Always update timestamp to current database time. + updated_at = CURRENT_TIMESTAMP +WHERE + wallet_id = ?4 +` + +type UpdateWalletSyncStateParams struct { + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayVerified sql.NullBool + WalletID int64 +} + +func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { + result, err := q.exec(ctx, q.updateWalletSyncStateStmt, UpdateWalletSyncState, + arg.SyncedHeight, + arg.BirthdayHeight, + arg.BirthdayVerified, + arg.WalletID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} From 8cd817dff0ca6f9dc4b7eb33a8e28703d1f6c77e Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 25 Nov 2025 02:16:39 -0300 Subject: [PATCH 333/691] wallet: add WalletStore implementation --- wallet/internal/db/block_common.go | 23 ++ wallet/internal/db/block_pg.go | 19 ++ wallet/internal/db/block_sqlite.go | 19 ++ wallet/internal/db/interface.go | 8 + wallet/internal/db/wallet_pg.go | 335 ++++++++++++++++++++++++++++ wallet/internal/db/wallet_sqlite.go | 334 +++++++++++++++++++++++++++ 6 files changed, 738 insertions(+) create mode 100644 wallet/internal/db/block_common.go create mode 100644 wallet/internal/db/block_pg.go create mode 100644 wallet/internal/db/block_sqlite.go create mode 100644 wallet/internal/db/wallet_pg.go create mode 100644 wallet/internal/db/wallet_sqlite.go diff --git a/wallet/internal/db/block_common.go b/wallet/internal/db/block_common.go new file mode 100644 index 0000000000..507315bc88 --- /dev/null +++ b/wallet/internal/db/block_common.go @@ -0,0 +1,23 @@ +package db + +import ( + "fmt" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" +) + +// buildBlock constructs a Block from the provided components that are common +// across different database backends. +func buildBlock(hash []byte, height uint32, timestamp int64) (*Block, error) { + h, err := chainhash.NewHash(hash) + if err != nil { + return nil, fmt.Errorf("block hash: %w", err) + } + + return &Block{ + Hash: *h, + Height: height, + Timestamp: time.Unix(timestamp, 0), + }, nil +} diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/block_pg.go new file mode 100644 index 0000000000..2cd05f3608 --- /dev/null +++ b/wallet/internal/db/block_pg.go @@ -0,0 +1,19 @@ +package db + +import ( + "database/sql" + "fmt" +) + +// buildPgBlock constructs a Block from the given PostgreSQL block +// fields. +func buildPgBlock(height sql.NullInt32, hash []byte, + timestamp sql.NullInt64) (*Block, error) { + + height32, err := nullInt32ToUint32(height) + if err != nil { + return nil, fmt.Errorf("block height: %w", err) + } + + return buildBlock(hash, height32, timestamp.Int64) +} diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/block_sqlite.go new file mode 100644 index 0000000000..7df790749c --- /dev/null +++ b/wallet/internal/db/block_sqlite.go @@ -0,0 +1,19 @@ +package db + +import ( + "database/sql" + "fmt" +) + +// buildSqliteBlock constructs a Block from the given SQLite block +// fields. +func buildSqliteBlock(height sql.NullInt64, hash []byte, + timestamp sql.NullInt64) (*Block, error) { + + height32, err := int64ToUint32(height.Int64) + if err != nil { + return nil, fmt.Errorf("block height: %w", err) + } + + return buildBlock(hash, height32, timestamp.Int64) +} diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 5f9c318a3c..09f96dab72 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -9,6 +9,14 @@ import ( ) var ( + // ErrWalletNotFound is returned when a wallet is not found in the + // database. + ErrWalletNotFound = errors.New("wallet not found") + + // ErrSecretNotFound is returned when a secret is not found or is empty + // in the database. + ErrSecretNotFound = errors.New("secret not found") + // ErrNilDB is returned when a nil database connection pointer is // provided to the wallet. ErrNilDB = errors.New( diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go new file mode 100644 index 0000000000..4658c15e74 --- /dev/null +++ b/wallet/internal/db/wallet_pg.go @@ -0,0 +1,335 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// Ensure PostgresWalletDB satisfies the WalletStore interface. +var _ WalletStore = (*PostgresWalletDB)(nil) + +// CreateWallet creates a new wallet in the database with the provided +// parameters. It returns the created wallet info or an error if the +// creation fails. +func (w *PostgresWalletDB) CreateWallet(ctx context.Context, + params CreateWalletParams) (*WalletInfo, error) { + + var info *WalletInfo + + err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + walletParams := sqlcpg.CreateWalletParams{ + Name: params.Name, + IsImported: params.IsImported, + ManagerVersion: params.ManagerVersion, + IsWatchOnly: params.IsWatchOnly, + MasterPubParams: params.MasterKeyPubParams, + EncryptedCryptoPubKey: params.EncryptedCryptoPubKey, + EncryptedMasterHdPubKey: params.EncryptedMasterPubKey, + } + + id, err := qtx.CreateWallet(ctx, walletParams) + if err != nil { + return fmt.Errorf("create wallet: %w", err) + } + + secretsParams := sqlcpg.InsertWalletSecretsParams{ + WalletID: id, + MasterPrivParams: params.MasterKeyPrivParams, + EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, + EncryptedCryptoScriptKey: params. + EncryptedCryptoScriptKey, + EncryptedMasterHdPrivKey: params.EncryptedMasterPrivKey, + } + + err = qtx.InsertWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf( + "insert wallet secrets: %w", err, + ) + } + + syncParams := sqlcpg.InsertWalletSyncStateParams{ + WalletID: id, + SyncedHeight: sql.NullInt32{}, + BirthdayHeight: sql.NullInt32{}, + BirthdayVerified: false, + } + + err = qtx.InsertWalletSyncState(ctx, syncParams) + if err != nil { + return fmt.Errorf( + "upsert wallet sync state: %w", err, + ) + } + + row, err := qtx.GetWalletByID(ctx, id) + if err != nil { + return fmt.Errorf( + "fetch created wallet: %w", err, + ) + } + + info, err = buildPgWalletInfo(pgWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) + if err != nil { + return fmt.Errorf("convert wallet row to info: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + return info, nil +} + +// GetWallet retrieves information about a wallet given its name. It +// returns a WalletInfo struct containing the wallet's properties or an +// error if the wallet is not found. +func (w *PostgresWalletDB) GetWallet(ctx context.Context, + name string) (*WalletInfo, error) { + + row, err := w.queries.GetWalletByName(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("wallet %q: %w", name, + ErrWalletNotFound) + } + + return nil, fmt.Errorf("get wallet: %w", err) + } + + return buildPgWalletInfo(pgWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) +} + +// ListWallets returns a slice of WalletInfo for all wallets stored in +// the database. It returns an empty slice if no wallets are found, or +// an error if the retrieval fails. +func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, + error) { + + rows, err := w.queries.ListWallets(ctx) + if err != nil { + return nil, fmt.Errorf("list wallets: %w", err) + } + + wallets := make([]WalletInfo, len(rows)) + for i, row := range rows { + info, err := buildPgWalletInfo(pgWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) + if err != nil { + return nil, err + } + + wallets[i] = *info + } + + return wallets, nil +} + +// UpdateWallet updates various properties of a wallet, such as its +// birthday, birthday block, or sync state. The specific fields to +// update are provided in the UpdateWalletParams struct. It returns an +// error if the update fails. +func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, + params UpdateWalletParams) error { + + return w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + // Build sync state update params directly from input. + // Only set fields that are being updated (leave others as nil). + syncParams := sqlcpg.UpdateWalletSyncStateParams{ + WalletID: int64(params.WalletID), + } + + if params.SyncedTo != nil { + syncedHeight, err := uint32ToNullInt32( + params.SyncedTo.Height) + if err != nil { + return err + } + + syncParams.SyncedHeight = syncedHeight + } + + if params.BirthdayBlock != nil { + birthdayHeight, err := uint32ToNullInt32( + params.BirthdayBlock.Height) + if err != nil { + return err + } + + syncParams.BirthdayHeight = birthdayHeight + + // Set birthday verified flag to true if the birthday + // block is set. + syncParams.BirthdayVerified = sql.NullBool{ + Bool: true, + Valid: true, + } + } + + rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) + if err != nil { + return fmt.Errorf("update wallet sync state: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("wallet sync state for wallet %d: %w", + params.WalletID, ErrWalletNotFound) + } + + return nil + }) +} + +// GetEncryptedHDSeed retrieves the encrypted Hierarchical +// Deterministic (HD) seed of the wallet. This seed is sensitive +// information and is returned in its encrypted form. It returns the +// encrypted seed as a byte slice or an error if the retrieval fails. +func (w *PostgresWalletDB) GetEncryptedHDSeed(ctx context.Context, + walletID uint32) ([]byte, error) { + + secrets, err := w.queries.GetWalletSecrets(ctx, int64(walletID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("secrets for wallet %d: %w", + walletID, ErrWalletNotFound) + } + + return nil, fmt.Errorf("get wallet secrets: %w", err) + } + + if len(secrets.EncryptedMasterHdPrivKey) == 0 { + return nil, fmt.Errorf( + "encrypted master privkey for wallet %d: %w", walletID, + ErrSecretNotFound) + } + + return secrets.EncryptedMasterHdPrivKey, nil +} + +// UpdateWalletSecrets updates the secrets for the wallet. +func (w *PostgresWalletDB) UpdateWalletSecrets(ctx context.Context, + params UpdateWalletSecretsParams) error { + + secretsParams := sqlcpg.UpdateWalletSecretsParams{ + MasterPrivParams: params.MasterPrivParams, + EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, + EncryptedCryptoScriptKey: params.EncryptedCryptoScriptKey, + EncryptedMasterHdPrivKey: params.EncryptedMasterHdPrivKey, + WalletID: int64(params.WalletID), + } + + rowsAffected, err := w.queries.UpdateWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf("update wallet secrets: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("wallet secrets for wallet %d: %w", + params.WalletID, ErrWalletNotFound) + } + + return nil +} + +// pgWalletRowParams holds the parameters needed to build a WalletInfo +// from a wallet row. +type pgWalletRowParams struct { + id int64 + name string + isImported bool + managerVersion int32 + isWatchOnly bool + syncedHeight sql.NullInt32 + syncedBlockHash []byte + syncedBlockTimestamp sql.NullInt64 + birthdayHeight sql.NullInt32 + birthdayBlockHash []byte + birthdayBlockTimestamp sql.NullInt64 +} + +// buildPgWalletInfo constructs a WalletInfo from the given wallet row +// parameters. +func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { + walletID, err := int64ToUint32(row.id) + if err != nil { + return nil, err + } + + info := &WalletInfo{ + ID: walletID, + Name: row.name, + IsImported: row.isImported, + ManagerVersion: row.managerVersion, + IsWatchOnly: row.isWatchOnly, + } + + if row.syncedHeight.Valid { + block, err := buildPgBlock( + row.syncedHeight, + row.syncedBlockHash, + row.syncedBlockTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("synced block: %w", err) + } + + info.SyncedTo = block + } + + if row.birthdayHeight.Valid { + block, err := buildPgBlock( + row.birthdayHeight, + row.birthdayBlockHash, + row.birthdayBlockTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("birthday block: %w", err) + } + + info.BirthdayBlock = *block + info.Birthday = block.Timestamp + } + + return info, nil +} diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go new file mode 100644 index 0000000000..ffed6a16f8 --- /dev/null +++ b/wallet/internal/db/wallet_sqlite.go @@ -0,0 +1,334 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// Ensure SQLiteWalletDB satisfies the WalletStore interface. +var _ WalletStore = (*SQLiteWalletDB)(nil) + +// CreateWallet creates a new wallet in the database with the provided +// parameters. It returns the created wallet info or an error if the +// creation fails. +func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, + params CreateWalletParams) (*WalletInfo, error) { + + var info *WalletInfo + + err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + walletParams := sqlcsqlite.CreateWalletParams{ + Name: params.Name, + IsImported: params.IsImported, + ManagerVersion: int64(params.ManagerVersion), + IsWatchOnly: params.IsWatchOnly, + MasterPubParams: params.MasterKeyPubParams, + EncryptedCryptoPubKey: params.EncryptedCryptoPubKey, + EncryptedMasterHdPubKey: params.EncryptedMasterPubKey, + } + + id, err := qtx.CreateWallet(ctx, walletParams) + if err != nil { + return fmt.Errorf("create wallet: %w", err) + } + + secretsParams := sqlcsqlite.InsertWalletSecretsParams{ + WalletID: id, + MasterPrivParams: params.MasterKeyPrivParams, + EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, + EncryptedCryptoScriptKey: params. + EncryptedCryptoScriptKey, + EncryptedMasterHdPrivKey: params.EncryptedMasterPrivKey, + } + + err = qtx.InsertWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf( + "insert wallet secrets: %w", err, + ) + } + + syncParams := sqlcsqlite.InsertWalletSyncStateParams{ + WalletID: id, + SyncedHeight: sql.NullInt64{}, + BirthdayHeight: sql.NullInt64{}, + BirthdayVerified: false, + } + + err = qtx.InsertWalletSyncState(ctx, syncParams) + if err != nil { + return fmt.Errorf( + "upsert wallet sync state: %w", err, + ) + } + + row, err := qtx.GetWalletByID(ctx, id) + if err != nil { + return fmt.Errorf( + "fetch created wallet: %w", err, + ) + } + + info, err = buildSqliteWalletInfo(sqliteWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) + if err != nil { + return fmt.Errorf("convert wallet row to info: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + return info, nil +} + +// GetWallet retrieves information about a wallet given its name. It +// returns a WalletInfo struct containing the wallet's properties or an +// error if the wallet is not found. +func (w *SQLiteWalletDB) GetWallet(ctx context.Context, + name string) (*WalletInfo, error) { + + row, err := w.queries.GetWalletByName(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("wallet %q: %w", name, + ErrWalletNotFound) + } + + return nil, fmt.Errorf("get wallet: %w", err) + } + + return buildSqliteWalletInfo(sqliteWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) +} + +// ListWallets returns a slice of WalletInfo for all wallets stored in +// the database. It returns an empty slice if no wallets are found, or +// an error if the retrieval fails. +func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, + error) { + + rows, err := w.queries.ListWallets(ctx) + if err != nil { + return nil, fmt.Errorf("list wallets: %w", err) + } + + wallets := make([]WalletInfo, len(rows)) + for i, row := range rows { + info, err := buildSqliteWalletInfo(sqliteWalletRowParams{ + id: row.ID, + name: row.Name, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) + if err != nil { + return nil, err + } + + wallets[i] = *info + } + + return wallets, nil +} + +// UpdateWallet updates various properties of a wallet, such as its +// birthday, birthday block, or sync state. The specific fields to +// update are provided in the UpdateWalletParams struct. It returns an +// error if the update fails. +func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, + params UpdateWalletParams) error { + + return w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + // Build sync state update params directly from input. + // Only set fields that are being updated (leave others as nil). + syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ + WalletID: int64(params.WalletID), + } + + if params.SyncedTo != nil { + syncParams.SyncedHeight = sql.NullInt64{ + Int64: int64(params.SyncedTo.Height), + Valid: true, + } + } + + if params.BirthdayBlock != nil { + syncParams.BirthdayHeight = sql.NullInt64{ + Int64: int64(params.BirthdayBlock.Height), + Valid: true, + } + + // Set birthday verified flag to true if the birthday + // block is set. + syncParams.BirthdayVerified = sql.NullBool{ + Bool: true, + Valid: true, + } + } + + rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) + if err != nil { + return fmt.Errorf("update wallet sync state: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("wallet sync state for wallet %d: %w", + params.WalletID, ErrWalletNotFound) + } + + return nil + }) +} + +// GetEncryptedHDSeed retrieves the encrypted Hierarchical +// Deterministic (HD) seed of the wallet. This seed is sensitive +// information and is returned in its encrypted form. It returns the +// encrypted seed as a byte slice or an error if the retrieval fails. +func (w *SQLiteWalletDB) GetEncryptedHDSeed(ctx context.Context, + walletID uint32) ([]byte, error) { + + secrets, err := w.queries.GetWalletSecrets(ctx, int64(walletID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("secrets for wallet %d: %w", + walletID, ErrWalletNotFound) + } + + return nil, fmt.Errorf("get wallet secrets: %w", err) + } + + if len(secrets.EncryptedMasterHdPrivKey) == 0 { + return nil, fmt.Errorf( + "encrypted master privkey for wallet %d: %w", walletID, + ErrSecretNotFound) + } + + return secrets.EncryptedMasterHdPrivKey, nil +} + +// UpdateWalletSecrets updates the secrets for the wallet. +func (w *SQLiteWalletDB) UpdateWalletSecrets(ctx context.Context, + params UpdateWalletSecretsParams) error { + + secretsParams := sqlcsqlite.UpdateWalletSecretsParams{ + MasterPrivParams: params.MasterPrivParams, + EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, + EncryptedCryptoScriptKey: params.EncryptedCryptoScriptKey, + EncryptedMasterHdPrivKey: params.EncryptedMasterHdPrivKey, + WalletID: int64(params.WalletID), + } + + rowsAffected, err := w.queries.UpdateWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf("update wallet secrets: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("wallet secrets for wallet %d: %w", + params.WalletID, ErrWalletNotFound) + } + + return nil +} + +// sqliteWalletRowParams holds the parameters needed to build a +// WalletInfo from a wallet row. +type sqliteWalletRowParams struct { + id int64 + name string + isImported bool + managerVersion int64 + isWatchOnly bool + syncedHeight sql.NullInt64 + syncedBlockHash []byte + syncedBlockTimestamp sql.NullInt64 + birthdayHeight sql.NullInt64 + birthdayBlockHash []byte + birthdayBlockTimestamp sql.NullInt64 +} + +// buildSqliteWalletInfo constructs a WalletInfo from the given wallet +// row parameters. +func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { + walletID, err := int64ToUint32(row.id) + if err != nil { + return nil, err + } + + mgrVer, err := int64ToInt32(row.managerVersion) + if err != nil { + return nil, err + } + + info := &WalletInfo{ + ID: walletID, + Name: row.name, + IsImported: row.isImported, + ManagerVersion: mgrVer, + IsWatchOnly: row.isWatchOnly, + } + + if row.syncedHeight.Valid { + block, err := buildSqliteBlock( + row.syncedHeight, + row.syncedBlockHash, + row.syncedBlockTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("synced block: %w", err) + } + + info.SyncedTo = block + } + + if row.birthdayHeight.Valid { + block, err := buildSqliteBlock( + row.birthdayHeight, + row.birthdayBlockHash, + row.birthdayBlockTimestamp, + ) + if err != nil { + return nil, fmt.Errorf("birthday block: %w", err) + } + + info.BirthdayBlock = *block + info.Birthday = block.Timestamp + } + + return info, nil +} From d1507ceb8bcd217dd3a9f7f6bee077ff6e6f4a0e Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 1 Dec 2025 14:44:07 -0300 Subject: [PATCH 334/691] wallet: add WalletStore integration tests --- wallet/internal/db/itest/fixtures_test.go | 100 ++++++ wallet/internal/db/itest/wallet_store_test.go | 301 ++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 wallet/internal/db/itest/fixtures_test.go create mode 100644 wallet/internal/db/itest/wallet_store_test.go diff --git a/wallet/internal/db/itest/fixtures_test.go b/wallet/internal/db/itest/fixtures_test.go new file mode 100644 index 0000000000..524cc09e05 --- /dev/null +++ b/wallet/internal/db/itest/fixtures_test.go @@ -0,0 +1,100 @@ +package itest + +import ( + "crypto/rand" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// CreateWalletParamsFixture creates test parameters for wallet creation. +func CreateWalletParamsFixture(name string) db.CreateWalletParams { + return db.CreateWalletParams{ + Name: name, + IsImported: false, + ManagerVersion: 1, + IsWatchOnly: false, + EncryptedMasterPrivKey: RandomBytes(32), + EncryptedMasterPubKey: RandomBytes(32), + MasterKeyPubParams: RandomBytes(16), + MasterKeyPrivParams: RandomBytes(16), + EncryptedCryptoPrivKey: RandomBytes(32), + EncryptedCryptoPubKey: RandomBytes(32), + EncryptedCryptoScriptKey: RandomBytes(32), + } +} + +// CreateImportedWalletParams creates test parameters for an imported wallet. +func CreateImportedWalletParams(name string) db.CreateWalletParams { + params := CreateWalletParamsFixture(name) + params.IsImported = true + + return params +} + +// CreateWatchOnlyWalletParams creates test parameters for a watch-only wallet. +func CreateWatchOnlyWalletParams(name string) db.CreateWalletParams { + params := CreateWalletParamsFixture(name) + params.IsWatchOnly = true + params.EncryptedMasterPrivKey = nil + params.MasterKeyPrivParams = nil + params.EncryptedCryptoPrivKey = nil + params.EncryptedCryptoScriptKey = nil + + return params +} + +// CreateBlockFixture inserts a test block into the database and returns it. +func CreateBlockFixture(t *testing.T, dbConn *sql.DB, height uint32) db.Block { + t.Helper() + + hash := RandomHash() + timestamp := time.Now().UTC() + + // TODO(gustavostingelin): use the block store to insert the block when + // available. + query := ` + INSERT INTO blocks (block_height, header_hash, timestamp) + VALUES ($1, $2, $3) + ` + _, err := dbConn.ExecContext(t.Context(), query, + height, hash[:], timestamp.Unix()) + require.NoError(t, err, "failed to insert block") + + return db.Block{ + Hash: hash, + Height: height, + Timestamp: timestamp, + } +} + +// RandomBytes generates random bytes for test data. +func RandomBytes(n int) []byte { + b := make([]byte, n) + + _, err := rand.Read(b) + if err != nil { + // This should never happen. + panic(fmt.Sprintf("failed to generate random bytes: %v", err)) + } + + return b +} + +// RandomHash generates a random chainhash.Hash for testing. +func RandomHash() chainhash.Hash { + var h chainhash.Hash + + _, err := rand.Read(h[:]) + if err != nil { + // This should never happen. + panic(fmt.Sprintf("failed to generate random hash: %v", err)) + } + + return h +} diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go new file mode 100644 index 0000000000..e19881198d --- /dev/null +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -0,0 +1,301 @@ +//go:build itest + +package itest + +import ( + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateWallet verifies that CreateWallet correctly creates a wallet +// and returns its information. +func TestCreateWallet(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + params := CreateWalletParamsFixture("test-wallet") + + info, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, info) + + require.Equal(t, info.ID, uint32(1), "first wallet ID should be 1") + require.Equal(t, params.Name, info.Name) + require.Equal(t, params.IsImported, info.IsImported) + require.Equal(t, params.ManagerVersion, info.ManagerVersion) + require.Equal(t, params.IsWatchOnly, info.IsWatchOnly) + + require.Nil(t, info.SyncedTo) + require.Equal(t, uint32(0), info.BirthdayBlock.Height) +} + +// TestCreateWallet_DuplicateName verifies that creating a wallet with a +// duplicate name fails with an appropriate error. +func TestCreateWallet_DuplicateName(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + params := CreateWalletParamsFixture("duplicate-wallet") + + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + // Attempt to create second wallet with same name. + _, err = store.CreateWallet(t.Context(), params) + require.Error(t, err, "expected error creating duplicate wallet") + + // We still do not normalize this error across database backends, + // and each engine returns its own message. Because of that, + // we only check for the shared parts of the message here. + require.ErrorContains(t, err, "wallets") + require.ErrorContains(t, err, "name") + require.ErrorContains(t, err, "constraint") +} + +// TestCreateWallet_Variants tests different wallet types. +func TestCreateWallet_Variants(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params func(string) db.CreateWalletParams + }{ + { + name: "imported wallet", + params: CreateImportedWalletParams, + }, + { + name: "watch-only wallet", + params: CreateWatchOnlyWalletParams, + }, + { + name: "standard wallet", + params: CreateWalletParamsFixture, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + params := tc.params(tc.name) + + info, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, info) + require.Equal(t, params.IsImported, info.IsImported) + require.Equal(t, params.IsWatchOnly, info.IsWatchOnly) + }) + } +} + +// TestGetWallet verifies that GetWallet retrieves a wallet by name. +func TestGetWallet(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("get-test-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + retrieved, err := store.GetWallet(t.Context(), params.Name) + require.NoError(t, err) + require.NotNil(t, retrieved) + + require.Equal(t, created.ID, retrieved.ID) + require.Equal(t, created.Name, retrieved.Name) + require.Equal(t, created.IsImported, retrieved.IsImported) + require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) + require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) +} + +// TestGetWallet_NotFound verifies that GetWallet returns ErrWalletNotFound +// when the wallet doesn't exist. +func TestGetWallet_NotFound(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + _, err := store.GetWallet(t.Context(), "non-existent-wallet") + require.Error(t, err) + require.ErrorIs(t, err, db.ErrWalletNotFound) +} + +// TestListWallets verifies that ListWallets returns all wallets. +func TestListWallets(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + // Initially empty. + wallets, err := store.ListWallets(t.Context()) + require.NoError(t, err) + require.Empty(t, wallets) + + // Create three wallets. + names := []string{"wallet-1", "wallet-2", "wallet-3"} + for _, name := range names { + params := CreateWalletParamsFixture(name) + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + } + + wallets, err = store.ListWallets(t.Context()) + require.NoError(t, err) + require.Len(t, wallets, 3) + + // Verify all names are present. + walletsName := make([]string, len(wallets)) + for i, w := range wallets { + walletsName[i] = w.Name + } + require.ElementsMatch(t, names, walletsName) +} + +// TestUpdateWallet_SyncedTo checks that updating the wallet's synced-to block +// works correctly. +func TestUpdateWallet_SyncedTo(t *testing.T) { + t.Parallel() + + store, dbConn := NewTestStore(t) + + params := CreateWalletParamsFixture("update-sync-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + block := CreateBlockFixture(t, dbConn, 100) + + updateParams := db.UpdateWalletParams{ + WalletID: created.ID, + SyncedTo: &block, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + retrieved, err := store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.NotNil(t, retrieved.SyncedTo) + require.Equal(t, block.Height, retrieved.SyncedTo.Height) + + // Assert fields that were not updated remain unchanged. + require.Equal(t, created.ID, retrieved.ID) + require.Equal(t, created.Name, retrieved.Name) + require.Equal(t, created.IsImported, retrieved.IsImported) + require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) + require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) + require.Equal(t, created.BirthdayBlock.Height, + retrieved.BirthdayBlock.Height) +} + +// TestUpdateWallet_BirthdayBlock checks that updating the wallet's birthday +// block works correctly. +func TestUpdateWallet_BirthdayBlock(t *testing.T) { + t.Parallel() + + store, dbConn := NewTestStore(t) + + params := CreateWalletParamsFixture("update-birthday-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + block := CreateBlockFixture(t, dbConn, 50) + + updateParams := db.UpdateWalletParams{ + WalletID: created.ID, + BirthdayBlock: &block, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + retrieved, err := store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.Equal(t, block.Height, retrieved.BirthdayBlock.Height) + require.Equal(t, block.Hash, retrieved.BirthdayBlock.Hash) + + // Assert fields that were not updated remain unchanged. + require.Equal(t, created.ID, retrieved.ID) + require.Equal(t, created.Name, retrieved.Name) + require.Equal(t, created.IsImported, retrieved.IsImported) + require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) + require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) + require.Nil(t, retrieved.SyncedTo) +} + +// TestUpdateWallet_NotFound verifies that updating a non-existent wallet fails. +func TestUpdateWallet_NotFound(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + updateParams := db.UpdateWalletParams{ + WalletID: 99999, // Non-existent ID. + } + + err := store.UpdateWallet(t.Context(), updateParams) + require.Error(t, err) + require.ErrorIs(t, err, db.ErrWalletNotFound) +} + +// TestGetEncryptedHDSeed verifies retrieving the encrypted HD seed. +func TestGetEncryptedHDSeed(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("seed-wallet") + expectedSeed := params.EncryptedMasterPrivKey + + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + seed, err := store.GetEncryptedHDSeed(t.Context(), created.ID) + require.NoError(t, err) + require.Equal(t, expectedSeed, seed) +} + +// TestGetEncryptedHDSeed_WatchOnly verifies that watch-only wallets +// have no encrypted seed. +func TestGetEncryptedHDSeed_WatchOnly(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWatchOnlyWalletParams("watch-only-seed") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + seed, err := store.GetEncryptedHDSeed(t.Context(), created.ID) + require.Nil(t, seed, "watch-only wallets should not have HD seed") + require.ErrorIs(t, err, db.ErrSecretNotFound) +} + +// TestUpdateWalletSecrets checks that updating the wallet secrets works +// correctly. +func TestUpdateWalletSecrets(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("secrets-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + newSecrets := db.UpdateWalletSecretsParams{ + WalletID: created.ID, + MasterPrivParams: RandomBytes(16), + EncryptedCryptoPrivKey: RandomBytes(32), + EncryptedCryptoScriptKey: RandomBytes(32), + EncryptedMasterHdPrivKey: RandomBytes(32), + } + + err = store.UpdateWalletSecrets(t.Context(), newSecrets) + require.NoError(t, err) + + seed, err := store.GetEncryptedHDSeed(t.Context(), created.ID) + require.NoError(t, err) + require.Equal(t, newSecrets.EncryptedMasterHdPrivKey, seed) +} From 9205db146068de0da7b0101f6d82ec984360bcc1 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 11 Dec 2025 00:23:41 -0300 Subject: [PATCH 335/691] wallet: add user-provided birthday to wallet sql store --- wallet/internal/db/data_types.go | 23 +++++-- wallet/internal/db/itest/wallet_store_test.go | 67 +++++++++++++++++-- .../migrations/postgres/000002_wallets.up.sql | 7 +- .../migrations/sqlite/000002_wallets.up.sql | 7 +- .../internal/db/queries/postgres/wallets.sql | 12 ++-- wallet/internal/db/queries/sqlite/wallets.sql | 12 ++-- wallet/internal/db/sqlc/postgres/models.go | 10 +-- .../internal/db/sqlc/postgres/wallets.sql.go | 44 ++++++------ wallet/internal/db/sqlc/sqlite/models.go | 10 +-- wallet/internal/db/sqlc/sqlite/wallets.sql.go | 44 ++++++------ wallet/internal/db/wallet_pg.go | 41 ++++++++---- wallet/internal/db/wallet_sqlite.go | 41 ++++++++---- 12 files changed, 209 insertions(+), 109 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index b6ce94e3bf..881c9665af 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -167,13 +167,15 @@ type WalletInfo struct { // meaning it does not have private keys and cannot sign transactions. IsWatchOnly bool - // Birthday is the timestamp of the wallet's creation, used as a - // starting point for rescans. + // Birthday is the user-provided timestamp for when to start rescanning. + // This is stored directly in the database and may be zero if not set. + // If zero, means the wallet should be rescanned from the genesis block. Birthday time.Time - // BirthdayBlock is the block hash and height from which to start a - // rescan. - BirthdayBlock Block + // BirthdayBlock is the verified block reference for starting a rescan. + // When this is non-nil, it indicates the block has been verified. + // A nil value means the birthday block has not been set or verified. + BirthdayBlock *Block // SyncedTo represents the wallet's current synchronization state with // the blockchain. @@ -211,6 +213,11 @@ type CreateWalletParams struct { // watch-only mode. IsWatchOnly bool + // Birthday is the user-provided birthday timestamp for the wallet. + // The zero value is treated as "no birthday" and is stored as NULL in + // the database. + Birthday time.Time + // EncryptedMasterPrivKey is the encrypted master HD private key. EncryptedMasterPrivKey []byte @@ -247,10 +254,12 @@ type UpdateWalletParams struct { // databases (signed 64-bit integers). WalletID uint32 - // Birthday is the new birthday for the wallet. + // Birthday is the user-provided birthday timestamp for the wallet. + // Setting this does NOT set BirthdayBlock. Birthday *time.Time - // BirthdayBlock is the new birthday block for the wallet. + // BirthdayBlock is the verified birthday block for the wallet. + // When this is set, it indicates the block is already verified. BirthdayBlock *Block // SyncedTo is the new synchronization state for the wallet. diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index e19881198d..0af162cc41 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -4,6 +4,7 @@ package itest import ( "testing" + "time" "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" @@ -16,7 +17,6 @@ func TestCreateWallet(t *testing.T) { store, _ := NewTestStore(t) params := CreateWalletParamsFixture("test-wallet") - info, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) require.NotNil(t, info) @@ -28,7 +28,27 @@ func TestCreateWallet(t *testing.T) { require.Equal(t, params.IsWatchOnly, info.IsWatchOnly) require.Nil(t, info.SyncedTo) - require.Equal(t, uint32(0), info.BirthdayBlock.Height) + require.Nil(t, info.BirthdayBlock) + require.True(t, info.Birthday.IsZero()) +} + +// TestCreateWallet_WithBirthday checks that CreateWallet correctly sets the +// wallet's birthday timestamp. +func TestCreateWallet_WithBirthday(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("birthday-wallet") + birthday := time.Now().UTC().Add(-30 * 24 * time.Hour) + params.Birthday = birthday + + info, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, info) + + require.Equal(t, birthday.Unix(), info.Birthday.Unix()) + require.Nil(t, info.BirthdayBlock) } // TestCreateWallet_DuplicateName verifies that creating a wallet with a @@ -187,8 +207,7 @@ func TestUpdateWallet_SyncedTo(t *testing.T) { require.Equal(t, created.IsImported, retrieved.IsImported) require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) - require.Equal(t, created.BirthdayBlock.Height, - retrieved.BirthdayBlock.Height) + require.Nil(t, retrieved.BirthdayBlock) } // TestUpdateWallet_BirthdayBlock checks that updating the wallet's birthday @@ -202,6 +221,9 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { created, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) + // Initially, BirthdayBlock should be nil. + require.Nil(t, created.BirthdayBlock) + block := CreateBlockFixture(t, dbConn, 50) updateParams := db.UpdateWalletParams{ @@ -213,8 +235,44 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { retrieved, err := store.GetWallet(t.Context(), created.Name) require.NoError(t, err) + require.NotNil(t, retrieved.BirthdayBlock) require.Equal(t, block.Height, retrieved.BirthdayBlock.Height) require.Equal(t, block.Hash, retrieved.BirthdayBlock.Hash) + require.Equal(t, block.Timestamp.Unix(), + retrieved.BirthdayBlock.Timestamp.Unix()) + + // Assert fields that were not updated remain unchanged. + require.Equal(t, created.ID, retrieved.ID) + require.Equal(t, created.Name, retrieved.Name) + require.Equal(t, created.IsImported, retrieved.IsImported) + require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) + require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) + require.Nil(t, retrieved.SyncedTo) +} + +// TestUpdateWallet_Birthday checks that updating the wallet's birthday +// timestamp works correctly. +func TestUpdateWallet_Birthday(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("birthday-timestamp-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + // Set birthday timestamp without setting birthday block. + birthdayTime := time.Now().UTC().Add(-30 * 24 * time.Hour) + updateParams := db.UpdateWalletParams{ + WalletID: created.ID, + Birthday: &birthdayTime, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + retrieved, err := store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.Equal(t, birthdayTime.Unix(), retrieved.Birthday.Unix()) // Assert fields that were not updated remain unchanged. require.Equal(t, created.ID, retrieved.ID) @@ -222,6 +280,7 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { require.Equal(t, created.IsImported, retrieved.IsImported) require.Equal(t, created.ManagerVersion, retrieved.ManagerVersion) require.Equal(t, created.IsWatchOnly, retrieved.IsWatchOnly) + require.Nil(t, retrieved.BirthdayBlock) require.Nil(t, retrieved.SyncedTo) } diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql index b53bea515f..6bf74e4a06 100644 --- a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -78,11 +78,12 @@ CREATE TABLE wallet_sync_states ( synced_height INTEGER, -- Birthday block height of the wallet (references blocks table). NULL if the - -- wallet has no known birthday block. + -- wallet has no known birthday block. When set, indicates the block has been + -- verified. birthday_height INTEGER, - -- Indicates if the birthday block has been verified. - birthday_verified BOOLEAN NOT NULL, + -- User-provided birthday timestamp for wallet rescan. NULL if not set. + birthday TIMESTAMP, -- Last updated timestamp stored in UTC without timezone info. updated_at TIMESTAMP NOT NULL, diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql index 77582601e5..eebcd592c3 100644 --- a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -78,11 +78,12 @@ CREATE TABLE wallet_sync_states ( synced_height INTEGER, -- Birthday block height of the wallet (references blocks table). NULL if the - -- wallet has no known birthday block. + -- wallet has no known birthday block. When set, indicates the block has been + -- verified. birthday_height INTEGER, - -- Indicates if the birthday block has been verified. - birthday_verified BOOLEAN NOT NULL, + -- User-provided birthday timestamp for wallet rescan. NULL if not set. + birthday DATETIME, -- Last updated timestamp stored in UTC without timezone info. updated_at DATETIME NOT NULL, diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index bec4bcc149..248f3b98fe 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -17,7 +17,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday_verified, + birthday, updated_at ) VALUES ( $1, $2, $3, $4, CURRENT_TIMESTAMP @@ -32,8 +32,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), - -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. - birthday_verified = COALESCE(sqlc.narg('birthday_verified'), birthday_verified), + -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. + birthday = COALESCE(sqlc.narg('birthday'), birthday), -- Always update timestamp to current database time. updated_at = CURRENT_TIMESTAMP @@ -49,7 +49,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -70,7 +70,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -91,7 +91,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql index 2253bc69d5..0259e8801d 100644 --- a/wallet/internal/db/queries/sqlite/wallets.sql +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -17,7 +17,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday_verified, + birthday, updated_at ) VALUES ( ?, ?, ?, ?, CURRENT_TIMESTAMP @@ -32,8 +32,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), - -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. - birthday_verified = COALESCE(sqlc.narg('birthday_verified'), birthday_verified), + -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. + birthday = COALESCE(sqlc.narg('birthday'), birthday), -- Always update timestamp to current database time. updated_at = CURRENT_TIMESTAMP @@ -49,7 +49,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -70,7 +70,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -91,7 +91,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index a2817f36fe..9f49ab37e5 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -35,9 +35,9 @@ type WalletSecret struct { } type WalletSyncState struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - BirthdayVerified bool - UpdatedAt time.Time + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + Birthday sql.NullTime + UpdatedAt time.Time } diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index ed8b84fa59..408f4c982b 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -59,7 +59,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -80,7 +80,7 @@ type GetWalletByIDRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -99,7 +99,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -118,7 +118,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -139,7 +139,7 @@ type GetWalletByNameRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -158,7 +158,7 @@ func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletBy &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -228,7 +228,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday_verified, + birthday, updated_at ) VALUES ( $1, $2, $3, $4, CURRENT_TIMESTAMP @@ -236,10 +236,10 @@ INSERT INTO wallet_sync_states ( ` type InsertWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - BirthdayVerified bool + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + Birthday sql.NullTime } func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { @@ -247,7 +247,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.BirthdayVerified, + arg.Birthday, ) return err } @@ -261,7 +261,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -282,7 +282,7 @@ type ListWalletsRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -307,7 +307,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -368,8 +368,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = COALESCE($3, birthday_height), - -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. - birthday_verified = COALESCE($4, birthday_verified), + -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. + birthday = COALESCE($4, birthday), -- Always update timestamp to current database time. updated_at = CURRENT_TIMESTAMP @@ -378,10 +378,10 @@ WHERE ` type UpdateWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - BirthdayVerified sql.NullBool + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + Birthday sql.NullTime } func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { @@ -389,7 +389,7 @@ func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.BirthdayVerified, + arg.Birthday, ) if err != nil { return 0, err diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 5a4568589d..60113bdff6 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -35,9 +35,9 @@ type WalletSecret struct { } type WalletSyncState struct { - WalletID int64 - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - BirthdayVerified bool - UpdatedAt time.Time + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + Birthday sql.NullTime + UpdatedAt time.Time } diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go index 289896088a..ae878ec63a 100644 --- a/wallet/internal/db/sqlc/sqlite/wallets.sql.go +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -59,7 +59,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -80,7 +80,7 @@ type GetWalletByIDRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -99,7 +99,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -118,7 +118,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -139,7 +139,7 @@ type GetWalletByNameRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -158,7 +158,7 @@ func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletBy &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -228,7 +228,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday_verified, + birthday, updated_at ) VALUES ( ?, ?, ?, ?, CURRENT_TIMESTAMP @@ -236,10 +236,10 @@ INSERT INTO wallet_sync_states ( ` type InsertWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - BirthdayVerified bool + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + Birthday sql.NullTime } func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { @@ -247,7 +247,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.BirthdayVerified, + arg.Birthday, ) return err } @@ -261,7 +261,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday_verified, + s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.timestamp AS synced_block_timestamp, @@ -282,7 +282,7 @@ type ListWalletsRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - BirthdayVerified sql.NullBool + Birthday sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -307,7 +307,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.BirthdayVerified, + &i.Birthday, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -368,8 +368,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = COALESCE(?2, birthday_height), - -- If birthday_verified param is NOT NULL, use it. Otherwise, keep existing value. - birthday_verified = COALESCE(?3, birthday_verified), + -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. + birthday = COALESCE(?3, birthday), -- Always update timestamp to current database time. updated_at = CURRENT_TIMESTAMP @@ -378,17 +378,17 @@ WHERE ` type UpdateWalletSyncStateParams struct { - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - BirthdayVerified sql.NullBool - WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + Birthday sql.NullTime + WalletID int64 } func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { result, err := q.exec(ctx, q.updateWalletSyncStateStmt, UpdateWalletSyncState, arg.SyncedHeight, arg.BirthdayHeight, - arg.BirthdayVerified, + arg.Birthday, arg.WalletID, ) if err != nil { diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index 4658c15e74..c50f16d8d5 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -52,11 +52,19 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, ) } + birthday := sql.NullTime{} + if !params.Birthday.IsZero() { + birthday = sql.NullTime{ + Time: params.Birthday, + Valid: true, + } + } + syncParams := sqlcpg.InsertWalletSyncStateParams{ - WalletID: id, - SyncedHeight: sql.NullInt32{}, - BirthdayHeight: sql.NullInt32{}, - BirthdayVerified: false, + WalletID: id, + SyncedHeight: sql.NullInt32{}, + BirthdayHeight: sql.NullInt32{}, + Birthday: birthday, } err = qtx.InsertWalletSyncState(ctx, syncParams) @@ -83,6 +91,7 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -125,6 +134,7 @@ func (w *PostgresWalletDB) GetWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -153,6 +163,7 @@ func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -190,6 +201,13 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, syncParams.SyncedHeight = syncedHeight } + if params.Birthday != nil { + syncParams.Birthday = sql.NullTime{ + Time: *params.Birthday, + Valid: true, + } + } + if params.BirthdayBlock != nil { birthdayHeight, err := uint32ToNullInt32( params.BirthdayBlock.Height) @@ -198,13 +216,6 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, } syncParams.BirthdayHeight = birthdayHeight - - // Set birthday verified flag to true if the birthday - // block is set. - syncParams.BirthdayVerified = sql.NullBool{ - Bool: true, - Valid: true, - } } rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) @@ -284,6 +295,7 @@ type pgWalletRowParams struct { syncedBlockHash []byte syncedBlockTimestamp sql.NullInt64 birthdayHeight sql.NullInt32 + birthday sql.NullTime birthdayBlockHash []byte birthdayBlockTimestamp sql.NullInt64 } @@ -304,6 +316,10 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { IsWatchOnly: row.isWatchOnly, } + if row.birthday.Valid { + info.Birthday = row.birthday.Time + } + if row.syncedHeight.Valid { block, err := buildPgBlock( row.syncedHeight, @@ -327,8 +343,7 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { return nil, fmt.Errorf("birthday block: %w", err) } - info.BirthdayBlock = *block - info.Birthday = block.Timestamp + info.BirthdayBlock = block } return info, nil diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index ffed6a16f8..989328eb59 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -52,11 +52,19 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, ) } + birthday := sql.NullTime{} + if !params.Birthday.IsZero() { + birthday = sql.NullTime{ + Time: params.Birthday, + Valid: true, + } + } + syncParams := sqlcsqlite.InsertWalletSyncStateParams{ - WalletID: id, - SyncedHeight: sql.NullInt64{}, - BirthdayHeight: sql.NullInt64{}, - BirthdayVerified: false, + WalletID: id, + SyncedHeight: sql.NullInt64{}, + BirthdayHeight: sql.NullInt64{}, + Birthday: birthday, } err = qtx.InsertWalletSyncState(ctx, syncParams) @@ -83,6 +91,7 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -125,6 +134,7 @@ func (w *SQLiteWalletDB) GetWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -153,6 +163,7 @@ func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, + birthday: row.Birthday, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -187,16 +198,16 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, } } - if params.BirthdayBlock != nil { - syncParams.BirthdayHeight = sql.NullInt64{ - Int64: int64(params.BirthdayBlock.Height), + if params.Birthday != nil { + syncParams.Birthday = sql.NullTime{ + Time: *params.Birthday, Valid: true, } + } - // Set birthday verified flag to true if the birthday - // block is set. - syncParams.BirthdayVerified = sql.NullBool{ - Bool: true, + if params.BirthdayBlock != nil { + syncParams.BirthdayHeight = sql.NullInt64{ + Int64: int64(params.BirthdayBlock.Height), Valid: true, } } @@ -278,6 +289,7 @@ type sqliteWalletRowParams struct { syncedBlockHash []byte syncedBlockTimestamp sql.NullInt64 birthdayHeight sql.NullInt64 + birthday sql.NullTime birthdayBlockHash []byte birthdayBlockTimestamp sql.NullInt64 } @@ -303,6 +315,10 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { IsWatchOnly: row.isWatchOnly, } + if row.birthday.Valid { + info.Birthday = row.birthday.Time + } + if row.syncedHeight.Valid { block, err := buildSqliteBlock( row.syncedHeight, @@ -326,8 +342,7 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { return nil, fmt.Errorf("birthday block: %w", err) } - info.BirthdayBlock = *block - info.Birthday = block.Timestamp + info.BirthdayBlock = block } return info, nil From 1b96262922fc9939135d534c7057e416b9516cc0 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 16:32:07 +0000 Subject: [PATCH 336/691] unit-bench: make `make unit-bench benchmem=1` works Fixes https://github.com/btcsuite/btcwallet/issues/1063. --- make/testing_flags.mk | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/make/testing_flags.mk b/make/testing_flags.mk index 86b9d1e5f8..0c7951107e 100644 --- a/make/testing_flags.mk +++ b/make/testing_flags.mk @@ -50,6 +50,10 @@ ifneq ($(nocache),) TEST_FLAGS += -test.count=1 endif +ifneq ($(benchmem),) +TEST_FLAGS += -test.benchmem +endif + # Define the log tags that will be applied only when running unit tests. If none # are provided, we default to "debug stdlog" which will be standard debug log # output. @@ -72,7 +76,7 @@ UNIT_RACE := $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) -race $(UNI # NONE is a special value which selects no other tests but only executes the # benchmark tests here. -UNIT_BENCH := $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" -test.bench=. -test.run=NONE $(UNITPKG) +UNIT_BENCH := $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) -test.bench=. -test.run=NONE $(UNITPKG) endif ifeq ($(UNIT_TARGETED), no) @@ -81,7 +85,7 @@ UNIT_DEBUG := $(GOLIST) | $(XARGS) env $(GOTEST) -v -tags="$(DEV_TAGS) $(LOG_TAG # NONE is a special value which selects no other tests but only executes the # benchmark tests here. -UNIT_BENCH := $(GOLIST) | $(XARGS) env $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" -test.bench=. -test.run=NONE +UNIT_BENCH := $(GOLIST) | $(XARGS) env $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) -test.bench=. -test.run=NONE UNIT_RACE := $(UNIT) -race endif From 857a281f0b9d6f68840fd0c78a7ddcbc11902f43 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 16:33:44 +0000 Subject: [PATCH 337/691] CI: verbose itest dbs running output for debugging --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ad81d16d43..686e99d4bd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -188,4 +188,4 @@ jobs: go-version: '${{ env.GO_VERSION }}' - name: run ${{ matrix.db }} ${{ matrix.target }} - run: make ${{ matrix.target }} db=${{ matrix.db }} + run: make ${{ matrix.target }} db=${{ matrix.db }} verbose=1 From 823c5fa645387dc9582db8421a16ef264ed1a603 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 9 Dec 2025 04:53:49 +0000 Subject: [PATCH 338/691] Makefile+tools: utilize existing docker tool for protolint --- Makefile | 2 +- tools/Dockerfile | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c88fb154aa..fb1e00fc8e 100644 --- a/Makefile +++ b/Makefile @@ -156,7 +156,7 @@ rpc-check: rpc #? protolint: Lint proto files using protolint protolint: @$(call print, "Linting proto files.") - docker run --rm --volume "$$(pwd):/workspace" --workdir /workspace yoheimuta/protolint lint rpc/ + $(DOCKER_TOOLS) protolint lint rpc/ #? sample-conf-check: Make sure default values in the sample-btcwallet.conf file are set correctly sample-conf-check: install diff --git a/tools/Dockerfile b/tools/Dockerfile index 52d530928e..d63a8c8322 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -12,6 +12,7 @@ RUN go install -trimpath \ github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ github.com/rinchsan/gosimports/cmd/gosimports \ github.com/sqlc-dev/sqlc/cmd/sqlc \ + github.com/yoheimuta/protolint/cmd/protolint \ && rm -rf /go/pkg/mod \ && rm -rf /root/.cache/go-build \ && rm -rf /go/src \ From dd56d375d5b19c357cc4e7b00d14189e2bf1ac8e Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 9 Dec 2025 04:55:27 +0000 Subject: [PATCH 339/691] Makefile+.protolint: move `.protolint.yml` file to unified config dir --- Makefile | 2 +- .protolint.yml => config/.protolint.yml | 0 tools/go.mod | 9 +++++++ tools/go.sum | 35 +++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) rename .protolint.yml => config/.protolint.yml (100%) diff --git a/Makefile b/Makefile index fb1e00fc8e..0963d50b93 100644 --- a/Makefile +++ b/Makefile @@ -156,7 +156,7 @@ rpc-check: rpc #? protolint: Lint proto files using protolint protolint: @$(call print, "Linting proto files.") - $(DOCKER_TOOLS) protolint lint rpc/ + $(DOCKER_TOOLS) protolint lint -config_dir_path=config rpc/ #? sample-conf-check: Make sure default values in the sample-btcwallet.conf file are set correctly sample-conf-check: install diff --git a/.protolint.yml b/config/.protolint.yml similarity index 100% rename from .protolint.yml rename to config/.protolint.yml diff --git a/tools/go.mod b/tools/go.mod index 3e07101e85..c5d295402a 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -6,6 +6,7 @@ tool ( github.com/golangci/golangci-lint/v2/cmd/golangci-lint github.com/rinchsan/gosimports/cmd/gosimports github.com/sqlc-dev/sqlc/cmd/sqlc + github.com/yoheimuta/protolint/cmd/protolint ) require ( @@ -55,6 +56,7 @@ require ( github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/chavacava/garif v0.1.0 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect github.com/cubicdaiya/gonp v1.0.4 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect @@ -70,6 +72,7 @@ require ( github.com/firefart/nonamedreturns v1.0.6 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect + github.com/gertd/go-pluralize v0.2.1 // indirect github.com/ghostiam/protogetter v0.3.15 // indirect github.com/go-critic/go-critic v0.13.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect @@ -104,10 +107,13 @@ require ( github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect github.com/gostaticanalysis/nilerr v0.1.1 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -153,6 +159,7 @@ require ( github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.20.0 // indirect + github.com/oklog/run v1.0.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect @@ -218,6 +225,8 @@ require ( github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect + github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect + github.com/yoheimuta/protolint v0.56.4 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect go-simpler.org/musttag v0.14.0 // indirect go-simpler.org/sloglint v0.11.1 // indirect diff --git a/tools/go.sum b/tools/go.sum index 945fcce995..17c6b6fd96 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -115,6 +115,8 @@ github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E= github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= @@ -140,6 +142,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= +github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -173,6 +177,7 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= @@ -185,6 +190,8 @@ github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwV github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= +github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= +github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY= github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY= @@ -337,8 +344,12 @@ github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -350,6 +361,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -365,6 +378,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -435,8 +450,12 @@ github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -470,6 +489,8 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nunnatsa/ginkgolinter v0.20.0 h1:OmWLkAFO2HUTYcU6mprnKud1Ey5pVdiVNYGO5HVicx8= github.com/nunnatsa/ginkgolinter v0.20.0/go.mod h1:dCIuFlTPfQerXgGUju3VygfAFPdC5aE1mdacCDKDJcQ= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY= @@ -605,6 +626,8 @@ github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ai github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -612,6 +635,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= @@ -654,6 +681,10 @@ github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5Jsjqto github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= +github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= +github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= +github.com/yoheimuta/protolint v0.56.4 h1:FWvXjVNRaKJWJFxsnilRZhfQ4tc3KS8VVGWecxnLXLo= +github.com/yoheimuta/protolint v0.56.4/go.mod h1:XrnOc0O5mckLR1GAOjqMPdb3R3ZEfLkMpLoq5RxxoG0= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -843,6 +874,7 @@ golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -865,10 +897,13 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From 51a5735193674202b78e5461896e096bc962055a Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 9 Dec 2025 04:59:46 +0000 Subject: [PATCH 340/691] Makefile+.golangci: move `.golangci.yml` to unified config dir --- Makefile | 6 +++--- .golangci.yml => config/.golangci.yml | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename .golangci.yml => config/.golangci.yml (100%) diff --git a/Makefile b/Makefile index 0963d50b93..b6d575ee0e 100644 --- a/Makefile +++ b/Makefile @@ -126,17 +126,17 @@ rpc-format: #? lint-config-check: Verify golangci-lint configuration lint-config-check: docker-tools @$(call print, "Verifying golangci-lint configuration.") - $(DOCKER_TOOLS) golangci-lint config verify -v + $(DOCKER_TOOLS) golangci-lint config verify -v --config config/.golangci.yml #? lint: Lint source and check errors lint-check: lint-config-check @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v $(LINT_WORKERS) + $(DOCKER_TOOLS) golangci-lint run -v --config config/.golangci.yml $(LINT_WORKERS) #? lint: Lint source and fix lint: lint-config-check @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v --fix $(LINT_WORKERS) + $(DOCKER_TOOLS) golangci-lint run -v --fix --config config/.golangci.yml $(LINT_WORKERS) #? docker-tools: Build tools docker image docker-tools: diff --git a/.golangci.yml b/config/.golangci.yml similarity index 100% rename from .golangci.yml rename to config/.golangci.yml From 0e1df0be373da9b0d740be6c32d11b68741cacc1 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Tue, 9 Dec 2025 05:02:51 +0000 Subject: [PATCH 341/691] Makefile+testing_flags: move make testing flags to unified config dir --- Makefile | 2 +- {make => config}/testing_flags.mk | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {make => config}/testing_flags.mk (100%) diff --git a/Makefile b/Makefile index b6d575ee0e..7e66b7e7ce 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ CP := cp MAKE := make XARGS := xargs -L 1 -include make/testing_flags.mk +include config/testing_flags.mk # Linting uses a lot of memory, so keep it under control by limiting the number # of workers if requested. diff --git a/make/testing_flags.mk b/config/testing_flags.mk similarity index 100% rename from make/testing_flags.mk rename to config/testing_flags.mk From e8879ce52bba4a0ee6e547415bc5426e88eb305a Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 04:24:23 +0000 Subject: [PATCH 342/691] Makefile+sql: move `sqlc.yaml` to unified config directory --- Makefile | 2 +- {wallet/internal/db/sqlc => config}/sqlc.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) rename {wallet/internal/db/sqlc => config}/sqlc.yaml (80%) diff --git a/Makefile b/Makefile index 7e66b7e7ce..dbb9d8df53 100644 --- a/Makefile +++ b/Makefile @@ -180,7 +180,7 @@ tidy-module-check: tidy-module #? sqlc: Generate sql models and queries in Go sqlc: docker-tools @$(call print, "Generating sql models and queries in Go") - $(DOCKER_TOOLS) sqlc generate -f wallet/internal/db/sqlc/sqlc.yaml + $(DOCKER_TOOLS) sqlc generate -f config/sqlc.yaml #? sqlc-check: Make sure sql models and queries are up to date sqlc-check: sqlc diff --git a/wallet/internal/db/sqlc/sqlc.yaml b/config/sqlc.yaml similarity index 80% rename from wallet/internal/db/sqlc/sqlc.yaml rename to config/sqlc.yaml index 158d518a3b..5e9f3a162b 100644 --- a/wallet/internal/db/sqlc/sqlc.yaml +++ b/config/sqlc.yaml @@ -3,11 +3,11 @@ version: "2" sql: - engine: "postgresql" - schema: "../migrations/postgres" - queries: "../queries/postgres" + schema: "../wallet/internal/db/migrations/postgres" + queries: "../wallet/internal/db/queries/postgres" gen: go: - out: "postgres" + out: "../wallet/internal/db/sqlc/postgres" package: "sqlcpg" # This is the driver package that sqlc will use in the generated code. @@ -24,11 +24,11 @@ sql: emit_prepared_queries: true - engine: "sqlite" - schema: "../migrations/sqlite" - queries: "../queries/sqlite" + schema: "../wallet/internal/db/migrations/sqlite" + queries: "../wallet/internal/db/queries/sqlite" gen: go: - out: "sqlite" + out: "../wallet/internal/db/sqlc/sqlite" package: "sqlcsqlite" # This is the driver package that sqlc will use in the generated code. From 6881daf0a265f366ba79b5e0b028d41a818ecfce Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 04:26:44 +0000 Subject: [PATCH 343/691] config+tools: prep `sqlfluff` for later makefile integration --- Makefile | 4 ++++ config/sqlfluff.cfg | 41 +++++++++++++++++++++++++++++++++++++++++ tools/Dockerfile | 8 ++++---- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 config/sqlfluff.cfg diff --git a/Makefile b/Makefile index dbb9d8df53..b65fcebac3 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,10 @@ DOCKER_TOOLS = docker run \ -v $(shell bash -c "mkdir -p /tmp/go-build-cache; echo /tmp/go-build-cache"):/root/.cache/go-build \ -v $$(pwd):/build btcwallet-tools +SQLFLUFF = docker run \ + --rm \ + -v $$(pwd):/sql sqlfluff/sqlfluff + GREEN := "\\033[0;32m" NC := "\\033[0m" define print diff --git a/config/sqlfluff.cfg b/config/sqlfluff.cfg new file mode 100644 index 0000000000..3ae05159ec --- /dev/null +++ b/config/sqlfluff.cfg @@ -0,0 +1,41 @@ +# SQLFluff configuration +# Source: https://docs.sqlfluff.com/en/stable/configuration/setting_configuration.html + +[sqlfluff] +# Supported dialects: postgres, sqlite +# We specify dialect via CLI (-d flag) to handle both postgres and sqlite +# sql_file_exts = .sql +# exclude_rules = None + +# Standard max line length +max_line_length = 120 + +# Use all available CPU cores for better performance +processes = 0 + +[sqlfluff:indentation] +# Use spaces for indentation +indent_unit = space +tab_space_size = 4 +indented_joins = False +indented_using_on = True + +[sqlfluff:rules:capitalisation.keywords] +# Keywords should be uppercase (e.g., SELECT, FROM, WHERE) +capitalisation_policy = upper + +[sqlfluff:rules:capitalisation.identifiers] +# Table and column names should be lowercase +extended_capitalisation_policy = lower + +[sqlfluff:rules:capitalisation.functions] +# Function names should be lowercase +extended_capitalisation_policy = lower + +[sqlfluff:rules:capitalisation.types] +# Data types should be uppercase (e.g., INTEGER, BYTEA, BLOB) +extended_capitalisation_policy = upper + +[sqlfluff:rules:convention.not_equal] +# Prefer != over <> for not equal +preferred_not_equal_style = c_style diff --git a/tools/Dockerfile b/tools/Dockerfile index d63a8c8322..fa2d491e86 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -9,10 +9,10 @@ WORKDIR /build COPY tools/go.mod tools/go.sum ./ RUN go install -trimpath \ - github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ - github.com/rinchsan/gosimports/cmd/gosimports \ - github.com/sqlc-dev/sqlc/cmd/sqlc \ - github.com/yoheimuta/protolint/cmd/protolint \ + github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ + github.com/rinchsan/gosimports/cmd/gosimports \ + github.com/sqlc-dev/sqlc/cmd/sqlc \ + github.com/yoheimuta/protolint/cmd/protolint \ && rm -rf /go/pkg/mod \ && rm -rf /root/.cache/go-build \ && rm -rf /go/src \ From 526e194181304995624adce1c16972e790db3906 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 11 Dec 2025 04:39:12 +0000 Subject: [PATCH 344/691] Makefile+workflows: integrate sqlfluff metadev helpers with CI and Makefile --- .github/workflows/main.yml | 8 +++- Makefile | 76 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 686e99d4bd..f5cafec1db 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -63,9 +63,15 @@ jobs: - name: Check RPC format run: make rpc-check - - name: Check SQL models + - name: Check generated SQL code is up-to-date run: make sqlc-check + - name: Check SQL formatting + run: make sql-format-check + + - name: Check SQL linting + run: make sql-lint-check + - name: compile code run: go install -v ./... diff --git a/Makefile b/Makefile index b65fcebac3..6463ea5cd2 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,17 @@ GOINSTALL := GO111MODULE=on go install -v GOFILES = $(shell find . -type f -name '*.go' -not -name "*.pb.go") + +# SQL directories. +SQL_MIGRATIONS_DIR := wallet/internal/db/migrations +SQL_QUERIES_DIR := wallet/internal/db/queries + +# SQL file paths. +SQL_POSTGRES_MIGRATIONS := $(SQL_MIGRATIONS_DIR)/postgres +SQL_POSTGRES_QUERIES := $(SQL_QUERIES_DIR)/postgres +SQL_SQLITE_MIGRATIONS := $(SQL_MIGRATIONS_DIR)/sqlite +SQL_SQLITE_QUERIES := $(SQL_QUERIES_DIR)/sqlite + RM := rm -f CP := cp MAKE := make @@ -26,7 +37,10 @@ DOCKER_TOOLS = docker run \ SQLFLUFF = docker run \ --rm \ - -v $$(pwd):/sql sqlfluff/sqlfluff + --user $$(id -u):$$(id -g) \ + -v $$(pwd):/sql \ + -w /sql \ + sqlfluff/sqlfluff GREEN := "\\033[0;32m" NC := "\\033[0m" @@ -181,16 +195,65 @@ tidy-module: tidy-module-check: tidy-module if test -n "$$(git status --porcelain)"; then echo "modules not updated, please run `make tidy-module` again!"; git status; exit 1; fi -#? sqlc: Generate sql models and queries in Go -sqlc: docker-tools +#? sql-parse: Ensures SQL files are syntactically valid +sql-parse: + @$(call print, "Validating SQL files (postgres migrations).") + $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Validating SQL files (postgres queries).") + $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Validating SQL files (sqlite migrations).") + $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Validating SQL files (sqlite queries).") + $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + +#? sqlc: Generate Go code from SQL queries and migrations +sqlc: sql-parse docker-tools @$(call print, "Generating sql models and queries in Go") $(DOCKER_TOOLS) sqlc generate -f config/sqlc.yaml -#? sqlc-check: Make sure sql models and queries are up to date +#? sqlc-check: Verify generated Go SQL queries and migrations are up-to-date sqlc-check: sqlc @$(call print, "Verifying sql code generation.") if test -n "$$(git status --porcelain '*.go')"; then echo "SQL models not properly generated!"; git status --porcelain '*.go'; exit 1; fi +#? sql-format: Format SQL migration and query files (like 'make fmt') +sql-format: + @$(call print, "Formatting SQL files (postgres migrations).") + $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Formatting SQL files (postgres queries).") + $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Formatting SQL files (sqlite migrations).") + $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Formatting SQL files (sqlite queries).") + $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + +#? sql-check: Verify SQL migration and query files are formatted correctly (like 'make fmt-check') +sql-format-check: sql-format + @$(call print, "Checking SQL formatting.") + if test -n "$$(git status --porcelain '$(SQL_MIGRATIONS_DIR)/**/*.sql' '$(SQL_QUERIES_DIR)/**/*.sql')"; then echo "SQL files not formatted correctly, please run 'make sql-format' again!"; git status; git diff; exit 1; fi + +#? sql-lint: Lint SQL migration and query files and fix issues (like 'make lint') +sql-lint: + @$(call print, "Linting SQL files (postgres migrations).") + $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Linting SQL files (postgres queries).") + $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Linting SQL files (sqlite migrations).") + $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Linting SQL files (sqlite queries).") + $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + +#? sql-lint-check: Lint SQL files and report errors (like 'make lint-check') +sql-lint-check: + @$(call print, "Linting SQL files (postgres migrations).") + $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Linting SQL files (postgres queries).") + $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Linting SQL files (sqlite migrations).") + $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Linting SQL files (sqlite queries).") + $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + .PHONY: all \ default \ build \ @@ -206,8 +269,13 @@ sqlc-check: sqlc fmt-check \ tidy-module \ tidy-module-check \ + sql-parse \ sqlc \ sqlc-check \ + sql-format \ + sql-lint \ + sql-lint-check \ + sql-format-check \ rpc-format \ lint \ lint-config-check \ From 8701840ce71d977749d7e0df04bafa098119aa94 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 11 Dec 2025 17:03:43 +0000 Subject: [PATCH 345/691] wallet: lint and format SQL files as proposed by sqlfluff Co-authored-by: Mohamed Awnallah --- wallet/internal/db/itest/fixtures_test.go | 2 +- .../migrations/postgres/000001_blocks.up.sql | 2 +- .../migrations/postgres/000002_wallets.up.sql | 18 ++--- .../db/migrations/sqlite/000001_blocks.up.sql | 2 +- .../migrations/sqlite/000002_wallets.up.sql | 18 ++--- .../internal/db/queries/postgres/blocks.sql | 7 +- .../internal/db/queries/postgres/wallets.sql | 56 +++++++------- wallet/internal/db/queries/sqlite/blocks.sql | 7 +- wallet/internal/db/queries/sqlite/wallets.sql | 56 +++++++------- .../internal/db/sqlc/postgres/blocks.sql.go | 17 +++-- wallet/internal/db/sqlc/postgres/models.go | 8 +- wallet/internal/db/sqlc/postgres/querier.go | 2 +- .../internal/db/sqlc/postgres/wallets.sql.go | 76 +++++++++---------- wallet/internal/db/sqlc/sqlite/blocks.sql.go | 17 +++-- wallet/internal/db/sqlc/sqlite/models.go | 8 +- wallet/internal/db/sqlc/sqlite/querier.go | 2 +- wallet/internal/db/sqlc/sqlite/wallets.sql.go | 76 +++++++++---------- wallet/internal/db/wallet_pg.go | 8 +- wallet/internal/db/wallet_sqlite.go | 8 +- 19 files changed, 201 insertions(+), 189 deletions(-) diff --git a/wallet/internal/db/itest/fixtures_test.go b/wallet/internal/db/itest/fixtures_test.go index 524cc09e05..f1b146d71f 100644 --- a/wallet/internal/db/itest/fixtures_test.go +++ b/wallet/internal/db/itest/fixtures_test.go @@ -59,7 +59,7 @@ func CreateBlockFixture(t *testing.T, dbConn *sql.DB, height uint32) db.Block { // TODO(gustavostingelin): use the block store to insert the block when // available. query := ` - INSERT INTO blocks (block_height, header_hash, timestamp) + INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3) ` _, err := dbConn.ExecContext(t.Context(), query, diff --git a/wallet/internal/db/migrations/postgres/000001_blocks.up.sql b/wallet/internal/db/migrations/postgres/000001_blocks.up.sql index f85ce44559..2880295c94 100644 --- a/wallet/internal/db/migrations/postgres/000001_blocks.up.sql +++ b/wallet/internal/db/migrations/postgres/000001_blocks.up.sql @@ -13,5 +13,5 @@ CREATE TABLE blocks ( header_hash BYTEA NOT NULL UNIQUE CHECK (length(header_hash) = 32), -- Unix timestamp - when the block was mined (seconds since epoch). - timestamp BIGINT NOT NULL CHECK (timestamp >= 0) + block_timestamp BIGINT NOT NULL CHECK (block_timestamp >= 0) ); diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql index 6bf74e4a06..591dad932e 100644 --- a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -7,7 +7,7 @@ CREATE TABLE wallets ( id BIGSERIAL PRIMARY KEY, -- Human friendly name for the wallet. - name TEXT NOT NULL, + wallet_name TEXT NOT NULL, -- Defines if the wallet was imported, all its accounts would also be imported. is_imported BOOLEAN NOT NULL, @@ -30,7 +30,7 @@ CREATE TABLE wallets ( ); -- Unique index to prevent duplicate wallet names. -CREATE UNIQUE INDEX uidx_wallets_name ON wallets (name); +CREATE UNIQUE INDEX uidx_wallets_name ON wallets (wallet_name); -- Wallet Secrets table to store rarely accessed, highly sensitive encrypted -- material with a strict one-to-one relationship with the wallets table. @@ -58,7 +58,7 @@ CREATE TABLE wallet_secrets ( -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if secrets still exist. - FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT ); -- Enforces one-to-one relationship: each wallet has at most one secrets record. @@ -90,20 +90,20 @@ CREATE TABLE wallet_sync_states ( -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if sync state still exists. - FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT, + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure -- that the block cannot be deleted if it is referenced by the sync state. - FOREIGN KEY (synced_height) REFERENCES blocks(block_height) - ON DELETE RESTRICT, + FOREIGN KEY (synced_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT, -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure -- that the block cannot be deleted if it is referenced by the sync state. - FOREIGN KEY (birthday_height) REFERENCES blocks(block_height) - ON DELETE RESTRICT + FOREIGN KEY (birthday_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT ); -- Enforces one-to-one relationship: each wallet has exactly one sync state record. -- Also serves as the effective primary key for this table. CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet - ON wallet_sync_states (wallet_id); +ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql b/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql index 26a6397681..b4b523d111 100644 --- a/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql +++ b/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql @@ -13,5 +13,5 @@ CREATE TABLE blocks ( header_hash BLOB NOT NULL UNIQUE CHECK (length(header_hash) = 32), -- Unix timestamp - when the block was mined (seconds since epoch). - timestamp INTEGER NOT NULL CHECK (timestamp >= 0) + block_timestamp INTEGER NOT NULL CHECK (block_timestamp >= 0) ); diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql index eebcd592c3..766cba96d3 100644 --- a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -7,7 +7,7 @@ CREATE TABLE wallets ( id INTEGER PRIMARY KEY, -- Human friendly name for the wallet. - name TEXT NOT NULL, + wallet_name TEXT NOT NULL, -- Defines if the wallet was imported, all its accounts would also be imported. is_imported BOOLEAN NOT NULL, @@ -30,7 +30,7 @@ CREATE TABLE wallets ( ); -- Unique index to prevent duplicate wallet names. -CREATE UNIQUE INDEX uidx_wallets_name ON wallets (name); +CREATE UNIQUE INDEX uidx_wallets_name ON wallets (wallet_name); -- Wallet Secrets table to store rarely accessed, highly sensitive encrypted -- material with a strict one-to-one relationship with the wallets table. @@ -58,7 +58,7 @@ CREATE TABLE wallet_secrets ( -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if secrets still exist. - FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT ); -- Enforces one-to-one relationship: each wallet has at most one secrets record. @@ -90,20 +90,20 @@ CREATE TABLE wallet_sync_states ( -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if sync state still exists. - FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE RESTRICT, + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure -- that the block cannot be deleted if it is referenced by the sync state. - FOREIGN KEY (synced_height) REFERENCES blocks(block_height) - ON DELETE RESTRICT, + FOREIGN KEY (synced_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT, -- Foreign key constraint to blocks. Using ON DELETE RESTRICT to ensure -- that the block cannot be deleted if it is referenced by the sync state. - FOREIGN KEY (birthday_height) REFERENCES blocks(block_height) - ON DELETE RESTRICT + FOREIGN KEY (birthday_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT ); -- Enforces one-to-one relationship: each wallet has exactly one sync state record. -- Also serves as the effective primary key for this table. CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet - ON wallet_sync_states (wallet_id); +ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/queries/postgres/blocks.sql b/wallet/internal/db/queries/postgres/blocks.sql index dc9884ba32..daa8e13e89 100644 --- a/wallet/internal/db/queries/postgres/blocks.sql +++ b/wallet/internal/db/queries/postgres/blocks.sql @@ -1,10 +1,13 @@ -- name: GetBlockByHeight :one -SELECT block_height, header_hash, timestamp +SELECT + block_height, + header_hash, + block_timestamp FROM blocks WHERE block_height = $1; -- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, timestamp) +INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3); -- name: DeleteBlock :exec diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index 248f3b98fe..bc01c5ecbc 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -1,6 +1,6 @@ -- name: CreateWallet :one INSERT INTO wallets ( - name, + wallet_name, is_imported, manager_version, is_watch_only, @@ -20,30 +20,30 @@ INSERT INTO wallet_sync_states ( birthday, updated_at ) VALUES ( - $1, $2, $3, $4, CURRENT_TIMESTAMP + $1, $2, $3, $4, current_timestamp ); -- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. - synced_height = COALESCE(sqlc.narg('synced_height'), synced_height), + synced_height = coalesce(sqlc.narg('synced_height'), synced_height), -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. - birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), + birthday_height = coalesce(sqlc.narg('birthday_height'), birthday_height), -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = COALESCE(sqlc.narg('birthday'), birthday), + birthday = coalesce(sqlc.narg('birthday'), birthday), -- Always update timestamp to current database time. - updated_at = CURRENT_TIMESTAMP + updated_at = current_timestamp WHERE wallet_id = $1; -- name: GetWalletByName :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -52,19 +52,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height -WHERE w.name = $1; + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.wallet_name = $1; -- name: ListWallets :many SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -73,19 +73,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height ORDER BY w.id; -- name: GetWalletByID :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -94,13 +94,13 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.id = $1; -- name: InsertWalletSecrets :exec diff --git a/wallet/internal/db/queries/sqlite/blocks.sql b/wallet/internal/db/queries/sqlite/blocks.sql index f37744df57..077e33c897 100644 --- a/wallet/internal/db/queries/sqlite/blocks.sql +++ b/wallet/internal/db/queries/sqlite/blocks.sql @@ -1,10 +1,13 @@ -- name: GetBlockByHeight :one -SELECT block_height, header_hash, timestamp +SELECT + block_height, + header_hash, + block_timestamp FROM blocks WHERE block_height = ?; -- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, timestamp) +INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?); -- name: DeleteBlock :exec diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql index 0259e8801d..3999eb636f 100644 --- a/wallet/internal/db/queries/sqlite/wallets.sql +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -1,6 +1,6 @@ -- name: CreateWallet :one INSERT INTO wallets ( - name, + wallet_name, is_imported, manager_version, is_watch_only, @@ -20,30 +20,30 @@ INSERT INTO wallet_sync_states ( birthday, updated_at ) VALUES ( - ?, ?, ?, ?, CURRENT_TIMESTAMP + ?, ?, ?, ?, current_timestamp ); -- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. - synced_height = COALESCE(sqlc.narg('synced_height'), synced_height), + synced_height = coalesce(sqlc.narg('synced_height'), synced_height), -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. - birthday_height = COALESCE(sqlc.narg('birthday_height'), birthday_height), + birthday_height = coalesce(sqlc.narg('birthday_height'), birthday_height), -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = COALESCE(sqlc.narg('birthday'), birthday), + birthday = coalesce(sqlc.narg('birthday'), birthday), -- Always update timestamp to current database time. - updated_at = CURRENT_TIMESTAMP + updated_at = current_timestamp WHERE wallet_id = sqlc.arg('wallet_id'); -- name: GetWalletByName :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -52,19 +52,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height -WHERE w.name = ?; + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.wallet_name = ?; -- name: ListWallets :many SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -73,19 +73,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height ORDER BY w.id; -- name: GetWalletByID :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -94,13 +94,13 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.id = ?; -- name: InsertWalletSecrets :exec diff --git a/wallet/internal/db/sqlc/postgres/blocks.sql.go b/wallet/internal/db/sqlc/postgres/blocks.sql.go index 8ca86ef812..c95f43abdb 100644 --- a/wallet/internal/db/sqlc/postgres/blocks.sql.go +++ b/wallet/internal/db/sqlc/postgres/blocks.sql.go @@ -20,7 +20,10 @@ func (q *Queries) DeleteBlock(ctx context.Context, blockHeight int32) error { } const GetBlockByHeight = `-- name: GetBlockByHeight :one -SELECT block_height, header_hash, timestamp +SELECT + block_height, + header_hash, + block_timestamp FROM blocks WHERE block_height = $1 ` @@ -28,22 +31,22 @@ WHERE block_height = $1 func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) { row := q.queryRow(ctx, q.getBlockByHeightStmt, GetBlockByHeight, blockHeight) var i Block - err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.Timestamp) + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.BlockTimestamp) return i, err } const InsertBlock = `-- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, timestamp) +INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3) ` type InsertBlockParams struct { - BlockHeight int32 - HeaderHash []byte - Timestamp int64 + BlockHeight int32 + HeaderHash []byte + BlockTimestamp int64 } func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error { - _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.Timestamp) + _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.BlockTimestamp) return err } diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 9f49ab37e5..2a8a0e55ee 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -10,14 +10,14 @@ import ( ) type Block struct { - BlockHeight int32 - HeaderHash []byte - Timestamp int64 + BlockHeight int32 + HeaderHash []byte + BlockTimestamp int64 } type Wallet struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 771a6ebdf2..9a3de084cd 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -13,7 +13,7 @@ type Querier interface { DeleteBlock(ctx context.Context, blockHeight int32) error GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) - GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) + GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index 408f4c982b..e60f36218c 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -12,7 +12,7 @@ import ( const CreateWallet = `-- name: CreateWallet :one INSERT INTO wallets ( - name, + wallet_name, is_imported, manager_version, is_watch_only, @@ -26,7 +26,7 @@ RETURNING id ` type CreateWalletParams struct { - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool @@ -37,7 +37,7 @@ type CreateWalletParams struct { func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) { row := q.queryRow(ctx, q.createWalletStmt, CreateWallet, - arg.Name, + arg.WalletName, arg.IsImported, arg.ManagerVersion, arg.IsWatchOnly, @@ -53,7 +53,7 @@ func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int const GetWalletByID = `-- name: GetWalletByID :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -62,19 +62,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.id = $1 ` type GetWalletByIDRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool @@ -93,7 +93,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow var i GetWalletByIDRow err := row.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -112,7 +112,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow const GetWalletByName = `-- name: GetWalletByName :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -121,19 +121,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height -WHERE w.name = $1 + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.wallet_name = $1 ` type GetWalletByNameRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool @@ -147,12 +147,12 @@ type GetWalletByNameRow struct { BirthdayBlockTimestamp sql.NullInt64 } -func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) { - row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, name) +func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) { + row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, walletName) var i GetWalletByNameRow err := row.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -231,7 +231,7 @@ INSERT INTO wallet_sync_states ( birthday, updated_at ) VALUES ( - $1, $2, $3, $4, CURRENT_TIMESTAMP + $1, $2, $3, $4, current_timestamp ) ` @@ -255,7 +255,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn const ListWallets = `-- name: ListWallets :many SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -264,19 +264,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height ORDER BY w.id ` type ListWalletsRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int32 IsWatchOnly bool @@ -301,7 +301,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { var i ListWalletsRow if err := rows.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -363,16 +363,16 @@ const UpdateWalletSyncState = `-- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. - synced_height = COALESCE($2, synced_height), + synced_height = coalesce($2, synced_height), -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. - birthday_height = COALESCE($3, birthday_height), + birthday_height = coalesce($3, birthday_height), -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = COALESCE($4, birthday), + birthday = coalesce($4, birthday), -- Always update timestamp to current database time. - updated_at = CURRENT_TIMESTAMP + updated_at = current_timestamp WHERE wallet_id = $1 ` diff --git a/wallet/internal/db/sqlc/sqlite/blocks.sql.go b/wallet/internal/db/sqlc/sqlite/blocks.sql.go index 62f6afa0f5..f5eb1d3e00 100644 --- a/wallet/internal/db/sqlc/sqlite/blocks.sql.go +++ b/wallet/internal/db/sqlc/sqlite/blocks.sql.go @@ -20,7 +20,10 @@ func (q *Queries) DeleteBlock(ctx context.Context, blockHeight int64) error { } const GetBlockByHeight = `-- name: GetBlockByHeight :one -SELECT block_height, header_hash, timestamp +SELECT + block_height, + header_hash, + block_timestamp FROM blocks WHERE block_height = ? ` @@ -28,22 +31,22 @@ WHERE block_height = ? func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) { row := q.queryRow(ctx, q.getBlockByHeightStmt, GetBlockByHeight, blockHeight) var i Block - err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.Timestamp) + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.BlockTimestamp) return i, err } const InsertBlock = `-- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, timestamp) +INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?) ` type InsertBlockParams struct { - BlockHeight int64 - HeaderHash []byte - Timestamp int64 + BlockHeight int64 + HeaderHash []byte + BlockTimestamp int64 } func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error { - _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.Timestamp) + _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.BlockTimestamp) return err } diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 60113bdff6..3789221a04 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -10,14 +10,14 @@ import ( ) type Block struct { - BlockHeight int64 - HeaderHash []byte - Timestamp int64 + BlockHeight int64 + HeaderHash []byte + BlockTimestamp int64 } type Wallet struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 61cc0cbcfb..6f11ef506a 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -13,7 +13,7 @@ type Querier interface { DeleteBlock(ctx context.Context, blockHeight int64) error GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) - GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) + GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go index ae878ec63a..8f38342fc8 100644 --- a/wallet/internal/db/sqlc/sqlite/wallets.sql.go +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -12,7 +12,7 @@ import ( const CreateWallet = `-- name: CreateWallet :one INSERT INTO wallets ( - name, + wallet_name, is_imported, manager_version, is_watch_only, @@ -26,7 +26,7 @@ RETURNING id ` type CreateWalletParams struct { - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool @@ -37,7 +37,7 @@ type CreateWalletParams struct { func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) { row := q.queryRow(ctx, q.createWalletStmt, CreateWallet, - arg.Name, + arg.WalletName, arg.IsImported, arg.ManagerVersion, arg.IsWatchOnly, @@ -53,7 +53,7 @@ func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (int const GetWalletByID = `-- name: GetWalletByID :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -62,19 +62,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.id = ? ` type GetWalletByIDRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool @@ -93,7 +93,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow var i GetWalletByIDRow err := row.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -112,7 +112,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow const GetWalletByName = `-- name: GetWalletByName :one SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -121,19 +121,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height -WHERE w.name = ? + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE w.wallet_name = ? ` type GetWalletByNameRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool @@ -147,12 +147,12 @@ type GetWalletByNameRow struct { BirthdayBlockTimestamp sql.NullInt64 } -func (q *Queries) GetWalletByName(ctx context.Context, name string) (GetWalletByNameRow, error) { - row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, name) +func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) { + row := q.queryRow(ctx, q.getWalletByNameStmt, GetWalletByName, walletName) var i GetWalletByNameRow err := row.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -231,7 +231,7 @@ INSERT INTO wallet_sync_states ( birthday, updated_at ) VALUES ( - ?, ?, ?, ?, CURRENT_TIMESTAMP + ?, ?, ?, ?, current_timestamp ) ` @@ -255,7 +255,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn const ListWallets = `-- name: ListWallets :many SELECT w.id, - w.name, + w.wallet_name, w.is_imported, w.manager_version, w.is_watch_only, @@ -264,19 +264,19 @@ SELECT s.birthday, s.updated_at, b_synced.header_hash AS synced_block_hash, - b_synced.timestamp AS synced_block_timestamp, + b_synced.block_timestamp AS synced_block_timestamp, b_birthday.header_hash AS birthday_block_hash, - b_birthday.timestamp AS birthday_block_timestamp -FROM wallets w -LEFT JOIN wallet_sync_states s ON s.wallet_id = w.id -LEFT JOIN blocks b_synced ON s.synced_height = b_synced.block_height -LEFT JOIN blocks b_birthday ON s.birthday_height = b_birthday.block_height + b_birthday.block_timestamp AS birthday_block_timestamp +FROM wallets AS w +LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id +LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height +LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height ORDER BY w.id ` type ListWalletsRow struct { ID int64 - Name string + WalletName string IsImported bool ManagerVersion int64 IsWatchOnly bool @@ -301,7 +301,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { var i ListWalletsRow if err := rows.Scan( &i.ID, - &i.Name, + &i.WalletName, &i.IsImported, &i.ManagerVersion, &i.IsWatchOnly, @@ -363,16 +363,16 @@ const UpdateWalletSyncState = `-- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET -- If synced_height param is NOT NULL, use it. Otherwise, keep existing value. - synced_height = COALESCE(?1, synced_height), + synced_height = coalesce(?1, synced_height), -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. - birthday_height = COALESCE(?2, birthday_height), + birthday_height = coalesce(?2, birthday_height), -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = COALESCE(?3, birthday), + birthday = coalesce(?3, birthday), -- Always update timestamp to current database time. - updated_at = CURRENT_TIMESTAMP + updated_at = current_timestamp WHERE wallet_id = ?4 ` diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index c50f16d8d5..1ac42a3ddb 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -22,7 +22,7 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { walletParams := sqlcpg.CreateWalletParams{ - Name: params.Name, + WalletName: params.Name, IsImported: params.IsImported, ManagerVersion: params.ManagerVersion, IsWatchOnly: params.IsWatchOnly, @@ -83,7 +83,7 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, info, err = buildPgWalletInfo(pgWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, @@ -126,7 +126,7 @@ func (w *PostgresWalletDB) GetWallet(ctx context.Context, return buildPgWalletInfo(pgWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, @@ -155,7 +155,7 @@ func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, for i, row := range rows { info, err := buildPgWalletInfo(pgWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index 989328eb59..8c988d4981 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -22,7 +22,7 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { walletParams := sqlcsqlite.CreateWalletParams{ - Name: params.Name, + WalletName: params.Name, IsImported: params.IsImported, ManagerVersion: int64(params.ManagerVersion), IsWatchOnly: params.IsWatchOnly, @@ -83,7 +83,7 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, info, err = buildSqliteWalletInfo(sqliteWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, @@ -126,7 +126,7 @@ func (w *SQLiteWalletDB) GetWallet(ctx context.Context, return buildSqliteWalletInfo(sqliteWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, @@ -155,7 +155,7 @@ func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, for i, row := range rows { info, err := buildSqliteWalletInfo(sqliteWalletRowParams{ id: row.ID, - name: row.Name, + name: row.WalletName, isImported: row.IsImported, managerVersion: row.ManagerVersion, isWatchOnly: row.IsWatchOnly, From eedb4b7addd18e3b412c1eb773af39f88275c32f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 16:54:02 -0300 Subject: [PATCH 346/691] wallet: fix code style issues --- wallet/internal/db/wallet_pg.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index 1ac42a3ddb..8eef576717 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -193,7 +193,8 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, if params.SyncedTo != nil { syncedHeight, err := uint32ToNullInt32( - params.SyncedTo.Height) + params.SyncedTo.Height, + ) if err != nil { return err } @@ -210,7 +211,8 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, if params.BirthdayBlock != nil { birthdayHeight, err := uint32ToNullInt32( - params.BirthdayBlock.Height) + params.BirthdayBlock.Height, + ) if err != nil { return err } From b1053a37bdbf86b562d163a71874dacb6d20d173 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 17:12:14 -0300 Subject: [PATCH 347/691] wallet: rename wallet sync table birthday to birthday_timestamp --- .../migrations/postgres/000002_wallets.up.sql | 2 +- .../migrations/sqlite/000002_wallets.up.sql | 2 +- .../internal/db/queries/postgres/wallets.sql | 12 ++--- wallet/internal/db/queries/sqlite/wallets.sql | 12 ++--- wallet/internal/db/sqlc/postgres/models.go | 10 ++--- .../internal/db/sqlc/postgres/wallets.sql.go | 44 +++++++++---------- wallet/internal/db/sqlc/sqlite/models.go | 10 ++--- wallet/internal/db/sqlc/sqlite/wallets.sql.go | 44 +++++++++---------- wallet/internal/db/wallet_pg.go | 26 +++++------ wallet/internal/db/wallet_sqlite.go | 26 +++++------ 10 files changed, 94 insertions(+), 94 deletions(-) diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql index 591dad932e..c6af352892 100644 --- a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -83,7 +83,7 @@ CREATE TABLE wallet_sync_states ( birthday_height INTEGER, -- User-provided birthday timestamp for wallet rescan. NULL if not set. - birthday TIMESTAMP, + birthday_timestamp TIMESTAMP, -- Last updated timestamp stored in UTC without timezone info. updated_at TIMESTAMP NOT NULL, diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql index 766cba96d3..253849cfb7 100644 --- a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -83,7 +83,7 @@ CREATE TABLE wallet_sync_states ( birthday_height INTEGER, -- User-provided birthday timestamp for wallet rescan. NULL if not set. - birthday DATETIME, + birthday_timestamp DATETIME, -- Last updated timestamp stored in UTC without timezone info. updated_at DATETIME NOT NULL, diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index bc01c5ecbc..b1033fc7b2 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -17,7 +17,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday, + birthday_timestamp, updated_at ) VALUES ( $1, $2, $3, $4, current_timestamp @@ -32,8 +32,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = coalesce(sqlc.narg('birthday_height'), birthday_height), - -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = coalesce(sqlc.narg('birthday'), birthday), + -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. + birthday_timestamp = coalesce(sqlc.narg('birthday_timestamp'), birthday_timestamp), -- Always update timestamp to current database time. updated_at = current_timestamp @@ -49,7 +49,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -70,7 +70,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -91,7 +91,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql index 3999eb636f..3e180b39dd 100644 --- a/wallet/internal/db/queries/sqlite/wallets.sql +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -17,7 +17,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday, + birthday_timestamp, updated_at ) VALUES ( ?, ?, ?, ?, current_timestamp @@ -32,8 +32,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = coalesce(sqlc.narg('birthday_height'), birthday_height), - -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = coalesce(sqlc.narg('birthday'), birthday), + -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. + birthday_timestamp = coalesce(sqlc.narg('birthday_timestamp'), birthday_timestamp), -- Always update timestamp to current database time. updated_at = current_timestamp @@ -49,7 +49,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -70,7 +70,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -91,7 +91,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 2a8a0e55ee..3282b71b51 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -35,9 +35,9 @@ type WalletSecret struct { } type WalletSyncState struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - Birthday sql.NullTime - UpdatedAt time.Time + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayTimestamp sql.NullTime + UpdatedAt time.Time } diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index e60f36218c..cddc263bf3 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -59,7 +59,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -80,7 +80,7 @@ type GetWalletByIDRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -99,7 +99,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -118,7 +118,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -139,7 +139,7 @@ type GetWalletByNameRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -158,7 +158,7 @@ func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (GetWa &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -228,7 +228,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday, + birthday_timestamp, updated_at ) VALUES ( $1, $2, $3, $4, current_timestamp @@ -236,10 +236,10 @@ INSERT INTO wallet_sync_states ( ` type InsertWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayTimestamp sql.NullTime } func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { @@ -247,7 +247,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.Birthday, + arg.BirthdayTimestamp, ) return err } @@ -261,7 +261,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -282,7 +282,7 @@ type ListWalletsRow struct { IsWatchOnly bool SyncedHeight sql.NullInt32 BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -307,7 +307,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -368,8 +368,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = coalesce($3, birthday_height), - -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = coalesce($4, birthday), + -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. + birthday_timestamp = coalesce($4, birthday_timestamp), -- Always update timestamp to current database time. updated_at = current_timestamp @@ -378,10 +378,10 @@ WHERE ` type UpdateWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt32 - BirthdayHeight sql.NullInt32 - Birthday sql.NullTime + WalletID int64 + SyncedHeight sql.NullInt32 + BirthdayHeight sql.NullInt32 + BirthdayTimestamp sql.NullTime } func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { @@ -389,7 +389,7 @@ func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.Birthday, + arg.BirthdayTimestamp, ) if err != nil { return 0, err diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 3789221a04..8a93984406 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -35,9 +35,9 @@ type WalletSecret struct { } type WalletSyncState struct { - WalletID int64 - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - Birthday sql.NullTime - UpdatedAt time.Time + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayTimestamp sql.NullTime + UpdatedAt time.Time } diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go index 8f38342fc8..56685d4f7b 100644 --- a/wallet/internal/db/sqlc/sqlite/wallets.sql.go +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -59,7 +59,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -80,7 +80,7 @@ type GetWalletByIDRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -99,7 +99,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -118,7 +118,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -139,7 +139,7 @@ type GetWalletByNameRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -158,7 +158,7 @@ func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (GetWa &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -228,7 +228,7 @@ INSERT INTO wallet_sync_states ( wallet_id, synced_height, birthday_height, - birthday, + birthday_timestamp, updated_at ) VALUES ( ?, ?, ?, ?, current_timestamp @@ -236,10 +236,10 @@ INSERT INTO wallet_sync_states ( ` type InsertWalletSyncStateParams struct { - WalletID int64 - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - Birthday sql.NullTime + WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayTimestamp sql.NullTime } func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error { @@ -247,7 +247,7 @@ func (q *Queries) InsertWalletSyncState(ctx context.Context, arg InsertWalletSyn arg.WalletID, arg.SyncedHeight, arg.BirthdayHeight, - arg.Birthday, + arg.BirthdayTimestamp, ) return err } @@ -261,7 +261,7 @@ SELECT w.is_watch_only, s.synced_height, s.birthday_height, - s.birthday, + s.birthday_timestamp, s.updated_at, b_synced.header_hash AS synced_block_hash, b_synced.block_timestamp AS synced_block_timestamp, @@ -282,7 +282,7 @@ type ListWalletsRow struct { IsWatchOnly bool SyncedHeight sql.NullInt64 BirthdayHeight sql.NullInt64 - Birthday sql.NullTime + BirthdayTimestamp sql.NullTime UpdatedAt sql.NullTime SyncedBlockHash []byte SyncedBlockTimestamp sql.NullInt64 @@ -307,7 +307,7 @@ func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { &i.IsWatchOnly, &i.SyncedHeight, &i.BirthdayHeight, - &i.Birthday, + &i.BirthdayTimestamp, &i.UpdatedAt, &i.SyncedBlockHash, &i.SyncedBlockTimestamp, @@ -368,8 +368,8 @@ SET -- If birthday_height param is NOT NULL, use it. Otherwise, keep existing value. birthday_height = coalesce(?2, birthday_height), - -- If birthday param is NOT NULL, use it. Otherwise, keep existing value. - birthday = coalesce(?3, birthday), + -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. + birthday_timestamp = coalesce(?3, birthday_timestamp), -- Always update timestamp to current database time. updated_at = current_timestamp @@ -378,17 +378,17 @@ WHERE ` type UpdateWalletSyncStateParams struct { - SyncedHeight sql.NullInt64 - BirthdayHeight sql.NullInt64 - Birthday sql.NullTime - WalletID int64 + SyncedHeight sql.NullInt64 + BirthdayHeight sql.NullInt64 + BirthdayTimestamp sql.NullTime + WalletID int64 } func (q *Queries) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) { result, err := q.exec(ctx, q.updateWalletSyncStateStmt, UpdateWalletSyncState, arg.SyncedHeight, arg.BirthdayHeight, - arg.Birthday, + arg.BirthdayTimestamp, arg.WalletID, ) if err != nil { diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index 8eef576717..e6f1b54e95 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -52,19 +52,19 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, ) } - birthday := sql.NullTime{} + birthdayTimestamp := sql.NullTime{} if !params.Birthday.IsZero() { - birthday = sql.NullTime{ + birthdayTimestamp = sql.NullTime{ Time: params.Birthday, Valid: true, } } syncParams := sqlcpg.InsertWalletSyncStateParams{ - WalletID: id, - SyncedHeight: sql.NullInt32{}, - BirthdayHeight: sql.NullInt32{}, - Birthday: birthday, + WalletID: id, + SyncedHeight: sql.NullInt32{}, + BirthdayHeight: sql.NullInt32{}, + BirthdayTimestamp: birthdayTimestamp, } err = qtx.InsertWalletSyncState(ctx, syncParams) @@ -91,7 +91,7 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -134,7 +134,7 @@ func (w *PostgresWalletDB) GetWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -163,7 +163,7 @@ func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -203,7 +203,7 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, } if params.Birthday != nil { - syncParams.Birthday = sql.NullTime{ + syncParams.BirthdayTimestamp = sql.NullTime{ Time: *params.Birthday, Valid: true, } @@ -297,7 +297,7 @@ type pgWalletRowParams struct { syncedBlockHash []byte syncedBlockTimestamp sql.NullInt64 birthdayHeight sql.NullInt32 - birthday sql.NullTime + birthdayTimestamp sql.NullTime birthdayBlockHash []byte birthdayBlockTimestamp sql.NullInt64 } @@ -318,8 +318,8 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { IsWatchOnly: row.isWatchOnly, } - if row.birthday.Valid { - info.Birthday = row.birthday.Time + if row.birthdayTimestamp.Valid { + info.Birthday = row.birthdayTimestamp.Time } if row.syncedHeight.Valid { diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index 8c988d4981..732242cb08 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -52,19 +52,19 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, ) } - birthday := sql.NullTime{} + birthdayTimestamp := sql.NullTime{} if !params.Birthday.IsZero() { - birthday = sql.NullTime{ + birthdayTimestamp = sql.NullTime{ Time: params.Birthday, Valid: true, } } syncParams := sqlcsqlite.InsertWalletSyncStateParams{ - WalletID: id, - SyncedHeight: sql.NullInt64{}, - BirthdayHeight: sql.NullInt64{}, - Birthday: birthday, + WalletID: id, + SyncedHeight: sql.NullInt64{}, + BirthdayHeight: sql.NullInt64{}, + BirthdayTimestamp: birthdayTimestamp, } err = qtx.InsertWalletSyncState(ctx, syncParams) @@ -91,7 +91,7 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -134,7 +134,7 @@ func (w *SQLiteWalletDB) GetWallet(ctx context.Context, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -163,7 +163,7 @@ func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, syncedBlockHash: row.SyncedBlockHash, syncedBlockTimestamp: row.SyncedBlockTimestamp, birthdayHeight: row.BirthdayHeight, - birthday: row.Birthday, + birthdayTimestamp: row.BirthdayTimestamp, birthdayBlockHash: row.BirthdayBlockHash, birthdayBlockTimestamp: row.BirthdayBlockTimestamp, }) @@ -199,7 +199,7 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, } if params.Birthday != nil { - syncParams.Birthday = sql.NullTime{ + syncParams.BirthdayTimestamp = sql.NullTime{ Time: *params.Birthday, Valid: true, } @@ -289,7 +289,7 @@ type sqliteWalletRowParams struct { syncedBlockHash []byte syncedBlockTimestamp sql.NullInt64 birthdayHeight sql.NullInt64 - birthday sql.NullTime + birthdayTimestamp sql.NullTime birthdayBlockHash []byte birthdayBlockTimestamp sql.NullInt64 } @@ -315,8 +315,8 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { IsWatchOnly: row.isWatchOnly, } - if row.birthday.Valid { - info.Birthday = row.birthday.Time + if row.birthdayTimestamp.Valid { + info.Birthday = row.birthdayTimestamp.Time } if row.syncedHeight.Valid { From b89e721f315de6f316e917a5d45bde0859f7ee7e Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 16 Dec 2025 10:26:03 -0300 Subject: [PATCH 348/691] wallet: fix pg itest "too many clients" --- wallet/internal/db/itest/pg_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 41065c45b7..1d6f6f2710 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -142,6 +142,13 @@ func NewPostgresDB(t *testing.T) *sql.DB { // Connect to the default database to create our test database. adminDB, err := sql.Open("pgx", connStr) require.NoError(t, err, "failed to open admin connection") + require.NotNil(t, adminDB, "admin connection is nil") + + // Close the connection to avoid leaking an idle connection during tests. + // The container is reused across all tests, so we explicitly clean this up. + t.Cleanup(func() { + _ = adminDB.Close() + }) // Create a database name based on the test name. dbName := sanitizedPgDBName(t) @@ -158,6 +165,13 @@ func NewPostgresDB(t *testing.T) *sql.DB { // connection constructor when available. dbConn, err := sql.Open("pgx", testConnStr) require.NoError(t, err, "failed to open test database connection") + require.NotNil(t, dbConn, "test database connection is nil") + + // Close the connection to avoid leaking an idle connection during tests. + // The container is reused across all tests, so we explicitly clean this up. + t.Cleanup(func() { + _ = dbConn.Close() + }) err = db.ApplyPostgresMigrations(dbConn) require.NoError(t, err, "failed to apply migrations") From 02ef8c96df1f43f5a2fd8e5930f3bde96751e577 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 14:55:42 -0300 Subject: [PATCH 349/691] wallet: add address types migrations and queries --- .../postgres/000003_address_types.down.sql | 3 + .../postgres/000003_address_types.up.sql | 32 ++++++++++ .../sqlite/000003_address_types.down.sql | 3 + .../sqlite/000003_address_types.up.sql | 32 ++++++++++ .../db/queries/postgres/address_types.sql | 15 +++++ .../db/queries/sqlite/address_types.sql | 15 +++++ .../db/sqlc/postgres/address_types.sql.go | 58 +++++++++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 20 +++++++ wallet/internal/db/sqlc/postgres/models.go | 5 ++ wallet/internal/db/sqlc/postgres/querier.go | 4 ++ .../db/sqlc/sqlite/address_types.sql.go | 58 +++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 20 +++++++ wallet/internal/db/sqlc/sqlite/models.go | 5 ++ wallet/internal/db/sqlc/sqlite/querier.go | 4 ++ 14 files changed, 274 insertions(+) create mode 100644 wallet/internal/db/migrations/postgres/000003_address_types.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000003_address_types.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000003_address_types.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000003_address_types.up.sql create mode 100644 wallet/internal/db/queries/postgres/address_types.sql create mode 100644 wallet/internal/db/queries/sqlite/address_types.sql create mode 100644 wallet/internal/db/sqlc/postgres/address_types.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/address_types.sql.go diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.down.sql b/wallet/internal/db/migrations/postgres/000003_address_types.down.sql new file mode 100644 index 0000000000..1d524ac5c2 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000003_address_types.down.sql @@ -0,0 +1,3 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if table already dropped or database in unexpected state. +DROP TABLE IF EXISTS address_types; diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.up.sql b/wallet/internal/db/migrations/postgres/000003_address_types.up.sql new file mode 100644 index 0000000000..0f474e79bb --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000003_address_types.up.sql @@ -0,0 +1,32 @@ +-- Address type lookup table - provides standardized descriptions for Bitcoin +-- address types. This is a reference table that maps the AddressType enum +-- values used in Go code to their human-readable Bitcoin protocol names. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE address_types ( + -- Primary key matching the Go AddressType enum values. + -- Using explicit IDs rather than auto-increment to ensure consistency + -- with the Go enum and across SQLite/Postgres implementations. + id SMALLINT PRIMARY KEY, + + -- Human-readable Bitcoin address type description. + -- These match standard Bitcoin protocol terminology. + description TEXT NOT NULL +); + +-- Unique constraint on description to prevent duplicate entries. +-- This ensures referential integrity and enables efficient reverse lookups. +CREATE UNIQUE INDEX uidx_address_types_description +ON address_types (description); + +-- Seed reference data matching the Go AddressType enum constants. +-- These values are static and represent the Bitcoin address types. +-- IDs MUST match the iota values in wallet/internal/db/data_types.go. +INSERT INTO address_types (id, description) VALUES +(0, 'P2PKH'), -- Pay-to-PubKey-Hash +(1, 'P2SH'), -- Pay-to-Script-Hash +(2, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash +(3, 'P2WSH'), -- Pay-to-Witness-Script-Hash +(4, 'P2SH-P2WPKH'), -- Nested Witness PubKey +(5, 'P2TR'); -- Pay-to-Taproot diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.down.sql b/wallet/internal/db/migrations/sqlite/000003_address_types.down.sql new file mode 100644 index 0000000000..1d524ac5c2 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000003_address_types.down.sql @@ -0,0 +1,3 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if table already dropped or database in unexpected state. +DROP TABLE IF EXISTS address_types; diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql b/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql new file mode 100644 index 0000000000..8722966aa6 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql @@ -0,0 +1,32 @@ +-- Address type lookup table - provides standardized descriptions for Bitcoin +-- address types. This is a reference table that maps the AddressType enum +-- values used in Go code to their human-readable Bitcoin protocol names. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE address_types ( + -- Primary key matching the Go AddressType enum values. + -- Using explicit IDs rather than auto-increment to ensure consistency + -- with the Go enum and across SQLite/Postgres implementations. + id INTEGER PRIMARY KEY, + + -- Human-readable Bitcoin address type description. + -- These match standard Bitcoin protocol terminology. + description TEXT NOT NULL +); + +-- Unique constraint on description to prevent duplicate entries. +-- This ensures referential integrity and enables efficient reverse lookups. +CREATE UNIQUE INDEX uidx_address_types_description +ON address_types (description); + +-- Seed reference data matching the Go AddressType enum constants. +-- These values are static and represent the Bitcoin address types. +-- IDs MUST match the iota values in wallet/internal/db/data_types.go. +INSERT INTO address_types (id, description) VALUES +(0, 'P2PKH'), -- Pay-to-PubKey-Hash +(1, 'P2SH'), -- Pay-to-Script-Hash +(2, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash +(3, 'P2WSH'), -- Pay-to-Witness-Script-Hash +(4, 'P2SH-P2WPKH'), -- Nested Witness PubKey +(5, 'P2TR'); -- Pay-to-Taproot diff --git a/wallet/internal/db/queries/postgres/address_types.sql b/wallet/internal/db/queries/postgres/address_types.sql new file mode 100644 index 0000000000..ceb86a4c8e --- /dev/null +++ b/wallet/internal/db/queries/postgres/address_types.sql @@ -0,0 +1,15 @@ +-- name: ListAddressTypes :many +-- Returns all address types ordered by ID. +SELECT + id, + description +FROM address_types +ORDER BY id; + +-- name: GetAddressTypeByID :one +-- Returns a single address type by its ID. +SELECT + id, + description +FROM address_types +WHERE id = $1; diff --git a/wallet/internal/db/queries/sqlite/address_types.sql b/wallet/internal/db/queries/sqlite/address_types.sql new file mode 100644 index 0000000000..6f4bcc0b04 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/address_types.sql @@ -0,0 +1,15 @@ +-- name: ListAddressTypes :many +-- Returns all address types ordered by ID. +SELECT + id, + description +FROM address_types +ORDER BY id; + +-- name: GetAddressTypeByID :one +-- Returns a single address type by its ID. +SELECT + id, + description +FROM address_types +WHERE id = ?; diff --git a/wallet/internal/db/sqlc/postgres/address_types.sql.go b/wallet/internal/db/sqlc/postgres/address_types.sql.go new file mode 100644 index 0000000000..86e7a4a531 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/address_types.sql.go @@ -0,0 +1,58 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: address_types.sql + +package sqlcpg + +import ( + "context" +) + +const GetAddressTypeByID = `-- name: GetAddressTypeByID :one +SELECT + id, + description +FROM address_types +WHERE id = $1 +` + +// Returns a single address type by its ID. +func (q *Queries) GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) { + row := q.queryRow(ctx, q.getAddressTypeByIDStmt, GetAddressTypeByID, id) + var i AddressType + err := row.Scan(&i.ID, &i.Description) + return i, err +} + +const ListAddressTypes = `-- name: ListAddressTypes :many +SELECT + id, + description +FROM address_types +ORDER BY id +` + +// Returns all address types ordered by ID. +func (q *Queries) ListAddressTypes(ctx context.Context) ([]AddressType, error) { + rows, err := q.query(ctx, q.listAddressTypesStmt, ListAddressTypes) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AddressType + for rows.Next() { + var i AddressType + if err := rows.Scan(&i.ID, &i.Description); 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 +} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 57320c40fb..77bc5e5392 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -30,6 +30,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) + } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } @@ -51,6 +54,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) } + if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { + return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } @@ -75,6 +81,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.getAddressTypeByIDStmt != nil { + if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) + } + } if q.getBlockByHeightStmt != nil { if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) @@ -110,6 +121,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) } } + if q.listAddressTypesStmt != nil { + if cerr := q.listAddressTypesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -166,6 +182,7 @@ type Queries struct { tx *sql.Tx createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt @@ -173,6 +190,7 @@ type Queries struct { insertBlockStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt insertWalletSyncStateStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt listWalletsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt @@ -184,6 +202,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { tx: tx, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, @@ -191,6 +210,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { insertBlockStmt: q.insertBlockStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAddressTypesStmt: q.listAddressTypesStmt, listWalletsStmt: q.listWalletsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 3282b71b51..920f8ce7aa 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -9,6 +9,11 @@ import ( "time" ) +type AddressType struct { + ID int16 + Description string +} + type Block struct { BlockHeight int32 HeaderHash []byte diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 9a3de084cd..30988e20a5 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -11,6 +11,8 @@ import ( type Querier interface { CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int32) error + // Returns a single address type by its ID. + GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) @@ -18,6 +20,8 @@ type Querier interface { InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + // Returns all address types ordered by ID. + ListAddressTypes(ctx context.Context) ([]AddressType, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) diff --git a/wallet/internal/db/sqlc/sqlite/address_types.sql.go b/wallet/internal/db/sqlc/sqlite/address_types.sql.go new file mode 100644 index 0000000000..5aa5cfdd5b --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/address_types.sql.go @@ -0,0 +1,58 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: address_types.sql + +package sqlcsqlite + +import ( + "context" +) + +const GetAddressTypeByID = `-- name: GetAddressTypeByID :one +SELECT + id, + description +FROM address_types +WHERE id = ? +` + +// Returns a single address type by its ID. +func (q *Queries) GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) { + row := q.queryRow(ctx, q.getAddressTypeByIDStmt, GetAddressTypeByID, id) + var i AddressType + err := row.Scan(&i.ID, &i.Description) + return i, err +} + +const ListAddressTypes = `-- name: ListAddressTypes :many +SELECT + id, + description +FROM address_types +ORDER BY id +` + +// Returns all address types ordered by ID. +func (q *Queries) ListAddressTypes(ctx context.Context) ([]AddressType, error) { + rows, err := q.query(ctx, q.listAddressTypesStmt, ListAddressTypes) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AddressType + for rows.Next() { + var i AddressType + if err := rows.Scan(&i.ID, &i.Description); 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 +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index c7080a445a..685bbdd06a 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -30,6 +30,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) + } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } @@ -51,6 +54,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) } + if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { + return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } @@ -75,6 +81,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.getAddressTypeByIDStmt != nil { + if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) + } + } if q.getBlockByHeightStmt != nil { if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) @@ -110,6 +121,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) } } + if q.listAddressTypesStmt != nil { + if cerr := q.listAddressTypesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -166,6 +182,7 @@ type Queries struct { tx *sql.Tx createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt @@ -173,6 +190,7 @@ type Queries struct { insertBlockStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt insertWalletSyncStateStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt listWalletsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt @@ -184,6 +202,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { tx: tx, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, @@ -191,6 +210,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { insertBlockStmt: q.insertBlockStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAddressTypesStmt: q.listAddressTypesStmt, listWalletsStmt: q.listWalletsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 8a93984406..448a62ca65 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -9,6 +9,11 @@ import ( "time" ) +type AddressType struct { + ID int64 + Description string +} + type Block struct { BlockHeight int64 HeaderHash []byte diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 6f11ef506a..edcbb3cdf7 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -11,6 +11,8 @@ import ( type Querier interface { CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int64) error + // Returns a single address type by its ID. + GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) @@ -18,6 +20,8 @@ type Querier interface { InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + // Returns all address types ordered by ID. + ListAddressTypes(ctx context.Context) ([]AddressType, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) From feadca67860fcfa1cbe7d2cc28de853f9dede31a Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 14:56:28 -0300 Subject: [PATCH 350/691] wallet: add safecasting for uint8 --- wallet/internal/db/safecasting.go | 22 ++++++ wallet/internal/db/safecasting_test.go | 100 +++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 1ddc2b0827..9ffbea2f2b 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -43,6 +43,28 @@ func int64ToInt32(v int64) (int32, error) { return int32(v), nil } +// int64ToUint8 safely casts an int64 to an uint8, returning an error +// if the value is out of range. +func int64ToUint8(v int64) (uint8, error) { + if v < 0 || v > math.MaxUint8 { + return 0, fmt.Errorf("could not cast %d to uint8: %w", v, + ErrCastingOverflow) + } + + return uint8(v), nil +} + +// int16ToUint8 safely casts an int16 to an uint8, returning an error +// if the value is out of range. +func int16ToUint8(v int16) (uint8, error) { + if v < 0 || v > math.MaxUint8 { + return 0, fmt.Errorf("could not cast %d to uint8: %w", v, + ErrCastingOverflow) + } + + return uint8(v), nil +} + // uint32ToInt32 safely casts an uint32 to an int32, returning an error // if the value is out of range. func uint32ToInt32(v uint32) (int32, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index d87fb89466..ec4b4d748a 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -100,6 +100,106 @@ func TestInt64ToInt32(t *testing.T) { } } +// TestInt64ToUint8 checks that an int64 value is converted to uint8 only +// when it is non-negative and fits within the uint8 range. It should fail +// loudly for any value outside those bounds. +func TestInt64ToUint8(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int64 + want uint8 + wantErr bool + }{ + { + name: "zero", + val: 0, + want: 0, + }, + { + name: "max uint8", + val: int64(math.MaxUint8), + want: math.MaxUint8, + }, + { + name: "negative", + val: -1, + wantErr: true, + }, + { + name: "too large", + val: int64(math.MaxUint8) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int64ToUint8(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestInt16ToUint8 checks that an int16 value is converted to uint8 only +// when it is non-negative and fits within the uint8 range. It should fail +// loudly for any value outside those bounds. +func TestInt16ToUint8(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int16 + want uint8 + wantErr bool + }{ + { + name: "zero", + val: 0, + want: 0, + }, + { + name: "max uint8", + val: int16(math.MaxUint8), + want: math.MaxUint8, + }, + { + name: "negative", + val: -1, + wantErr: true, + }, + { + name: "too large", + val: int16(math.MaxUint8) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int16ToUint8(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + // TestUint32ToInt32 checks that an uint32 value is safely converted to int32 // only when it fits within the signed 32 bit range. It should fail loudly // for any value that exceeds those limits. From 1f66b7764542229b5be823c83913363bc7135bcd Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 15 Dec 2025 14:57:15 -0300 Subject: [PATCH 351/691] wallet: add address types store implementation --- wallet/internal/db/address_types_common.go | 63 ++++++++++ wallet/internal/db/address_types_pg.go | 39 ++++++ wallet/internal/db/address_types_sqlite.go | 41 ++++++ wallet/internal/db/data_types.go | 19 +++ wallet/internal/db/interface.go | 12 ++ .../db/itest/address_types_store_test.go | 117 ++++++++++++++++++ 6 files changed, 291 insertions(+) create mode 100644 wallet/internal/db/address_types_common.go create mode 100644 wallet/internal/db/address_types_pg.go create mode 100644 wallet/internal/db/address_types_sqlite.go create mode 100644 wallet/internal/db/itest/address_types_store_test.go diff --git a/wallet/internal/db/address_types_common.go b/wallet/internal/db/address_types_common.go new file mode 100644 index 0000000000..fa69e047b0 --- /dev/null +++ b/wallet/internal/db/address_types_common.go @@ -0,0 +1,63 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +func addressTypeInfosFromRows[T any](rows []T, + toInfo func(T) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { + + types := make([]AddressTypeInfo, len(rows)) + for i, row := range rows { + info, err := toInfo(row) + if err != nil { + return nil, err + } + + types[i] = info + } + + return types, nil +} + +func listAddressTypes[T any](ctx context.Context, + list func(context.Context) ([]T, error), + toInfo func(T) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { + + rows, err := list(ctx) + if err != nil { + return nil, fmt.Errorf("list address types: %w", err) + } + + return addressTypeInfosFromRows(rows, toInfo) +} + +func getAddressTypeByID[T any, ID any](ctx context.Context, + get func(context.Context, ID) (T, error), queryID ID, + id AddressType, toInfo func(T) (AddressTypeInfo, error)) ( + AddressTypeInfo, error) { + + row, err := get(ctx, queryID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return AddressTypeInfo{}, fmt.Errorf( + "address type %d: %w", id, + ErrAddressTypeNotFound, + ) + } + + return AddressTypeInfo{}, fmt.Errorf("get address type: %w", + err) + } + + info, err := toInfo(row) + if err != nil { + return AddressTypeInfo{}, fmt.Errorf("get address type: %w", + err) + } + + return info, nil +} diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/address_types_pg.go new file mode 100644 index 0000000000..6f906ccc5d --- /dev/null +++ b/wallet/internal/db/address_types_pg.go @@ -0,0 +1,39 @@ +package db + +import ( + "context" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +func pgAddressTypeToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { + id, err := int16ToUint8(row.ID) + if err != nil { + return AddressTypeInfo{}, fmt.Errorf("address type id %d: %w", + row.ID, err) + } + + return AddressTypeInfo{ + Type: AddressType(id), + Description: row.Description, + }, nil +} + +// ListAddressTypes returns all supported address types along with their +// readable descriptions, wrapped in AddressTypeInfo values. +func (w *PostgresWalletDB) ListAddressTypes(ctx context.Context) ( + []AddressTypeInfo, error) { + + return listAddressTypes(ctx, w.queries.ListAddressTypes, + pgAddressTypeToInfo) +} + +// GetAddressType returns the AddressTypeInfo associated with the given address +// type identifier. An error is returned if the type is unknown. +func (w *PostgresWalletDB) GetAddressType(ctx context.Context, + id AddressType) (AddressTypeInfo, error) { + + return getAddressTypeByID(ctx, w.queries.GetAddressTypeByID, + int16(id), id, pgAddressTypeToInfo) +} diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/address_types_sqlite.go new file mode 100644 index 0000000000..b1b2d998bd --- /dev/null +++ b/wallet/internal/db/address_types_sqlite.go @@ -0,0 +1,41 @@ +package db + +import ( + "context" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +func sqliteAddressTypeToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, + error) { + + id, err := int64ToUint8(row.ID) + if err != nil { + return AddressTypeInfo{}, fmt.Errorf("address type id %d: %w", + row.ID, err) + } + + return AddressTypeInfo{ + Type: AddressType(id), + Description: row.Description, + }, nil +} + +// ListAddressTypes returns all supported address types along with their +// readable descriptions, wrapped in AddressTypeInfo values. +func (w *SQLiteWalletDB) ListAddressTypes(ctx context.Context) ( + []AddressTypeInfo, error) { + + return listAddressTypes(ctx, w.queries.ListAddressTypes, + sqliteAddressTypeToInfo) +} + +// GetAddressType returns the AddressTypeInfo associated with the given address +// type identifier. An error is returned if the type is unknown. +func (w *SQLiteWalletDB) GetAddressType(ctx context.Context, + id AddressType) (AddressTypeInfo, error) { + + return getAddressTypeByID(ctx, w.queries.GetAddressTypeByID, + int64(id), id, sqliteAddressTypeToInfo) +} diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 881c9665af..a2dc6142f7 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -30,6 +30,9 @@ type KeyScope struct { // AddressType specifies the type of a managed address. This is used to // identify the script type of an address, such as P2PKH, P2SH, P2WKH, etc. +// +// The enum values MUST match the IDs in the address_types database table. +// See migration 000003_address_types for the canonical descriptions. type AddressType uint8 const ( @@ -43,6 +46,10 @@ const ( // address. WitnessPubKey + // WitnessScript represents a pay-to-witness-script-hash (P2WSH) + // address. + WitnessScript + // NestedWitnessPubKey represents a P2WKH output nested within a P2SH // address. NestedWitnessPubKey @@ -51,6 +58,18 @@ const ( TaprootPubKey ) +// AddressTypeInfo groups an address type identifier with its readable +// description. +type AddressTypeInfo struct { + // Type is the AddressType value used as the unique identifier on both + // the application side and the database side. + Type AddressType + + // Description is a readable description of the address type. + // It is intended for argument parsing, logging, and user-facing output. + Description string +} + const ( // BIP0044Purpose is the purpose field for BIP0044 derivation. BIP0044Purpose = 44 diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 09f96dab72..18e6de92c7 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -13,6 +13,9 @@ var ( // database. ErrWalletNotFound = errors.New("wallet not found") + // ErrAddressTypeNotFound is returned when an address type is not found. + ErrAddressTypeNotFound = errors.New("address type not found") + // ErrSecretNotFound is returned when a secret is not found or is empty // in the database. ErrSecretNotFound = errors.New("secret not found") @@ -134,6 +137,15 @@ type AddressStore interface { // imports. GetPrivateKey(ctx context.Context, params GetPrivateKeyParams) ( *btcec.PrivateKey, error) + + // ListAddressTypes returns all supported address types along with their + // readable descriptions, wrapped in AddressTypeInfo values. + ListAddressTypes(ctx context.Context) ([]AddressTypeInfo, error) + + // GetAddressType returns the AddressTypeInfo associated with the given + // address type identifier. An error is returned if the type is unknown. + GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, + error) } // TxStore defines the database actions for managing transaction records. diff --git a/wallet/internal/db/itest/address_types_store_test.go b/wallet/internal/db/itest/address_types_store_test.go new file mode 100644 index 0000000000..27653f499e --- /dev/null +++ b/wallet/internal/db/itest/address_types_store_test.go @@ -0,0 +1,117 @@ +//go:build itest + +package itest + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +func TestListAddressTypes(t *testing.T) { + t.Parallel() + store, _ := NewTestStore(t) + + types, err := store.ListAddressTypes(t.Context()) + require.NoError(t, err) + + want := []db.AddressTypeInfo{ + {Type: db.PubKeyHash, Description: "P2PKH"}, + {Type: db.ScriptHash, Description: "P2SH"}, + {Type: db.WitnessPubKey, Description: "P2WPKH"}, + {Type: db.WitnessScript, Description: "P2WSH"}, + {Type: db.NestedWitnessPubKey, Description: "P2SH-P2WPKH"}, + {Type: db.TaprootPubKey, Description: "P2TR"}, + } + + require.Equal(t, want, types) +} + +func TestGetAddressType(t *testing.T) { + t.Parallel() + store, _ := NewTestStore(t) + + tests := []struct { + id db.AddressType + wantResult *db.AddressTypeInfo + wantErr error + }{ + { + id: 0, + wantResult: &db.AddressTypeInfo{ + Type: db.PubKeyHash, + Description: "P2PKH", + }, + }, + { + id: 1, + wantResult: &db.AddressTypeInfo{ + Type: db.ScriptHash, + Description: "P2SH", + }, + }, + { + id: 2, + wantResult: &db.AddressTypeInfo{ + Type: db.WitnessPubKey, + Description: "P2WPKH", + }, + }, + { + id: 3, + wantResult: &db.AddressTypeInfo{ + Type: db.WitnessScript, + Description: "P2WSH", + }, + }, + { + id: 4, + wantResult: &db.AddressTypeInfo{ + Type: db.NestedWitnessPubKey, + Description: "P2SH-P2WPKH", + }, + }, + { + id: 5, + wantResult: &db.AddressTypeInfo{ + Type: db.TaprootPubKey, + Description: "P2TR", + }, + }, + { + // Means last valid plus one, so it should be invalid + id: 6, + wantErr: db.ErrAddressTypeNotFound, + }, + { + // some other invalid id + id: 100, + wantErr: db.ErrAddressTypeNotFound, + }, + } + + for _, tc := range tests { + name := fmt.Sprintf("id_%d_expect_", tc.id) + if tc.wantResult != nil { + name += tc.wantResult.Description + } else { + name += tc.wantErr.Error() + } + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := store.GetAddressType(t.Context(), tc.id) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, *tc.wantResult, got) + }) + } +} From 6994df2e1e2021ed291514ac8d51301a66f03ef8 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 17 Dec 2025 14:50:22 -0300 Subject: [PATCH 352/691] wallet: add P2A and P2PK address types --- wallet/internal/db/data_types.go | 16 +++++--- .../db/itest/address_types_store_test.go | 38 +++++++++++++------ .../postgres/000003_address_types.up.sql | 14 ++++--- .../sqlite/000003_address_types.up.sql | 14 ++++--- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index a2dc6142f7..ee5ef8e126 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -36,12 +36,19 @@ type KeyScope struct { type AddressType uint8 const ( + // RawPubKey represents a pay-to-pubkey (P2PK) address. + RawPubKey AddressType = iota + // PubKeyHash represents a pay-to-pubkey-hash (P2PKH) address. - PubKeyHash AddressType = iota + PubKeyHash // ScriptHash represents a pay-to-script-hash (P2SH) address. ScriptHash + // NestedWitnessPubKey represents a P2WKH output nested within a P2SH + // address. + NestedWitnessPubKey + // WitnessPubKey represents a pay-to-witness-pubkey-hash (P2WKH) // address. WitnessPubKey @@ -50,12 +57,11 @@ const ( // address. WitnessScript - // NestedWitnessPubKey represents a P2WKH output nested within a P2SH - // address. - NestedWitnessPubKey - // TaprootPubKey represents a pay-to-taproot (P2TR) address. TaprootPubKey + + // Anchor represents a pay-to-anchor (P2A) address. + Anchor ) // AddressTypeInfo groups an address type identifier with its readable diff --git a/wallet/internal/db/itest/address_types_store_test.go b/wallet/internal/db/itest/address_types_store_test.go index 27653f499e..16586a8224 100644 --- a/wallet/internal/db/itest/address_types_store_test.go +++ b/wallet/internal/db/itest/address_types_store_test.go @@ -18,12 +18,14 @@ func TestListAddressTypes(t *testing.T) { require.NoError(t, err) want := []db.AddressTypeInfo{ + {Type: db.RawPubKey, Description: "P2PK"}, {Type: db.PubKeyHash, Description: "P2PKH"}, {Type: db.ScriptHash, Description: "P2SH"}, + {Type: db.NestedWitnessPubKey, Description: "P2SH-P2WPKH"}, {Type: db.WitnessPubKey, Description: "P2WPKH"}, {Type: db.WitnessScript, Description: "P2WSH"}, - {Type: db.NestedWitnessPubKey, Description: "P2SH-P2WPKH"}, {Type: db.TaprootPubKey, Description: "P2TR"}, + {Type: db.Anchor, Description: "P2A"}, } require.Equal(t, want, types) @@ -40,49 +42,63 @@ func TestGetAddressType(t *testing.T) { }{ { id: 0, + wantResult: &db.AddressTypeInfo{ + Type: db.RawPubKey, + Description: "P2PK", + }, + }, + { + id: 1, wantResult: &db.AddressTypeInfo{ Type: db.PubKeyHash, Description: "P2PKH", }, }, { - id: 1, + id: 2, wantResult: &db.AddressTypeInfo{ Type: db.ScriptHash, Description: "P2SH", }, }, { - id: 2, + id: 3, + wantResult: &db.AddressTypeInfo{ + Type: db.NestedWitnessPubKey, + Description: "P2SH-P2WPKH", + }, + }, + { + id: 4, wantResult: &db.AddressTypeInfo{ Type: db.WitnessPubKey, Description: "P2WPKH", }, }, { - id: 3, + id: 5, wantResult: &db.AddressTypeInfo{ Type: db.WitnessScript, Description: "P2WSH", }, }, { - id: 4, + id: 6, wantResult: &db.AddressTypeInfo{ - Type: db.NestedWitnessPubKey, - Description: "P2SH-P2WPKH", + Type: db.TaprootPubKey, + Description: "P2TR", }, }, { - id: 5, + id: 7, wantResult: &db.AddressTypeInfo{ - Type: db.TaprootPubKey, - Description: "P2TR", + Type: db.Anchor, + Description: "P2A", }, }, { // Means last valid plus one, so it should be invalid - id: 6, + id: 8, wantErr: db.ErrAddressTypeNotFound, }, { diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.up.sql b/wallet/internal/db/migrations/postgres/000003_address_types.up.sql index 0f474e79bb..ac810bbfb7 100644 --- a/wallet/internal/db/migrations/postgres/000003_address_types.up.sql +++ b/wallet/internal/db/migrations/postgres/000003_address_types.up.sql @@ -24,9 +24,11 @@ ON address_types (description); -- These values are static and represent the Bitcoin address types. -- IDs MUST match the iota values in wallet/internal/db/data_types.go. INSERT INTO address_types (id, description) VALUES -(0, 'P2PKH'), -- Pay-to-PubKey-Hash -(1, 'P2SH'), -- Pay-to-Script-Hash -(2, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash -(3, 'P2WSH'), -- Pay-to-Witness-Script-Hash -(4, 'P2SH-P2WPKH'), -- Nested Witness PubKey -(5, 'P2TR'); -- Pay-to-Taproot +(0, 'P2PK'), -- Pay-to-PubKey +(1, 'P2PKH'), -- Pay-to-PubKey-Hash +(2, 'P2SH'), -- Pay-to-Script-Hash +(3, 'P2SH-P2WPKH'), -- Nested Witness PubKey +(4, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash +(5, 'P2WSH'), -- Pay-to-Witness-Script-Hash +(6, 'P2TR'), -- Pay-to-Taproot +(7, 'P2A'); -- Pay-to-Anchor diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql b/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql index 8722966aa6..0ced45fd58 100644 --- a/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql +++ b/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql @@ -24,9 +24,11 @@ ON address_types (description); -- These values are static and represent the Bitcoin address types. -- IDs MUST match the iota values in wallet/internal/db/data_types.go. INSERT INTO address_types (id, description) VALUES -(0, 'P2PKH'), -- Pay-to-PubKey-Hash -(1, 'P2SH'), -- Pay-to-Script-Hash -(2, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash -(3, 'P2WSH'), -- Pay-to-Witness-Script-Hash -(4, 'P2SH-P2WPKH'), -- Nested Witness PubKey -(5, 'P2TR'); -- Pay-to-Taproot +(0, 'P2PK'), -- Pay-to-PubKey +(1, 'P2PKH'), -- Pay-to-PubKey-Hash +(2, 'P2SH'), -- Pay-to-Script-Hash +(3, 'P2SH-P2WPKH'), -- Nested Witness PubKey +(4, 'P2WPKH'), -- Pay-to-Witness-PubKey-Hash +(5, 'P2WSH'), -- Pay-to-Witness-Script-Hash +(6, 'P2TR'), -- Pay-to-Taproot +(7, 'P2A'); -- Pay-to-Anchor From 50b6c99c9031667439d986eaaa2e4de8eced499f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 16 Dec 2025 23:23:34 -0300 Subject: [PATCH 353/691] wallet: add key scope SQL migrations --- .../postgres/000004_key_scopes.down.sql | 4 ++ .../postgres/000004_key_scopes.up.sql | 64 +++++++++++++++++++ .../sqlite/000004_key_scopes.down.sql | 4 ++ .../sqlite/000004_key_scopes.up.sql | 64 +++++++++++++++++++ wallet/internal/db/sqlc/postgres/models.go | 15 +++++ wallet/internal/db/sqlc/sqlite/models.go | 15 +++++ 6 files changed, 166 insertions(+) create mode 100644 wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql new file mode 100644 index 0000000000..5f22164220 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database in unexpected state. +DROP TABLE IF EXISTS key_scope_secrets; +DROP TABLE IF EXISTS key_scopes; diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql new file mode 100644 index 0000000000..e7165cdcc0 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql @@ -0,0 +1,64 @@ +-- Key Scopes table to store different key scopes (BIP standards) for each +-- wallet. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE key_scopes ( + -- DB ID of the key scope, primary key. Only used for DB level relations. + id BIGSERIAL PRIMARY KEY, + + -- Reference to the wallet this key scope belongs to. + wallet_id BIGINT NOT NULL, + + -- Indicates the BIP standard for the key scope. This is typically will be + -- 84h or 1017h. + purpose BIGINT NOT NULL, + + -- Indicates the coin type for the key scope. This is typically 0 for BTC. + coin_type BIGINT NOT NULL, + + -- Encrypted key used to derive public keys for this scope. + encrypted_coin_pub_key BYTEA NOT NULL, + + -- Reference to the address type used for internal/change addresses. + internal_type_id SMALLINT NOT NULL, + + -- Reference to the address type used for external/receiving addresses. + external_type_id SMALLINT NOT NULL, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if key scopes still exist. + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, + + -- Foreign key constraints to address types. Using ON DELETE RESTRICT to ensure + -- that the address types cannot be deleted if key scopes still exist. + FOREIGN KEY (internal_type_id) REFERENCES address_types (id) ON DELETE RESTRICT, + FOREIGN KEY (external_type_id) REFERENCES address_types (id) ON DELETE RESTRICT +); + +-- Unique index to prevent duplicate key scopes for the same wallet. +CREATE UNIQUE INDEX uidx_key_scopes_wallet_purpose_coin +ON key_scopes (wallet_id, purpose, coin_type); + +-- Key Scope Secrets table to hold encrypted coin-type secrets for each scope. +-- Separated from the main key_scopes table for security and access pattern isolation. +-- Watch-only scopes may have no corresponding row in this table or have NULL +-- encrypted_coin_priv_key. +CREATE TABLE key_scope_secrets ( + -- Reference to the key scope these keys belong to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + scope_id BIGINT NOT NULL, + + -- Encrypted key used to derive private keys for this scope. + -- NULL for watch-only key scopes. + encrypted_coin_priv_key BYTEA, + + -- Foreign key constraint to key_scopes. Using ON DELETE RESTRICT to ensure + -- that the key scope cannot be deleted if secrets still exist. + FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each key scope has at most one secrets record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_key_scope_secrets_scope +ON key_scope_secrets (scope_id); diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql new file mode 100644 index 0000000000..5f22164220 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database in unexpected state. +DROP TABLE IF EXISTS key_scope_secrets; +DROP TABLE IF EXISTS key_scopes; diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql new file mode 100644 index 0000000000..55952cd11b --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql @@ -0,0 +1,64 @@ +-- Key Scopes table to store different key scopes (BIP standards) for each +-- wallet. +-- +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. +CREATE TABLE key_scopes ( + -- DB ID of the key scope, primary key. Only used for DB level relations. + id INTEGER PRIMARY KEY, + + -- Reference to the wallet this key scope belongs to. + wallet_id INTEGER NOT NULL, + + -- Indicates the BIP standard for the key scope. This is typically will be + -- 84h or 1017h. + purpose INTEGER NOT NULL, + + -- Indicates the coin type for the key scope. This is typically 0 for BTC. + coin_type INTEGER NOT NULL, + + -- Encrypted key used to derive public keys for this scope. + encrypted_coin_pub_key BLOB NOT NULL, + + -- Reference to the address type used for internal/change addresses. + internal_type_id INTEGER NOT NULL, + + -- Reference to the address type used for external/receiving addresses. + external_type_id INTEGER NOT NULL, + + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure + -- that the wallet cannot be deleted if key scopes still exist. + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, + + -- Foreign key constraints to address types. Using ON DELETE RESTRICT to ensure + -- that the address types cannot be deleted if key scopes still exist. + FOREIGN KEY (internal_type_id) REFERENCES address_types (id) ON DELETE RESTRICT, + FOREIGN KEY (external_type_id) REFERENCES address_types (id) ON DELETE RESTRICT +); + +-- Unique index to prevent duplicate key scopes for the same wallet. +CREATE UNIQUE INDEX uidx_key_scopes_wallet_purpose_coin +ON key_scopes (wallet_id, purpose, coin_type); + +-- Key Scope Secrets table to hold encrypted coin-type secrets for each scope. +-- Separated from the main key_scopes table for security and access pattern isolation. +-- Watch-only scopes may have no corresponding row in this table or have NULL +-- encrypted_coin_priv_key. +CREATE TABLE key_scope_secrets ( + -- Reference to the key scope these keys belong to. Acts as the primary key + -- via the unique index below, enforcing one-to-one relationship. + scope_id INTEGER NOT NULL, + + -- Encrypted key used to derive private keys for this scope. + -- NULL for watch-only key scopes. + encrypted_coin_priv_key BLOB, + + -- Foreign key constraint to key_scopes. Using ON DELETE RESTRICT to ensure + -- that the key scope cannot be deleted if secrets still exist. + FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT +); + +-- Enforces one-to-one relationship: each key scope has at most one secrets record. +-- Also serves as the effective primary key for this table. +CREATE UNIQUE INDEX uidx_key_scope_secrets_scope +ON key_scope_secrets (scope_id); diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 920f8ce7aa..4c49c371e0 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -20,6 +20,21 @@ type Block struct { BlockTimestamp int64 } +type KeyScope struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + +type KeyScopeSecret struct { + ScopeID int64 + EncryptedCoinPrivKey []byte +} + type Wallet struct { ID int64 WalletName string diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 448a62ca65..d32eca6e07 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -20,6 +20,21 @@ type Block struct { BlockTimestamp int64 } +type KeyScope struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + +type KeyScopeSecret struct { + ScopeID int64 + EncryptedCoinPrivKey []byte +} + type Wallet struct { ID int64 WalletName string From 699c77c8626c39b8862fde624fe8389ceab14692 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 16 Dec 2025 23:24:44 -0300 Subject: [PATCH 354/691] wallet: add key scope SQL queries --- .../db/queries/postgres/key_scopes.sql | 81 +++++++ .../internal/db/queries/sqlite/key_scopes.sql | 81 +++++++ wallet/internal/db/sqlc/postgres/db.go | 144 ++++++++--- .../db/sqlc/postgres/key_scopes.sql.go | 223 ++++++++++++++++++ wallet/internal/db/sqlc/postgres/querier.go | 17 ++ wallet/internal/db/sqlc/sqlite/db.go | 144 ++++++++--- .../internal/db/sqlc/sqlite/key_scopes.sql.go | 223 ++++++++++++++++++ wallet/internal/db/sqlc/sqlite/querier.go | 17 ++ 8 files changed, 866 insertions(+), 64 deletions(-) create mode 100644 wallet/internal/db/queries/postgres/key_scopes.sql create mode 100644 wallet/internal/db/queries/sqlite/key_scopes.sql create mode 100644 wallet/internal/db/sqlc/postgres/key_scopes.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/key_scopes.sql.go diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/db/queries/postgres/key_scopes.sql new file mode 100644 index 0000000000..b1061927b8 --- /dev/null +++ b/wallet/internal/db/queries/postgres/key_scopes.sql @@ -0,0 +1,81 @@ +-- name: CreateKeyScope :one +-- Creates a new key scope for a wallet and returns its ID. +INSERT INTO key_scopes ( + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +RETURNING id; + +-- name: InsertKeyScopeSecrets :exec +-- Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for +-- watch-only scopes. +INSERT INTO key_scope_secrets ( + scope_id, + encrypted_coin_priv_key +) VALUES ( + $1, $2 +); + +-- name: GetKeyScopeByID :one +-- Retrieves a key scope by its ID. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE id = $1; + +-- name: GetKeyScopeByWalletAndScope :one +-- Retrieves a key scope by wallet ID, purpose, and coin type. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = $1 AND purpose = $2 AND coin_type = $3; + +-- name: ListKeyScopesByWallet :many +-- Lists all key scopes for a wallet, ordered by ID. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = $1 +ORDER BY id; + +-- name: GetKeyScopeSecrets :one +-- Retrieves the secrets for a key scope. +SELECT + scope_id, + encrypted_coin_priv_key +FROM key_scope_secrets +WHERE scope_id = $1; + +-- name: DeleteKeyScopeSecrets :execrows +-- Deletes the secrets for a key scope. +DELETE FROM key_scope_secrets +WHERE scope_id = $1; + +-- name: DeleteKeyScope :execrows +-- Deletes a key scope by its ID. +DELETE FROM key_scopes +WHERE id = $1; diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/db/queries/sqlite/key_scopes.sql new file mode 100644 index 0000000000..0385f64685 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/key_scopes.sql @@ -0,0 +1,81 @@ +-- name: CreateKeyScope :one +-- Creates a new key scope for a wallet and returns its ID. +INSERT INTO key_scopes ( + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +) VALUES ( + ?, ?, ?, ?, ?, ? +) +RETURNING id; + +-- name: InsertKeyScopeSecrets :exec +-- Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for +-- watch-only scopes. +INSERT INTO key_scope_secrets ( + scope_id, + encrypted_coin_priv_key +) VALUES ( + ?, ? +); + +-- name: GetKeyScopeByID :one +-- Retrieves a key scope by its ID. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE id = ?; + +-- name: GetKeyScopeByWalletAndScope :one +-- Retrieves a key scope by wallet ID, purpose, and coin type. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = ? AND purpose = ? AND coin_type = ?; + +-- name: ListKeyScopesByWallet :many +-- Lists all key scopes for a wallet, ordered by ID. +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = ? +ORDER BY id; + +-- name: GetKeyScopeSecrets :one +-- Retrieves the secrets for a key scope. +SELECT + scope_id, + encrypted_coin_priv_key +FROM key_scope_secrets +WHERE scope_id = ?; + +-- name: DeleteKeyScopeSecrets :execrows +-- Deletes the secrets for a key scope. +DELETE FROM key_scope_secrets +WHERE scope_id = ?; + +-- name: DeleteKeyScope :execrows +-- Deletes a key scope by its ID. +DELETE FROM key_scopes +WHERE id = ?; diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 77bc5e5392..870a1598ad 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -24,18 +24,36 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { + return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) + } if q.createWalletStmt, err = db.PrepareContext(ctx, CreateWallet); err != nil { return nil, fmt.Errorf("error preparing query CreateWallet: %w", err) } if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.deleteKeyScopeStmt, err = db.PrepareContext(ctx, DeleteKeyScope); err != nil { + return nil, fmt.Errorf("error preparing query DeleteKeyScope: %w", err) + } + if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } + if q.getKeyScopeByIDStmt, err = db.PrepareContext(ctx, GetKeyScopeByID); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeByID: %w", err) + } + if q.getKeyScopeByWalletAndScopeStmt, err = db.PrepareContext(ctx, GetKeyScopeByWalletAndScope); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeByWalletAndScope: %w", err) + } + if q.getKeyScopeSecretsStmt, err = db.PrepareContext(ctx, GetKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeSecrets: %w", err) + } if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) } @@ -48,6 +66,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } + if q.insertKeyScopeSecretsStmt, err = db.PrepareContext(ctx, InsertKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query InsertKeyScopeSecrets: %w", err) + } if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) } @@ -57,6 +78,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } + if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { + return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } @@ -71,6 +95,11 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.createKeyScopeStmt != nil { + if cerr := q.createKeyScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) + } + } if q.createWalletStmt != nil { if cerr := q.createWalletStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createWalletStmt: %w", cerr) @@ -81,6 +110,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.deleteKeyScopeStmt != nil { + if cerr := q.deleteKeyScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteKeyScopeStmt: %w", cerr) + } + } + if q.deleteKeyScopeSecretsStmt != nil { + if cerr := q.deleteKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -91,6 +130,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) } } + if q.getKeyScopeByIDStmt != nil { + if cerr := q.getKeyScopeByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeByIDStmt: %w", cerr) + } + } + if q.getKeyScopeByWalletAndScopeStmt != nil { + if cerr := q.getKeyScopeByWalletAndScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeByWalletAndScopeStmt: %w", cerr) + } + } + if q.getKeyScopeSecretsStmt != nil { + if cerr := q.getKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeSecretsStmt: %w", cerr) + } + } if q.getWalletByIDStmt != nil { if cerr := q.getWalletByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) @@ -111,6 +165,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) } } + if q.insertKeyScopeSecretsStmt != nil { + if cerr := q.insertKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertKeyScopeSecretsStmt: %w", cerr) + } + } if q.insertWalletSecretsStmt != nil { if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) @@ -126,6 +185,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) } } + if q.listKeyScopesByWalletStmt != nil { + if cerr := q.listKeyScopesByWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -178,41 +242,57 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createWalletStmt *sql.Stmt - deleteBlockStmt *sql.Stmt - getAddressTypeByIDStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - getWalletByIDStmt *sql.Stmt - getWalletByNameStmt *sql.Stmt - getWalletSecretsStmt *sql.Stmt - insertBlockStmt *sql.Stmt - insertWalletSecretsStmt *sql.Stmt - insertWalletSyncStateStmt *sql.Stmt - listAddressTypesStmt *sql.Stmt - listWalletsStmt *sql.Stmt - updateWalletSecretsStmt *sql.Stmt - updateWalletSyncStateStmt *sql.Stmt + db DBTX + tx *sql.Tx + createKeyScopeStmt *sql.Stmt + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + deleteKeyScopeStmt *sql.Stmt + deleteKeyScopeSecretsStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getKeyScopeByIDStmt *sql.Stmt + getKeyScopeByWalletAndScopeStmt *sql.Stmt + getKeyScopeSecretsStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertKeyScopeSecretsStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt + listKeyScopesByWalletStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createWalletStmt: q.createWalletStmt, - deleteBlockStmt: q.deleteBlockStmt, - getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - getWalletByIDStmt: q.getWalletByIDStmt, - getWalletByNameStmt: q.getWalletByNameStmt, - getWalletSecretsStmt: q.getWalletSecretsStmt, - insertBlockStmt: q.insertBlockStmt, - insertWalletSecretsStmt: q.insertWalletSecretsStmt, - insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, - listAddressTypesStmt: q.listAddressTypesStmt, - listWalletsStmt: q.listWalletsStmt, - updateWalletSecretsStmt: q.updateWalletSecretsStmt, - updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, + db: tx, + tx: tx, + createKeyScopeStmt: q.createKeyScopeStmt, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + deleteKeyScopeStmt: q.deleteKeyScopeStmt, + deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, + getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, + getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAddressTypesStmt: q.listAddressTypesStmt, + listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listWalletsStmt: q.listWalletsStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go new file mode 100644 index 0000000000..7be4e847c2 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -0,0 +1,223 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: key_scopes.sql + +package sqlcpg + +import ( + "context" +) + +const CreateKeyScope = `-- name: CreateKeyScope :one +INSERT INTO key_scopes ( + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +RETURNING id +` + +type CreateKeyScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + +// Creates a new key scope for a wallet and returns its ID. +func (q *Queries) CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) { + row := q.queryRow(ctx, q.createKeyScopeStmt, CreateKeyScope, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.EncryptedCoinPubKey, + arg.InternalTypeID, + arg.ExternalTypeID, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const DeleteKeyScope = `-- name: DeleteKeyScope :execrows +DELETE FROM key_scopes +WHERE id = $1 +` + +// Deletes a key scope by its ID. +func (q *Queries) DeleteKeyScope(ctx context.Context, id int64) (int64, error) { + result, err := q.exec(ctx, q.deleteKeyScopeStmt, DeleteKeyScope, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteKeyScopeSecrets = `-- name: DeleteKeyScopeSecrets :execrows +DELETE FROM key_scope_secrets +WHERE scope_id = $1 +` + +// Deletes the secrets for a key scope. +func (q *Queries) DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) { + result, err := q.exec(ctx, q.deleteKeyScopeSecretsStmt, DeleteKeyScopeSecrets, scopeID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetKeyScopeByID = `-- name: GetKeyScopeByID :one +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE id = $1 +` + +// Retrieves a key scope by its ID. +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { + row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) + var i KeyScope + err := row.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const GetKeyScopeByWalletAndScope = `-- name: GetKeyScopeByWalletAndScope :one +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = $1 AND purpose = $2 AND coin_type = $3 +` + +type GetKeyScopeByWalletAndScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 +} + +// Retrieves a key scope by wallet ID, purpose, and coin type. +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { + row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) + var i KeyScope + err := row.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const GetKeyScopeSecrets = `-- name: GetKeyScopeSecrets :one +SELECT + scope_id, + encrypted_coin_priv_key +FROM key_scope_secrets +WHERE scope_id = $1 +` + +// Retrieves the secrets for a key scope. +func (q *Queries) GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) { + row := q.queryRow(ctx, q.getKeyScopeSecretsStmt, GetKeyScopeSecrets, scopeID) + var i KeyScopeSecret + err := row.Scan(&i.ScopeID, &i.EncryptedCoinPrivKey) + return i, err +} + +const InsertKeyScopeSecrets = `-- name: InsertKeyScopeSecrets :exec +INSERT INTO key_scope_secrets ( + scope_id, + encrypted_coin_priv_key +) VALUES ( + $1, $2 +) +` + +type InsertKeyScopeSecretsParams struct { + ScopeID int64 + EncryptedCoinPrivKey []byte +} + +// Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for +// watch-only scopes. +func (q *Queries) InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error { + _, err := q.exec(ctx, q.insertKeyScopeSecretsStmt, InsertKeyScopeSecrets, arg.ScopeID, arg.EncryptedCoinPrivKey) + return err +} + +const ListKeyScopesByWallet = `-- name: ListKeyScopesByWallet :many +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = $1 +ORDER BY id +` + +// Lists all key scopes for a wallet, ordered by ID. +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { + rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []KeyScope + for rows.Next() { + var i KeyScope + if err := rows.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ); 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 +} diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 30988e20a5..6277126563 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -9,19 +9,36 @@ import ( ) type Querier interface { + // Creates a new key scope for a wallet and returns its ID. + CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int32) error + // Deletes a key scope by its ID. + DeleteKeyScope(ctx context.Context, id int64) (int64, error) + // Deletes the secrets for a key scope. + DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) + // Retrieves a key scope by its ID. + GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) + // Retrieves a key scope by wallet ID, purpose, and coin type. + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) + // Retrieves the secrets for a key scope. + GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error + // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for + // watch-only scopes. + InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) + // Lists all key scopes for a wallet, ordered by ID. + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 685bbdd06a..a0079857b9 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,18 +24,36 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { + return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) + } if q.createWalletStmt, err = db.PrepareContext(ctx, CreateWallet); err != nil { return nil, fmt.Errorf("error preparing query CreateWallet: %w", err) } if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.deleteKeyScopeStmt, err = db.PrepareContext(ctx, DeleteKeyScope); err != nil { + return nil, fmt.Errorf("error preparing query DeleteKeyScope: %w", err) + } + if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } + if q.getKeyScopeByIDStmt, err = db.PrepareContext(ctx, GetKeyScopeByID); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeByID: %w", err) + } + if q.getKeyScopeByWalletAndScopeStmt, err = db.PrepareContext(ctx, GetKeyScopeByWalletAndScope); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeByWalletAndScope: %w", err) + } + if q.getKeyScopeSecretsStmt, err = db.PrepareContext(ctx, GetKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query GetKeyScopeSecrets: %w", err) + } if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) } @@ -48,6 +66,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } + if q.insertKeyScopeSecretsStmt, err = db.PrepareContext(ctx, InsertKeyScopeSecrets); err != nil { + return nil, fmt.Errorf("error preparing query InsertKeyScopeSecrets: %w", err) + } if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) } @@ -57,6 +78,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } + if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { + return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } @@ -71,6 +95,11 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.createKeyScopeStmt != nil { + if cerr := q.createKeyScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) + } + } if q.createWalletStmt != nil { if cerr := q.createWalletStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createWalletStmt: %w", cerr) @@ -81,6 +110,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.deleteKeyScopeStmt != nil { + if cerr := q.deleteKeyScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteKeyScopeStmt: %w", cerr) + } + } + if q.deleteKeyScopeSecretsStmt != nil { + if cerr := q.deleteKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -91,6 +130,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) } } + if q.getKeyScopeByIDStmt != nil { + if cerr := q.getKeyScopeByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeByIDStmt: %w", cerr) + } + } + if q.getKeyScopeByWalletAndScopeStmt != nil { + if cerr := q.getKeyScopeByWalletAndScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeByWalletAndScopeStmt: %w", cerr) + } + } + if q.getKeyScopeSecretsStmt != nil { + if cerr := q.getKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getKeyScopeSecretsStmt: %w", cerr) + } + } if q.getWalletByIDStmt != nil { if cerr := q.getWalletByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) @@ -111,6 +165,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) } } + if q.insertKeyScopeSecretsStmt != nil { + if cerr := q.insertKeyScopeSecretsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertKeyScopeSecretsStmt: %w", cerr) + } + } if q.insertWalletSecretsStmt != nil { if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) @@ -126,6 +185,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) } } + if q.listKeyScopesByWalletStmt != nil { + if cerr := q.listKeyScopesByWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -178,41 +242,57 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createWalletStmt *sql.Stmt - deleteBlockStmt *sql.Stmt - getAddressTypeByIDStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - getWalletByIDStmt *sql.Stmt - getWalletByNameStmt *sql.Stmt - getWalletSecretsStmt *sql.Stmt - insertBlockStmt *sql.Stmt - insertWalletSecretsStmt *sql.Stmt - insertWalletSyncStateStmt *sql.Stmt - listAddressTypesStmt *sql.Stmt - listWalletsStmt *sql.Stmt - updateWalletSecretsStmt *sql.Stmt - updateWalletSyncStateStmt *sql.Stmt + db DBTX + tx *sql.Tx + createKeyScopeStmt *sql.Stmt + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + deleteKeyScopeStmt *sql.Stmt + deleteKeyScopeSecretsStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getKeyScopeByIDStmt *sql.Stmt + getKeyScopeByWalletAndScopeStmt *sql.Stmt + getKeyScopeSecretsStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertKeyScopeSecretsStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt + listKeyScopesByWalletStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createWalletStmt: q.createWalletStmt, - deleteBlockStmt: q.deleteBlockStmt, - getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - getWalletByIDStmt: q.getWalletByIDStmt, - getWalletByNameStmt: q.getWalletByNameStmt, - getWalletSecretsStmt: q.getWalletSecretsStmt, - insertBlockStmt: q.insertBlockStmt, - insertWalletSecretsStmt: q.insertWalletSecretsStmt, - insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, - listAddressTypesStmt: q.listAddressTypesStmt, - listWalletsStmt: q.listWalletsStmt, - updateWalletSecretsStmt: q.updateWalletSecretsStmt, - updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, + db: tx, + tx: tx, + createKeyScopeStmt: q.createKeyScopeStmt, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + deleteKeyScopeStmt: q.deleteKeyScopeStmt, + deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, + getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, + getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAddressTypesStmt: q.listAddressTypesStmt, + listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listWalletsStmt: q.listWalletsStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go new file mode 100644 index 0000000000..4223e0789d --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -0,0 +1,223 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: key_scopes.sql + +package sqlcsqlite + +import ( + "context" +) + +const CreateKeyScope = `-- name: CreateKeyScope :one +INSERT INTO key_scopes ( + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +) VALUES ( + ?, ?, ?, ?, ?, ? +) +RETURNING id +` + +type CreateKeyScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + +// Creates a new key scope for a wallet and returns its ID. +func (q *Queries) CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) { + row := q.queryRow(ctx, q.createKeyScopeStmt, CreateKeyScope, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.EncryptedCoinPubKey, + arg.InternalTypeID, + arg.ExternalTypeID, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const DeleteKeyScope = `-- name: DeleteKeyScope :execrows +DELETE FROM key_scopes +WHERE id = ? +` + +// Deletes a key scope by its ID. +func (q *Queries) DeleteKeyScope(ctx context.Context, id int64) (int64, error) { + result, err := q.exec(ctx, q.deleteKeyScopeStmt, DeleteKeyScope, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteKeyScopeSecrets = `-- name: DeleteKeyScopeSecrets :execrows +DELETE FROM key_scope_secrets +WHERE scope_id = ? +` + +// Deletes the secrets for a key scope. +func (q *Queries) DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) { + result, err := q.exec(ctx, q.deleteKeyScopeSecretsStmt, DeleteKeyScopeSecrets, scopeID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetKeyScopeByID = `-- name: GetKeyScopeByID :one +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE id = ? +` + +// Retrieves a key scope by its ID. +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { + row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) + var i KeyScope + err := row.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const GetKeyScopeByWalletAndScope = `-- name: GetKeyScopeByWalletAndScope :one +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = ? AND purpose = ? AND coin_type = ? +` + +type GetKeyScopeByWalletAndScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 +} + +// Retrieves a key scope by wallet ID, purpose, and coin type. +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { + row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) + var i KeyScope + err := row.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const GetKeyScopeSecrets = `-- name: GetKeyScopeSecrets :one +SELECT + scope_id, + encrypted_coin_priv_key +FROM key_scope_secrets +WHERE scope_id = ? +` + +// Retrieves the secrets for a key scope. +func (q *Queries) GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) { + row := q.queryRow(ctx, q.getKeyScopeSecretsStmt, GetKeyScopeSecrets, scopeID) + var i KeyScopeSecret + err := row.Scan(&i.ScopeID, &i.EncryptedCoinPrivKey) + return i, err +} + +const InsertKeyScopeSecrets = `-- name: InsertKeyScopeSecrets :exec +INSERT INTO key_scope_secrets ( + scope_id, + encrypted_coin_priv_key +) VALUES ( + ?, ? +) +` + +type InsertKeyScopeSecretsParams struct { + ScopeID int64 + EncryptedCoinPrivKey []byte +} + +// Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for +// watch-only scopes. +func (q *Queries) InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error { + _, err := q.exec(ctx, q.insertKeyScopeSecretsStmt, InsertKeyScopeSecrets, arg.ScopeID, arg.EncryptedCoinPrivKey) + return err +} + +const ListKeyScopesByWallet = `-- name: ListKeyScopesByWallet :many +SELECT + id, + wallet_id, + purpose, + coin_type, + encrypted_coin_pub_key, + internal_type_id, + external_type_id +FROM key_scopes +WHERE wallet_id = ? +ORDER BY id +` + +// Lists all key scopes for a wallet, ordered by ID. +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { + rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []KeyScope + for rows.Next() { + var i KeyScope + if err := rows.Scan( + &i.ID, + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.EncryptedCoinPubKey, + &i.InternalTypeID, + &i.ExternalTypeID, + ); 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 +} diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index edcbb3cdf7..d489425bc2 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -9,19 +9,36 @@ import ( ) type Querier interface { + // Creates a new key scope for a wallet and returns its ID. + CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int64) error + // Deletes a key scope by its ID. + DeleteKeyScope(ctx context.Context, id int64) (int64, error) + // Deletes the secrets for a key scope. + DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) + // Retrieves a key scope by its ID. + GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) + // Retrieves a key scope by wallet ID, purpose, and coin type. + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) + // Retrieves the secrets for a key scope. + GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error + // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for + // watch-only scopes. + InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) + // Lists all key scopes for a wallet, ordered by ID. + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) From 5698e38e045095bd181edad6be88a787ef2d673f Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Mon, 22 Dec 2025 11:47:09 +0000 Subject: [PATCH 355/691] wallet: update blocks queries to handle race condition --- wallet/internal/db/queries/postgres/blocks.sql | 3 ++- wallet/internal/db/queries/sqlite/blocks.sql | 2 +- wallet/internal/db/sqlc/postgres/blocks.sql.go | 1 + wallet/internal/db/sqlc/sqlite/blocks.sql.go | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/wallet/internal/db/queries/postgres/blocks.sql b/wallet/internal/db/queries/postgres/blocks.sql index daa8e13e89..050e85c253 100644 --- a/wallet/internal/db/queries/postgres/blocks.sql +++ b/wallet/internal/db/queries/postgres/blocks.sql @@ -8,7 +8,8 @@ WHERE block_height = $1; -- name: InsertBlock :exec INSERT INTO blocks (block_height, header_hash, block_timestamp) -VALUES ($1, $2, $3); +VALUES ($1, $2, $3) +ON CONFLICT (block_height) DO NOTHING; -- name: DeleteBlock :exec DELETE FROM blocks diff --git a/wallet/internal/db/queries/sqlite/blocks.sql b/wallet/internal/db/queries/sqlite/blocks.sql index 077e33c897..003d6d3e83 100644 --- a/wallet/internal/db/queries/sqlite/blocks.sql +++ b/wallet/internal/db/queries/sqlite/blocks.sql @@ -7,7 +7,7 @@ FROM blocks WHERE block_height = ?; -- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, block_timestamp) +INSERT OR IGNORE INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?); -- name: DeleteBlock :exec diff --git a/wallet/internal/db/sqlc/postgres/blocks.sql.go b/wallet/internal/db/sqlc/postgres/blocks.sql.go index c95f43abdb..b5f83978cb 100644 --- a/wallet/internal/db/sqlc/postgres/blocks.sql.go +++ b/wallet/internal/db/sqlc/postgres/blocks.sql.go @@ -38,6 +38,7 @@ func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int32) (Bloc const InsertBlock = `-- name: InsertBlock :exec INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3) +ON CONFLICT (block_height) DO NOTHING ` type InsertBlockParams struct { diff --git a/wallet/internal/db/sqlc/sqlite/blocks.sql.go b/wallet/internal/db/sqlc/sqlite/blocks.sql.go index f5eb1d3e00..decb094d32 100644 --- a/wallet/internal/db/sqlc/sqlite/blocks.sql.go +++ b/wallet/internal/db/sqlc/sqlite/blocks.sql.go @@ -36,7 +36,7 @@ func (q *Queries) GetBlockByHeight(ctx context.Context, blockHeight int64) (Bloc } const InsertBlock = `-- name: InsertBlock :exec -INSERT INTO blocks (block_height, header_hash, block_timestamp) +INSERT OR IGNORE INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?) ` From c2aa4c7dce33ef97932a324166f45075922f53f3 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 18 Dec 2025 05:37:08 +0000 Subject: [PATCH 356/691] wallet: integrate relevant blocks queries --- wallet/internal/db/block_pg.go | 26 ++++++++ wallet/internal/db/block_sqlite.go | 24 ++++++++ wallet/internal/db/itest/wallet_store_test.go | 61 +++++++++++++++++++ wallet/internal/db/wallet_pg.go | 19 ++++++ wallet/internal/db/wallet_sqlite.go | 21 +++++++ 5 files changed, 151 insertions(+) diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/block_pg.go index 2cd05f3608..e49647dd50 100644 --- a/wallet/internal/db/block_pg.go +++ b/wallet/internal/db/block_pg.go @@ -1,8 +1,11 @@ package db import ( + "context" "database/sql" "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) // buildPgBlock constructs a Block from the given PostgreSQL block @@ -17,3 +20,26 @@ func buildPgBlock(height sql.NullInt32, hash []byte, return buildBlock(hash, height32, timestamp.Int64) } + +// ensureBlockExistsPg ensures that a block exists in the database. +func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, + block *Block) error { + + height, err := uint32ToInt32(block.Height) + if err != nil { + return fmt.Errorf("convert block height: %w", err) + } + + blockParams := sqlcpg.InsertBlockParams{ + BlockHeight: height, + HeaderHash: block.Hash[:], + BlockTimestamp: block.Timestamp.Unix(), + } + + err = qtx.InsertBlock(ctx, blockParams) + if err != nil { + return fmt.Errorf("insert block: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/block_sqlite.go index 7df790749c..8fd83c22bf 100644 --- a/wallet/internal/db/block_sqlite.go +++ b/wallet/internal/db/block_sqlite.go @@ -1,8 +1,11 @@ package db import ( + "context" "database/sql" "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) // buildSqliteBlock constructs a Block from the given SQLite block @@ -17,3 +20,24 @@ func buildSqliteBlock(height sql.NullInt64, hash []byte, return buildBlock(hash, height32, timestamp.Int64) } + +// ensureBlockExistsSqlite ensures that a block exists in the database. If it +// doesn't exist, it inserts it. +func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + block *Block) error { + + height := int64(block.Height) + + blockParams := sqlcsqlite.InsertBlockParams{ + BlockHeight: height, + HeaderHash: block.Hash[:], + BlockTimestamp: block.Timestamp.Unix(), + } + + err := qtx.InsertBlock(ctx, blockParams) + if err != nil { + return fmt.Errorf("insert block: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index 0af162cc41..a311fc2997 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -358,3 +358,64 @@ func TestUpdateWalletSecrets(t *testing.T) { require.NoError(t, err) require.Equal(t, newSecrets.EncryptedMasterHdPrivKey, seed) } + +// TestUpdateWallet_AutoBlockInsertion verifies that UpdateWallet automatically +// inserts blocks when updating SyncedTo or BirthdayBlock. +func TestUpdateWallet_AutoBlockInsertion(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + params := CreateWalletParamsFixture("auto-block-wallet") + created, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + + // Create a block WITHOUT pre-inserting it into the blocks table. + block := db.Block{ + Height: uint32(100), + Hash: RandomHash(), + Timestamp: time.Now().UTC(), + } + + // Update wallet with SyncedTo - should automatically insert the block. + updateParams := db.UpdateWalletParams{ + WalletID: created.ID, + SyncedTo: &block, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + // Verify the wallet was updated. + retrieved, err := store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.NotNil(t, retrieved.SyncedTo) + require.Equal(t, block.Height, retrieved.SyncedTo.Height) + require.Equal(t, block.Hash, retrieved.SyncedTo.Hash) + + // Update again with the same block - should be idempotent. + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err, "updating with same block should be idempotent") + + // Create another block for BirthdayBlock. + birthdayBlock := db.Block{ + Height: uint32(50), + Hash: RandomHash(), + Timestamp: time.Now().UTC().Add(-time.Hour), + } + + // Update wallet with BirthdayBlock - should automatically insert it. + updateParams = db.UpdateWalletParams{ + WalletID: created.ID, + BirthdayBlock: &birthdayBlock, + } + err = store.UpdateWallet(t.Context(), updateParams) + require.NoError(t, err) + + // Verify both blocks are set. + retrieved, err = store.GetWallet(t.Context(), created.Name) + require.NoError(t, err) + require.NotNil(t, retrieved.SyncedTo) + require.NotNil(t, retrieved.BirthdayBlock) + require.Equal(t, block.Height, retrieved.SyncedTo.Height) + require.Equal(t, birthdayBlock.Height, retrieved.BirthdayBlock.Height) +} diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index e6f1b54e95..f7fd073ed3 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -185,6 +185,25 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, params UpdateWalletParams) error { return w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + // Insert blocks if needed. + if params.SyncedTo != nil { + err := ensureBlockExistsPg(ctx, qtx, params.SyncedTo) + if err != nil { + return fmt.Errorf("ensure synced block: %w", + err) + } + } + + if params.BirthdayBlock != nil { + err := ensureBlockExistsPg( + ctx, qtx, params.BirthdayBlock, + ) + if err != nil { + return fmt.Errorf("ensure birthday block: %w", + err) + } + } + // Build sync state update params directly from input. // Only set fields that are being updated (leave others as nil). syncParams := sqlcpg.UpdateWalletSyncStateParams{ diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index 732242cb08..ad4b69bb12 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -185,6 +185,27 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, params UpdateWalletParams) error { return w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + // Insert blocks if needed. + if params.SyncedTo != nil { + err := ensureBlockExistsSqlite( + ctx, qtx, params.SyncedTo, + ) + if err != nil { + return fmt.Errorf("ensure synced block: %w", + err) + } + } + + if params.BirthdayBlock != nil { + err := ensureBlockExistsSqlite( + ctx, qtx, params.BirthdayBlock, + ) + if err != nil { + return fmt.Errorf("ensure birthday block: %w", + err) + } + } + // Build sync state update params directly from input. // Only set fields that are being updated (leave others as nil). syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ From 4d3640cafd076b803bbf9be77fafc23ecdd1c4b6 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 18 Dec 2025 05:51:50 +0000 Subject: [PATCH 357/691] wallet: refactor wallet sync params update Fixes linting complains "calculated cyclomatic complexity for function UpdateWallet is 12, max is 10 (cyclop)" --- wallet/internal/db/wallet_pg.go | 75 ++++++++++++++++------------- wallet/internal/db/wallet_sqlite.go | 60 +++++++++++++---------- 2 files changed, 76 insertions(+), 59 deletions(-) diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index f7fd073ed3..f84a291ce1 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -204,39 +204,9 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, } } - // Build sync state update params directly from input. - // Only set fields that are being updated (leave others as nil). - syncParams := sqlcpg.UpdateWalletSyncStateParams{ - WalletID: int64(params.WalletID), - } - - if params.SyncedTo != nil { - syncedHeight, err := uint32ToNullInt32( - params.SyncedTo.Height, - ) - if err != nil { - return err - } - - syncParams.SyncedHeight = syncedHeight - } - - if params.Birthday != nil { - syncParams.BirthdayTimestamp = sql.NullTime{ - Time: *params.Birthday, - Valid: true, - } - } - - if params.BirthdayBlock != nil { - birthdayHeight, err := uint32ToNullInt32( - params.BirthdayBlock.Height, - ) - if err != nil { - return err - } - - syncParams.BirthdayHeight = birthdayHeight + syncParams, err := buildUpdateSyncParamsPg(params) + if err != nil { + return err } rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) @@ -369,3 +339,42 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { return info, nil } + +// buildUpdateSyncParamsPg constructs the UpdateWalletSyncStateParams from +// the given UpdateWalletParams. +func buildUpdateSyncParamsPg(params UpdateWalletParams) ( + sqlcpg.UpdateWalletSyncStateParams, error) { + + syncParams := sqlcpg.UpdateWalletSyncStateParams{ + WalletID: int64(params.WalletID), + } + + if params.SyncedTo != nil { + syncedHeight, err := uint32ToNullInt32(params.SyncedTo.Height) + if err != nil { + return syncParams, err + } + + syncParams.SyncedHeight = syncedHeight + } + + if params.Birthday != nil { + syncParams.BirthdayTimestamp = sql.NullTime{ + Time: *params.Birthday, + Valid: true, + } + } + + if params.BirthdayBlock != nil { + birthdayHeight, err := uint32ToNullInt32( + params.BirthdayBlock.Height, + ) + if err != nil { + return syncParams, err + } + + syncParams.BirthdayHeight = birthdayHeight + } + + return syncParams, nil +} diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index ad4b69bb12..e7d7ff45ff 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -206,32 +206,7 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, } } - // Build sync state update params directly from input. - // Only set fields that are being updated (leave others as nil). - syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ - WalletID: int64(params.WalletID), - } - - if params.SyncedTo != nil { - syncParams.SyncedHeight = sql.NullInt64{ - Int64: int64(params.SyncedTo.Height), - Valid: true, - } - } - - if params.Birthday != nil { - syncParams.BirthdayTimestamp = sql.NullTime{ - Time: *params.Birthday, - Valid: true, - } - } - - if params.BirthdayBlock != nil { - syncParams.BirthdayHeight = sql.NullInt64{ - Int64: int64(params.BirthdayBlock.Height), - Valid: true, - } - } + syncParams := buildUpdateSyncParamsSqlite(params) rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) if err != nil { @@ -368,3 +343,36 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { return info, nil } + +// buildUpdateSyncParamsSqlite constructs the UpdateWalletSyncStateParams from +// the given UpdateWalletParams. +func buildUpdateSyncParamsSqlite( + params UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { + + syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ + WalletID: int64(params.WalletID), + } + + if params.SyncedTo != nil { + syncParams.SyncedHeight = sql.NullInt64{ + Int64: int64(params.SyncedTo.Height), + Valid: true, + } + } + + if params.Birthday != nil { + syncParams.BirthdayTimestamp = sql.NullTime{ + Time: *params.Birthday, + Valid: true, + } + } + + if params.BirthdayBlock != nil { + syncParams.BirthdayHeight = sql.NullInt64{ + Int64: int64(params.BirthdayBlock.Height), + Valid: true, + } + } + + return syncParams +} From b6cb65a4f4cce680fec496b4ae6d60461bad6d05 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 10:48:48 -0300 Subject: [PATCH 358/691] wallet: add accounts SQL migrations --- .../postgres/000004_key_scopes.up.sql | 13 ++- .../postgres/000005_accounts.down.sql | 5 + .../postgres/000005_accounts.up.sql | 107 ++++++++++++++++++ .../sqlite/000004_key_scopes.up.sql | 13 ++- .../sqlite/000005_accounts.down.sql | 5 + .../migrations/sqlite/000005_accounts.up.sql | 107 ++++++++++++++++++ .../db/sqlc/postgres/key_scopes.sql.go | 44 +++++-- wallet/internal/db/sqlc/postgres/models.go | 23 ++++ wallet/internal/db/sqlc/postgres/querier.go | 6 +- .../internal/db/sqlc/sqlite/key_scopes.sql.go | 44 +++++-- wallet/internal/db/sqlc/sqlite/models.go | 23 ++++ wallet/internal/db/sqlc/sqlite/querier.go | 6 +- 12 files changed, 372 insertions(+), 24 deletions(-) create mode 100644 wallet/internal/db/migrations/postgres/000005_accounts.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000005_accounts.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000005_accounts.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000005_accounts.up.sql diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql index e7165cdcc0..9af8cabd0a 100644 --- a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql @@ -17,8 +17,9 @@ CREATE TABLE key_scopes ( -- Indicates the coin type for the key scope. This is typically 0 for BTC. coin_type BIGINT NOT NULL, - -- Encrypted key used to derive public keys for this scope. - encrypted_coin_pub_key BYTEA NOT NULL, + -- Encrypted key used to derive public keys for this scope in imported + -- accounts. + encrypted_coin_pub_key BYTEA, -- Reference to the address type used for internal/change addresses. internal_type_id SMALLINT NOT NULL, @@ -26,6 +27,14 @@ CREATE TABLE key_scopes ( -- Reference to the address type used for external/receiving addresses. external_type_id SMALLINT NOT NULL, + -- Counter used to allocate sequential account numbers within this scope. + -- This avoids scanning the accounts table to compute MAX(account_number). + -- The value is updated atomically via UPDATE with RETURNING, which allows + -- concurrent account creation without additional locking logic. + -- The counter starts at minus one. Each new account consumes the current + -- value plus one, then stores the updated value for the next allocation. + last_account_number BIGINT NOT NULL DEFAULT -1, + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if key scopes still exist. FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.down.sql b/wallet/internal/db/migrations/postgres/000005_accounts.down.sql new file mode 100644 index 0000000000..d7b3097d8b --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000005_accounts.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TABLE IF EXISTS account_secrets; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS account_origins; diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql new file mode 100644 index 0000000000..c51ea1f95e --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -0,0 +1,107 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Account origin lookup table - provides standardized descriptions for wallet +-- account origins. This is a reference table that maps the AccountOrigin enum +-- values used in Go code to their human-readable names. +CREATE TABLE account_origins ( + -- Primary key matching the Go AccountOrigin enum values. + -- Using explicit IDs rather than auto-increment to ensure consistency + -- with the Go enum and across SQLite/Postgres implementations. + id SMALLINT PRIMARY KEY, + + -- Human-readable account origin description. + description TEXT NOT NULL +); + +-- Unique constraint on description to prevent duplicate entries. +-- This ensures referential integrity and enables efficient reverse lookups. +CREATE UNIQUE INDEX uidx_account_origins_description +ON account_origins (description); + +-- Seed reference data matching the Go AccountOrigin enum constants. +-- These values are static and represent the account origin types. +-- IDs MUST match the iota values in wallet/internal/db/data_types.go. +INSERT INTO account_origins (id, description) VALUES +(0, 'derived'), -- Derived from a hierarchical deterministic key. +(1, 'imported'); -- Imported from an external source. + +-- Accounts table stores wallet accounts under each key scope (BIP32/BIP44 +-- hierarchy). Each account represents either a derived HD account following +-- BIP44 derivation paths (m/purpose'/coin'/account') or an imported account +-- from an external source (e.g., hardware wallet, watch-only xpub). +-- +-- The table supports both account types through nullable account_number: +-- - Derived accounts have sequential account_number values (0, 1, 2, ...) +-- - Imported accounts have NULL account_number (not part of BIP44 hierarchy) +CREATE TABLE accounts ( + -- DB ID of the account, primary key. + id BIGSERIAL PRIMARY KEY, + + -- Reference to the key scope this account belongs to. + scope_id BIGINT NOT NULL, + + -- Account number described in BIP44. NULL for imported accounts since they + -- don't follow the BIP44 derivation path. + account_number BIGINT, + + -- Human friendly name for the account. + account_name TEXT NOT NULL, + + -- Reference to the origin of the account. + origin_id SMALLINT NOT NULL, + + -- Defines if the account is watch-only. + is_watch_only BOOLEAN NOT NULL, + + -- Master fingerprint is the fingerprint of the master pub key that created + -- this account. + master_fingerprint BIGINT, + + -- Encrypted public key for the account. + encrypted_public_key BYTEA, + + -- Timestamp when the account was created. Automatically set by the database. + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure + -- that the key scope cannot be deleted if accounts still exist. + FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, + + -- Foreign key constraint to account origins. Using ON DELETE RESTRICT to + -- ensure that the origin cannot be deleted if accounts still exist. + FOREIGN KEY (origin_id) REFERENCES account_origins (id) ON DELETE RESTRICT +); + +-- Index on foreign scope_id for faster lookups and joins. +CREATE INDEX idx_accounts_scope ON accounts (scope_id); + +-- Unique partial index to prevent duplicate account numbers within the same +-- key scope. Only enforced for non-NULL account numbers (derived accounts). +-- Imported accounts have NULL account_number and are excluded from this +-- constraint. +CREATE UNIQUE INDEX uidx_accounts_scope_account_number +ON accounts (scope_id, account_number) +WHERE account_number IS NOT NULL; + +-- Unique index to prevent duplicate account names within the same key scope. +CREATE UNIQUE INDEX uidx_accounts_scope_account_name +ON accounts (scope_id, account_name); + +-- Account Secrets table to hold encrypted account-level secrets. +CREATE TABLE account_secrets ( + -- Reference to the account these keys belong to. + account_id BIGINT NOT NULL, + + -- Encrypted private key for the account. Watch-only accounts may have + -- no row in this table. + encrypted_private_key BYTEA NOT NULL, + + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure + -- that the account cannot be deleted if secrets still exist. + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT +); + +-- Unique index to ensure one-to-one relationship between account and its secrets. +CREATE UNIQUE INDEX uidx_account_secrets_account +ON account_secrets (account_id); diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql index 55952cd11b..37432efdfc 100644 --- a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql @@ -17,8 +17,9 @@ CREATE TABLE key_scopes ( -- Indicates the coin type for the key scope. This is typically 0 for BTC. coin_type INTEGER NOT NULL, - -- Encrypted key used to derive public keys for this scope. - encrypted_coin_pub_key BLOB NOT NULL, + -- Encrypted key used to derive public keys for this scope in imported + -- accounts. + encrypted_coin_pub_key BLOB, -- Reference to the address type used for internal/change addresses. internal_type_id INTEGER NOT NULL, @@ -26,6 +27,14 @@ CREATE TABLE key_scopes ( -- Reference to the address type used for external/receiving addresses. external_type_id INTEGER NOT NULL, + -- Counter used to allocate sequential account numbers within this scope. + -- This avoids scanning the accounts table to compute MAX(account_number). + -- The value is updated atomically via UPDATE with RETURNING, which allows + -- concurrent account creation without additional locking logic. + -- The counter starts at minus one. Each new account consumes the current + -- value plus one, then stores the updated value for the next allocation. + last_account_number INTEGER NOT NULL DEFAULT -1, + -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if key scopes still exist. FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.down.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.down.sql new file mode 100644 index 0000000000..d7b3097d8b --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TABLE IF EXISTS account_secrets; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS account_origins; diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql new file mode 100644 index 0000000000..0644de5ae4 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -0,0 +1,107 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Account origin lookup table - provides standardized descriptions for wallet +-- account origins. This is a reference table that maps the AccountOrigin enum +-- values used in Go code to their human-readable names. +CREATE TABLE account_origins ( + -- Primary key matching the Go AccountOrigin enum values. + -- Using explicit IDs rather than auto-increment to ensure consistency + -- with the Go enum and across SQLite/Postgres implementations. + id INTEGER PRIMARY KEY, + + -- Human-readable account origin description. + description TEXT NOT NULL +); + +-- Unique constraint on description to prevent duplicate entries. +-- This ensures referential integrity and enables efficient reverse lookups. +CREATE UNIQUE INDEX uidx_account_origins_description +ON account_origins (description); + +-- Seed reference data matching the Go AccountOrigin enum constants. +-- These values are static and represent the account origin types. +-- IDs MUST match the iota values in wallet/internal/db/data_types.go. +INSERT INTO account_origins (id, description) VALUES +(0, 'derived'), -- Derived from a hierarchical deterministic key. +(1, 'imported'); -- Imported from an external source. + +-- Accounts table stores wallet accounts under each key scope (BIP32/BIP44 +-- hierarchy). Each account represents either a derived HD account following +-- BIP44 derivation paths (m/purpose'/coin'/account') or an imported account +-- from an external source (e.g., hardware wallet, watch-only xpub). +-- +-- The table supports both account types through nullable account_number: +-- - Derived accounts have sequential account_number values (0, 1, 2, ...) +-- - Imported accounts have NULL account_number (not part of BIP44 hierarchy) +CREATE TABLE accounts ( + -- DB ID of the account, primary key. + id INTEGER PRIMARY KEY, + + -- Reference to the key scope this account belongs to. + scope_id INTEGER NOT NULL, + + -- Account number described in BIP44. NULL for imported accounts since they + -- don't follow the BIP44 derivation path. + account_number INTEGER, + + -- Human friendly name for the account. + account_name TEXT NOT NULL, + + -- Reference to the origin of the account. + origin_id INTEGER NOT NULL, + + -- Defines if the account is watch-only. + is_watch_only BOOLEAN NOT NULL, + + -- Master fingerprint is the fingerprint of the master pub key that created + -- this account. + master_fingerprint INTEGER, + + -- Encrypted public key for the account. + encrypted_public_key BLOB, + + -- Timestamp when the account was created. Automatically set by the database. + created_at DATETIME NOT NULL DEFAULT current_timestamp, + + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure + -- that the key scope cannot be deleted if accounts still exist. + FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, + + -- Foreign key constraint to account origins. Using ON DELETE RESTRICT to + -- ensure that the origin cannot be deleted if accounts still exist. + FOREIGN KEY (origin_id) REFERENCES account_origins (id) ON DELETE RESTRICT +); + +-- Index on foreign scope_id for faster lookups and joins. +CREATE INDEX idx_accounts_scope ON accounts (scope_id); + +-- Unique partial index to prevent duplicate account numbers within the same +-- key scope. Only enforced for non-NULL account numbers (derived accounts). +-- Imported accounts have NULL account_number and are excluded from this +-- constraint. +CREATE UNIQUE INDEX uidx_accounts_scope_account_number +ON accounts (scope_id, account_number) +WHERE account_number IS NOT NULL; + +-- Unique index to prevent duplicate account names within the same key scope. +CREATE UNIQUE INDEX uidx_accounts_scope_account_name +ON accounts (scope_id, account_name); + +-- Account Secrets table to hold encrypted account-level secrets. +CREATE TABLE account_secrets ( + -- Reference to the account these keys belong to. + account_id INTEGER NOT NULL, + + -- Encrypted private key for the account. Watch-only accounts may have + -- no row in this table. + encrypted_private_key BLOB NOT NULL, + + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure + -- that the account cannot be deleted if secrets still exist. + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT +); + +-- Unique index to ensure one-to-one relationship between account and its secrets. +CREATE UNIQUE INDEX uidx_account_secrets_account +ON account_secrets (account_id); diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go index 7be4e847c2..121df96ffe 100644 --- a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -88,10 +88,20 @@ FROM key_scopes WHERE id = $1 ` +type GetKeyScopeByIDRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + // Retrieves a key scope by its ID. -func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) { row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) - var i KeyScope + var i GetKeyScopeByIDRow err := row.Scan( &i.ID, &i.WalletID, @@ -123,10 +133,20 @@ type GetKeyScopeByWalletAndScopeParams struct { CoinType int64 } +type GetKeyScopeByWalletAndScopeRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + // Retrieves a key scope by wallet ID, purpose, and coin type. -func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) { row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) - var i KeyScope + var i GetKeyScopeByWalletAndScopeRow err := row.Scan( &i.ID, &i.WalletID, @@ -190,16 +210,26 @@ WHERE wallet_id = $1 ORDER BY id ` +type ListKeyScopesByWalletRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int16 + ExternalTypeID int16 +} + // Lists all key scopes for a wallet, ordered by ID. -func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) { rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) if err != nil { return nil, err } defer rows.Close() - var items []KeyScope + var items []ListKeyScopesByWalletRow for rows.Next() { - var i KeyScope + var i ListKeyScopesByWalletRow if err := rows.Scan( &i.ID, &i.WalletID, diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 4c49c371e0..2957b82a6e 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -9,6 +9,28 @@ import ( "time" ) +type Account struct { + ID int64 + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + MasterFingerprint sql.NullInt64 + EncryptedPublicKey []byte + CreatedAt time.Time +} + +type AccountOrigin struct { + ID int16 + Description string +} + +type AccountSecret struct { + AccountID int64 + EncryptedPrivateKey []byte +} + type AddressType struct { ID int16 Description string @@ -28,6 +50,7 @@ type KeyScope struct { EncryptedCoinPubKey []byte InternalTypeID int16 ExternalTypeID int16 + LastAccountNumber int64 } type KeyScopeSecret struct { diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 6277126563..57c00a5a20 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -21,9 +21,9 @@ type Querier interface { GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) // Retrieves a key scope by its ID. - GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) + GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) // Retrieves a key scope by wallet ID, purpose, and coin type. - GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) @@ -38,7 +38,7 @@ type Querier interface { // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. - ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go index 4223e0789d..241da81ea9 100644 --- a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -88,10 +88,20 @@ FROM key_scopes WHERE id = ? ` +type GetKeyScopeByIDRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + // Retrieves a key scope by its ID. -func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) { row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) - var i KeyScope + var i GetKeyScopeByIDRow err := row.Scan( &i.ID, &i.WalletID, @@ -123,10 +133,20 @@ type GetKeyScopeByWalletAndScopeParams struct { CoinType int64 } +type GetKeyScopeByWalletAndScopeRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + // Retrieves a key scope by wallet ID, purpose, and coin type. -func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) { row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) - var i KeyScope + var i GetKeyScopeByWalletAndScopeRow err := row.Scan( &i.ID, &i.WalletID, @@ -190,16 +210,26 @@ WHERE wallet_id = ? ORDER BY id ` +type ListKeyScopesByWalletRow struct { + ID int64 + WalletID int64 + Purpose int64 + CoinType int64 + EncryptedCoinPubKey []byte + InternalTypeID int64 + ExternalTypeID int64 +} + // Lists all key scopes for a wallet, ordered by ID. -func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) { rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) if err != nil { return nil, err } defer rows.Close() - var items []KeyScope + var items []ListKeyScopesByWalletRow for rows.Next() { - var i KeyScope + var i ListKeyScopesByWalletRow if err := rows.Scan( &i.ID, &i.WalletID, diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index d32eca6e07..47199cd608 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -9,6 +9,28 @@ import ( "time" ) +type Account struct { + ID int64 + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + MasterFingerprint sql.NullInt64 + EncryptedPublicKey []byte + CreatedAt time.Time +} + +type AccountOrigin struct { + ID int64 + Description string +} + +type AccountSecret struct { + AccountID int64 + EncryptedPrivateKey []byte +} + type AddressType struct { ID int64 Description string @@ -28,6 +50,7 @@ type KeyScope struct { EncryptedCoinPubKey []byte InternalTypeID int64 ExternalTypeID int64 + LastAccountNumber int64 } type KeyScopeSecret struct { diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index d489425bc2..27e37f69b7 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -21,9 +21,9 @@ type Querier interface { GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) // Retrieves a key scope by its ID. - GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) + GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) // Retrieves a key scope by wallet ID, purpose, and coin type. - GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) @@ -38,7 +38,7 @@ type Querier interface { // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. - ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) From 74e873c1eb1566d80ea2bd5ae41d4af57c2303b6 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 10:56:31 -0300 Subject: [PATCH 359/691] wallet: add accounts SQL queries --- .../internal/db/queries/postgres/accounts.sql | 235 ++++++ .../db/queries/postgres/key_scopes.sql | 1 + .../internal/db/queries/sqlite/accounts.sql | 229 ++++++ .../internal/db/queries/sqlite/key_scopes.sql | 12 + .../internal/db/sqlc/postgres/accounts.sql.go | 715 ++++++++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 236 ++++-- .../db/sqlc/postgres/key_scopes.sql.go | 1 + wallet/internal/db/sqlc/postgres/querier.go | 37 + .../internal/db/sqlc/sqlite/accounts.sql.go | 711 +++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 246 ++++-- .../internal/db/sqlc/sqlite/key_scopes.sql.go | 25 + wallet/internal/db/sqlc/sqlite/querier.go | 50 ++ 12 files changed, 2402 insertions(+), 96 deletions(-) create mode 100644 wallet/internal/db/queries/postgres/accounts.sql create mode 100644 wallet/internal/db/queries/sqlite/accounts.sql create mode 100644 wallet/internal/db/sqlc/postgres/accounts.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/accounts.sql.go diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql new file mode 100644 index 0000000000..bf5f02ed9f --- /dev/null +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -0,0 +1,235 @@ +-- name: CreateDerivedAccount :one +-- Creates a new derived account under the given scope, allocating a fresh +-- sequential account number from key_scopes.last_account_number. +-- The allocation is atomic: the UPDATE takes the row lock on the scope row, +-- returns the allocated number, and updates the counter for the next call. +WITH allocated_number AS ( + UPDATE key_scopes + SET last_account_number = last_account_number + 1 + WHERE key_scopes.id = $1 + RETURNING key_scopes.id, last_account_number AS account_number +) + +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +SELECT + allocated_number.id AS scope_id, + allocated_number.account_number, + $2 AS account_name, + $3 AS origin_id, + $4 AS encrypted_public_key, + $5 AS master_fingerprint, + $6 AS is_watch_only +FROM allocated_number +RETURNING accounts.id, accounts.account_number, accounts.created_at; + +-- name: CreateImportedAccount :one +-- Creates a new imported account under the given scope with NULL account +-- number. Imported accounts don't follow BIP44 derivation, so they don't need +-- a sequential account number. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES ($1, NULL, $2, $3, $4, $5, $6) +RETURNING id, created_at; + +-- name: CreateAccountSecret :exec +-- Inserts the encrypted private key material for an account. +INSERT INTO account_secrets ( + account_id, + encrypted_private_key +) VALUES ( + $1, $2 +); + +-- name: GetAccountByScopeAndName :one +-- Returns a single account by scope id and account name. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 AND a.account_name = $2; + +-- name: GetAccountByScopeAndNumber :one +-- Returns a single account by scope id and account number. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 AND a.account_number = $2; + +-- name: GetAccountByWalletScopeAndName :one +-- Returns a single account by wallet id, scope tuple, and account name. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 + AND a.account_name = $4; + +-- name: GetAccountByWalletScopeAndNumber :one +-- Returns a single account by wallet id, scope tuple, and account number. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 + AND a.account_number = $4; + +-- name: GetAccountPropsById :one +-- Returns full account properties by account id. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.encrypted_public_key, + a.master_fingerprint, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.id = $1; + +-- name: ListAccountsByScope :many +-- Lists all accounts in a scope, ordered by account number. Imported accounts +-- (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 +ORDER BY a.account_number NULLS LAST; + +-- name: ListAccountsByWalletScope :many +-- Lists all accounts for a wallet and scope tuple, ordered by account number. +-- Imported accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 +ORDER BY a.account_number NULLS LAST; + +-- name: ListAccountsByWalletAndName :many +-- Lists all accounts for a wallet filtered by account name, ordered by account +-- number. Imported accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = $1 AND a.account_name = $2 +ORDER BY a.account_number NULLS LAST; + +-- name: ListAccountsByWallet :many +-- Lists all accounts for a wallet, ordered by account number. Imported +-- accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = $1 +ORDER BY a.account_number NULLS LAST; + +-- name: UpdateAccountNameByWalletScopeAndNumber :execrows +-- Renames an account identified by wallet id, scope tuple, and account number. +UPDATE accounts +SET account_name = sqlc.arg(new_name) +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = sqlc.arg(wallet_id) + AND purpose = sqlc.arg(purpose) + AND coin_type = sqlc.arg(coin_type) + ) + AND account_number = sqlc.arg(account_number); + +-- name: UpdateAccountNameByWalletScopeAndName :execrows +-- Renames an account identified by wallet id, scope tuple, and current account name. +UPDATE accounts +SET account_name = sqlc.arg(new_name) +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = sqlc.arg(wallet_id) + AND purpose = sqlc.arg(purpose) + AND coin_type = sqlc.arg(coin_type) + ) + AND account_name = sqlc.arg(old_name); diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/db/queries/postgres/key_scopes.sql index b1061927b8..5b9d31fb01 100644 --- a/wallet/internal/db/queries/postgres/key_scopes.sql +++ b/wallet/internal/db/queries/postgres/key_scopes.sql @@ -10,6 +10,7 @@ INSERT INTO key_scopes ( ) VALUES ( $1, $2, $3, $4, $5, $6 ) +ON CONFLICT (wallet_id, purpose, coin_type) DO NOTHING RETURNING id; -- name: InsertKeyScopeSecrets :exec diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql new file mode 100644 index 0000000000..5d033dec88 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -0,0 +1,229 @@ +-- name: CreateDerivedAccount :one +-- Creates a new derived account under the given scope, using a caller-provided +-- account number. +-- +-- NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation +-- step with an INSERT in a single CTE. +-- +-- We instead: +-- 1) call AllocateAccountNumber (key_scopes.sql) +-- 2) call CreateDerivedAccount with the returned number +-- +-- Both statements run within the same SQL transaction. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING id, account_number, created_at; + +-- name: CreateImportedAccount :one +-- Creates a new imported account under the given scope with NULL account +-- number. Imported accounts don't follow BIP44 derivation, so they don't need +-- a sequential account number. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES (?, NULL, ?, ?, ?, ?, ?) +RETURNING id, created_at; + +-- name: CreateAccountSecret :exec +-- Inserts the encrypted private key material for an account. +INSERT INTO account_secrets ( + account_id, + encrypted_private_key +) VALUES ( + ?, ? +); + +-- name: GetAccountByScopeAndName :one +-- Returns a single account by scope id and account name. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? AND a.account_name = ?; + +-- name: GetAccountByScopeAndNumber :one +-- Returns a single account by scope id and account number. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? AND a.account_number = ?; + +-- name: GetAccountByWalletScopeAndName :one +-- Returns a single account by wallet id, scope tuple, and account name. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? + AND a.account_name = ?; + +-- name: GetAccountByWalletScopeAndNumber :one +-- Returns a single account by wallet id, scope tuple, and account number. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? + AND a.account_number = ?; + +-- name: GetAccountPropsById :one +-- Returns full account properties by account id. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.encrypted_public_key, + a.master_fingerprint, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.id = ?; + +-- name: ListAccountsByScope :many +-- Lists all accounts in a scope, ordered by account number. Imported accounts +-- (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? +ORDER BY a.account_number IS NULL, a.account_number; + +-- name: ListAccountsByWalletScope :many +-- Lists all accounts for a wallet and scope tuple, ordered by account number. +-- Imported accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? +ORDER BY a.account_number IS NULL, a.account_number; + +-- name: ListAccountsByWalletAndName :many +-- Lists all accounts for a wallet filtered by account name, ordered by account +-- number. Imported accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = ? AND a.account_name = ? +ORDER BY a.account_number IS NULL, a.account_number; + +-- name: ListAccountsByWallet :many +-- Lists all accounts for a wallet, ordered by account number. Imported +-- accounts (with NULL account_number) appear last. +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = ? +ORDER BY a.account_number IS NULL, a.account_number; + +-- name: UpdateAccountNameByWalletScopeAndNumber :execrows +-- Renames an account identified by wallet id, scope tuple, and account number. +UPDATE accounts +SET account_name = sqlc.arg(new_name) +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = sqlc.arg(wallet_id) + AND purpose = sqlc.arg(purpose) + AND coin_type = sqlc.arg(coin_type) + ) + AND account_number = sqlc.arg(account_number); + +-- name: UpdateAccountNameByWalletScopeAndName :execrows +-- Renames an account identified by wallet id, scope tuple, and current account name. +UPDATE accounts +SET account_name = sqlc.arg(new_name) +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = sqlc.arg(wallet_id) + AND purpose = sqlc.arg(purpose) + AND coin_type = sqlc.arg(coin_type) + ) + AND account_name = sqlc.arg(old_name); diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/db/queries/sqlite/key_scopes.sql index 0385f64685..1635336b66 100644 --- a/wallet/internal/db/queries/sqlite/key_scopes.sql +++ b/wallet/internal/db/queries/sqlite/key_scopes.sql @@ -10,6 +10,7 @@ INSERT INTO key_scopes ( ) VALUES ( ?, ?, ?, ?, ?, ? ) +ON CONFLICT (wallet_id, purpose, coin_type) DO NOTHING RETURNING id; -- name: InsertKeyScopeSecrets :exec @@ -48,6 +49,17 @@ SELECT FROM key_scopes WHERE wallet_id = ? AND purpose = ? AND coin_type = ?; +-- name: AllocateAccountNumber :one +-- Atomically allocates the next account number for a key scope. +-- Returns the scope_id and the allocated account number. +-- SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single +-- CTE (unlike Postgres). So, we call this first, then pass the returned number +-- to CreateAccount, within the same SQL transaction. +UPDATE key_scopes +SET last_account_number = last_account_number + 1 +WHERE id = ? +RETURNING id, last_account_number; + -- name: ListKeyScopesByWallet :many -- Lists all key scopes for a wallet, ordered by ID. SELECT diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go new file mode 100644 index 0000000000..b5b5de9ade --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -0,0 +1,715 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: accounts.sql + +package sqlcpg + +import ( + "context" + "database/sql" + "time" +) + +const CreateAccountSecret = `-- name: CreateAccountSecret :exec +INSERT INTO account_secrets ( + account_id, + encrypted_private_key +) VALUES ( + $1, $2 +) +` + +type CreateAccountSecretParams struct { + AccountID int64 + EncryptedPrivateKey []byte +} + +// Inserts the encrypted private key material for an account. +func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error { + _, err := q.exec(ctx, q.createAccountSecretStmt, CreateAccountSecret, arg.AccountID, arg.EncryptedPrivateKey) + return err +} + +const CreateDerivedAccount = `-- name: CreateDerivedAccount :one +WITH allocated_number AS ( + UPDATE key_scopes + SET last_account_number = last_account_number + 1 + WHERE key_scopes.id = $1 + RETURNING key_scopes.id, last_account_number AS account_number +) + +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +SELECT + allocated_number.id AS scope_id, + allocated_number.account_number, + $2 AS account_name, + $3 AS origin_id, + $4 AS encrypted_public_key, + $5 AS master_fingerprint, + $6 AS is_watch_only +FROM allocated_number +RETURNING accounts.id, accounts.account_number, accounts.created_at +` + +type CreateDerivedAccountParams struct { + ID int64 + AccountName string + OriginID int16 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool +} + +type CreateDerivedAccountRow struct { + ID int64 + AccountNumber sql.NullInt64 + CreatedAt time.Time +} + +// Creates a new derived account under the given scope, allocating a fresh +// sequential account number from key_scopes.last_account_number. +// The allocation is atomic: the UPDATE takes the row lock on the scope row, +// returns the allocated number, and updates the counter for the next call. +func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) { + row := q.queryRow(ctx, q.createDerivedAccountStmt, CreateDerivedAccount, + arg.ID, + arg.AccountName, + arg.OriginID, + arg.EncryptedPublicKey, + arg.MasterFingerprint, + arg.IsWatchOnly, + ) + var i CreateDerivedAccountRow + err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) + return i, err +} + +const CreateImportedAccount = `-- name: CreateImportedAccount :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES ($1, NULL, $2, $3, $4, $5, $6) +RETURNING id, created_at +` + +type CreateImportedAccountParams struct { + ScopeID int64 + AccountName string + OriginID int16 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool +} + +type CreateImportedAccountRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates a new imported account under the given scope with NULL account +// number. Imported accounts don't follow BIP44 derivation, so they don't need +// a sequential account number. +func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) { + row := q.queryRow(ctx, q.createImportedAccountStmt, CreateImportedAccount, + arg.ScopeID, + arg.AccountName, + arg.OriginID, + arg.EncryptedPublicKey, + arg.MasterFingerprint, + arg.IsWatchOnly, + ) + var i CreateImportedAccountRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const GetAccountByScopeAndName = `-- name: GetAccountByScopeAndName :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 AND a.account_name = $2 +` + +type GetAccountByScopeAndNameParams struct { + ScopeID int64 + AccountName string +} + +type GetAccountByScopeAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by scope id and account name. +func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) { + row := q.queryRow(ctx, q.getAccountByScopeAndNameStmt, GetAccountByScopeAndName, arg.ScopeID, arg.AccountName) + var i GetAccountByScopeAndNameRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByScopeAndNumber = `-- name: GetAccountByScopeAndNumber :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 AND a.account_number = $2 +` + +type GetAccountByScopeAndNumberParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 +} + +type GetAccountByScopeAndNumberRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by scope id and account number. +func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccountByScopeAndNumberParams) (GetAccountByScopeAndNumberRow, error) { + row := q.queryRow(ctx, q.getAccountByScopeAndNumberStmt, GetAccountByScopeAndNumber, arg.ScopeID, arg.AccountNumber) + var i GetAccountByScopeAndNumberRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByWalletScopeAndName = `-- name: GetAccountByWalletScopeAndName :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 + AND a.account_name = $4 +` + +type GetAccountByWalletScopeAndNameParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string +} + +type GetAccountByWalletScopeAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by wallet id, scope tuple, and account name. +func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAccountByWalletScopeAndNameParams) (GetAccountByWalletScopeAndNameRow, error) { + row := q.queryRow(ctx, q.getAccountByWalletScopeAndNameStmt, GetAccountByWalletScopeAndName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountName, + ) + var i GetAccountByWalletScopeAndNameRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByWalletScopeAndNumber = `-- name: GetAccountByWalletScopeAndNumber :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 + AND a.account_number = $4 +` + +type GetAccountByWalletScopeAndNumberParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 +} + +type GetAccountByWalletScopeAndNumberRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by wallet id, scope tuple, and account number. +func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) { + row := q.queryRow(ctx, q.getAccountByWalletScopeAndNumberStmt, GetAccountByWalletScopeAndNumber, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + ) + var i GetAccountByWalletScopeAndNumberRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountPropsById = `-- name: GetAccountPropsById :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.encrypted_public_key, + a.master_fingerprint, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.id = $1 +` + +type GetAccountPropsByIdRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + InternalTypeID int16 + ExternalTypeID int16 +} + +// Returns full account properties by account id. +func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) { + row := q.queryRow(ctx, q.getAccountPropsByIdStmt, GetAccountPropsById, id) + var i GetAccountPropsByIdRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.EncryptedPublicKey, + &i.MasterFingerprint, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const ListAccountsByScope = `-- name: ListAccountsByScope :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = $1 +ORDER BY a.account_number NULLS LAST +` + +type ListAccountsByScopeRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts in a scope, ordered by account number. Imported accounts +// (with NULL account_number) appear last. +func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]ListAccountsByScopeRow, error) { + rows, err := q.query(ctx, q.listAccountsByScopeStmt, ListAccountsByScope, scopeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByScopeRow + for rows.Next() { + var i ListAccountsByScopeRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); 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 ListAccountsByWallet = `-- name: ListAccountsByWallet :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = $1 +ORDER BY a.account_number NULLS LAST +` + +type ListAccountsByWalletRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet, ordered by account number. Imported +// accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]ListAccountsByWalletRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletStmt, ListAccountsByWallet, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletRow + for rows.Next() { + var i ListAccountsByWalletRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); 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 ListAccountsByWalletAndName = `-- name: ListAccountsByWalletAndName :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = $1 AND a.account_name = $2 +ORDER BY a.account_number NULLS LAST +` + +type ListAccountsByWalletAndNameParams struct { + WalletID int64 + AccountName string +} + +type ListAccountsByWalletAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet filtered by account name, ordered by account +// number. Imported accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletAndNameStmt, ListAccountsByWalletAndName, arg.WalletID, arg.AccountName) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletAndNameRow + for rows.Next() { + var i ListAccountsByWalletAndNameRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); 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 ListAccountsByWalletScope = `-- name: ListAccountsByWalletScope :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 +ORDER BY a.account_number NULLS LAST +` + +type ListAccountsByWalletScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 +} + +type ListAccountsByWalletScopeRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet and scope tuple, ordered by account number. +// Imported accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletScopeStmt, ListAccountsByWalletScope, arg.WalletID, arg.Purpose, arg.CoinType) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletScopeRow + for rows.Next() { + var i ListAccountsByWalletScopeRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); 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 UpdateAccountNameByWalletScopeAndName = `-- name: UpdateAccountNameByWalletScopeAndName :execrows +UPDATE accounts +SET account_name = $1 +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = $2 + AND purpose = $3 + AND coin_type = $4 + ) + AND account_name = $5 +` + +type UpdateAccountNameByWalletScopeAndNameParams struct { + NewName string + WalletID int64 + Purpose int64 + CoinType int64 + OldName string +} + +// Renames an account identified by wallet id, scope tuple, and current account name. +func (q *Queries) UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) { + result, err := q.exec(ctx, q.updateAccountNameByWalletScopeAndNameStmt, UpdateAccountNameByWalletScopeAndName, + arg.NewName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.OldName, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateAccountNameByWalletScopeAndNumber = `-- name: UpdateAccountNameByWalletScopeAndNumber :execrows +UPDATE accounts +SET account_name = $1 +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = $2 + AND purpose = $3 + AND coin_type = $4 + ) + AND account_number = $5 +` + +type UpdateAccountNameByWalletScopeAndNumberParams struct { + NewName string + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 +} + +// Renames an account identified by wallet id, scope tuple, and account number. +func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) { + result, err := q.exec(ctx, q.updateAccountNameByWalletScopeAndNumberStmt, UpdateAccountNameByWalletScopeAndNumber, + arg.NewName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 870a1598ad..22925ed29e 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -24,6 +24,15 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { + return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) + } + if q.createDerivedAccountStmt, err = db.PrepareContext(ctx, CreateDerivedAccount); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAccount: %w", err) + } + if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { + return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) + } if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) } @@ -39,6 +48,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) } + if q.getAccountByScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByScopeAndName: %w", err) + } + if q.getAccountByScopeAndNumberStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByScopeAndNumber: %w", err) + } + if q.getAccountByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByWalletScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByWalletScopeAndName: %w", err) + } + if q.getAccountByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, GetAccountByWalletScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByWalletScopeAndNumber: %w", err) + } + if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } @@ -75,6 +99,18 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) } + if q.listAccountsByScopeStmt, err = db.PrepareContext(ctx, ListAccountsByScope); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByScope: %w", err) + } + if q.listAccountsByWalletStmt, err = db.PrepareContext(ctx, ListAccountsByWallet); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWallet: %w", err) + } + if q.listAccountsByWalletAndNameStmt, err = db.PrepareContext(ctx, ListAccountsByWalletAndName); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWalletAndName: %w", err) + } + if q.listAccountsByWalletScopeStmt, err = db.PrepareContext(ctx, ListAccountsByWalletScope); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWalletScope: %w", err) + } if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } @@ -84,6 +120,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) + } + if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -95,6 +137,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.createAccountSecretStmt != nil { + if cerr := q.createAccountSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) + } + } + if q.createDerivedAccountStmt != nil { + if cerr := q.createDerivedAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAccountStmt: %w", cerr) + } + } + if q.createImportedAccountStmt != nil { + if cerr := q.createImportedAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) + } + } if q.createKeyScopeStmt != nil { if cerr := q.createKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) @@ -120,6 +177,31 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) } } + if q.getAccountByScopeAndNameStmt != nil { + if cerr := q.getAccountByScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByScopeAndNameStmt: %w", cerr) + } + } + if q.getAccountByScopeAndNumberStmt != nil { + if cerr := q.getAccountByScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByScopeAndNumberStmt: %w", cerr) + } + } + if q.getAccountByWalletScopeAndNameStmt != nil { + if cerr := q.getAccountByWalletScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByWalletScopeAndNameStmt: %w", cerr) + } + } + if q.getAccountByWalletScopeAndNumberStmt != nil { + if cerr := q.getAccountByWalletScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByWalletScopeAndNumberStmt: %w", cerr) + } + } + if q.getAccountPropsByIdStmt != nil { + if cerr := q.getAccountPropsByIdStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -180,6 +262,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) } } + if q.listAccountsByScopeStmt != nil { + if cerr := q.listAccountsByScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByScopeStmt: %w", cerr) + } + } + if q.listAccountsByWalletStmt != nil { + if cerr := q.listAccountsByWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletStmt: %w", cerr) + } + } + if q.listAccountsByWalletAndNameStmt != nil { + if cerr := q.listAccountsByWalletAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletAndNameStmt: %w", cerr) + } + } + if q.listAccountsByWalletScopeStmt != nil { + if cerr := q.listAccountsByWalletScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) + } + } if q.listAddressTypesStmt != nil { if cerr := q.listAddressTypesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) @@ -195,6 +297,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.updateAccountNameByWalletScopeAndNameStmt != nil { + if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) + } + } + if q.updateAccountNameByWalletScopeAndNumberStmt != nil { + if cerr := q.updateAccountNameByWalletScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -242,57 +354,85 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createKeyScopeStmt *sql.Stmt - createWalletStmt *sql.Stmt - deleteBlockStmt *sql.Stmt - deleteKeyScopeStmt *sql.Stmt - deleteKeyScopeSecretsStmt *sql.Stmt - getAddressTypeByIDStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - getKeyScopeByIDStmt *sql.Stmt - getKeyScopeByWalletAndScopeStmt *sql.Stmt - getKeyScopeSecretsStmt *sql.Stmt - getWalletByIDStmt *sql.Stmt - getWalletByNameStmt *sql.Stmt - getWalletSecretsStmt *sql.Stmt - insertBlockStmt *sql.Stmt - insertKeyScopeSecretsStmt *sql.Stmt - insertWalletSecretsStmt *sql.Stmt - insertWalletSyncStateStmt *sql.Stmt - listAddressTypesStmt *sql.Stmt - listKeyScopesByWalletStmt *sql.Stmt - listWalletsStmt *sql.Stmt - updateWalletSecretsStmt *sql.Stmt - updateWalletSyncStateStmt *sql.Stmt + db DBTX + tx *sql.Tx + createAccountSecretStmt *sql.Stmt + createDerivedAccountStmt *sql.Stmt + createImportedAccountStmt *sql.Stmt + createKeyScopeStmt *sql.Stmt + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + deleteKeyScopeStmt *sql.Stmt + deleteKeyScopeSecretsStmt *sql.Stmt + getAccountByScopeAndNameStmt *sql.Stmt + getAccountByScopeAndNumberStmt *sql.Stmt + getAccountByWalletScopeAndNameStmt *sql.Stmt + getAccountByWalletScopeAndNumberStmt *sql.Stmt + getAccountPropsByIdStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getKeyScopeByIDStmt *sql.Stmt + getKeyScopeByWalletAndScopeStmt *sql.Stmt + getKeyScopeSecretsStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertKeyScopeSecretsStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listAccountsByScopeStmt *sql.Stmt + listAccountsByWalletStmt *sql.Stmt + listAccountsByWalletAndNameStmt *sql.Stmt + listAccountsByWalletScopeStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt + listKeyScopesByWalletStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateAccountNameByWalletScopeAndNameStmt *sql.Stmt + updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createKeyScopeStmt: q.createKeyScopeStmt, - createWalletStmt: q.createWalletStmt, - deleteBlockStmt: q.deleteBlockStmt, - deleteKeyScopeStmt: q.deleteKeyScopeStmt, - deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, - getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, - getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, - getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, - getWalletByIDStmt: q.getWalletByIDStmt, - getWalletByNameStmt: q.getWalletByNameStmt, - getWalletSecretsStmt: q.getWalletSecretsStmt, - insertBlockStmt: q.insertBlockStmt, - insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, - insertWalletSecretsStmt: q.insertWalletSecretsStmt, - insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, - listAddressTypesStmt: q.listAddressTypesStmt, - listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, - listWalletsStmt: q.listWalletsStmt, - updateWalletSecretsStmt: q.updateWalletSecretsStmt, - updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, + db: tx, + tx: tx, + createAccountSecretStmt: q.createAccountSecretStmt, + createDerivedAccountStmt: q.createDerivedAccountStmt, + createImportedAccountStmt: q.createImportedAccountStmt, + createKeyScopeStmt: q.createKeyScopeStmt, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + deleteKeyScopeStmt: q.deleteKeyScopeStmt, + deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + getAccountByScopeAndNameStmt: q.getAccountByScopeAndNameStmt, + getAccountByScopeAndNumberStmt: q.getAccountByScopeAndNumberStmt, + getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, + getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, + getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, + getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, + getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAccountsByScopeStmt: q.listAccountsByScopeStmt, + listAccountsByWalletStmt: q.listAccountsByWalletStmt, + listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, + listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listAddressTypesStmt: q.listAddressTypesStmt, + listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listWalletsStmt: q.listWalletsStmt, + updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, + updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go index 121df96ffe..a7e3e5968f 100644 --- a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -20,6 +20,7 @@ INSERT INTO key_scopes ( ) VALUES ( $1, $2, $3, $4, $5, $6 ) +ON CONFLICT (wallet_id, purpose, coin_type) DO NOTHING RETURNING id ` diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 57c00a5a20..41471588e8 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -9,6 +9,17 @@ import ( ) type Querier interface { + // Inserts the encrypted private key material for an account. + CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error + // Creates a new derived account under the given scope, allocating a fresh + // sequential account number from key_scopes.last_account_number. + // The allocation is atomic: the UPDATE takes the row lock on the scope row, + // returns the allocated number, and updates the counter for the next call. + CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) + // Creates a new imported account under the given scope with NULL account + // number. Imported accounts don't follow BIP44 derivation, so they don't need + // a sequential account number. + CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) // Creates a new key scope for a wallet and returns its ID. CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) @@ -17,6 +28,16 @@ type Querier interface { DeleteKeyScope(ctx context.Context, id int64) (int64, error) // Deletes the secrets for a key scope. DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) + // Returns a single account by scope id and account name. + GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) + // Returns a single account by scope id and account number. + GetAccountByScopeAndNumber(ctx context.Context, arg GetAccountByScopeAndNumberParams) (GetAccountByScopeAndNumberRow, error) + // Returns a single account by wallet id, scope tuple, and account name. + GetAccountByWalletScopeAndName(ctx context.Context, arg GetAccountByWalletScopeAndNameParams) (GetAccountByWalletScopeAndNameRow, error) + // Returns a single account by wallet id, scope tuple, and account number. + GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) + // Returns full account properties by account id. + GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) @@ -35,11 +56,27 @@ type Querier interface { InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + // Lists all accounts in a scope, ordered by account number. Imported accounts + // (with NULL account_number) appear last. + ListAccountsByScope(ctx context.Context, scopeID int64) ([]ListAccountsByScopeRow, error) + // Lists all accounts for a wallet, ordered by account number. Imported + // accounts (with NULL account_number) appear last. + ListAccountsByWallet(ctx context.Context, walletID int64) ([]ListAccountsByWalletRow, error) + // Lists all accounts for a wallet filtered by account name, ordered by account + // number. Imported accounts (with NULL account_number) appear last. + ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) + // Lists all accounts for a wallet and scope tuple, ordered by account number. + // Imported accounts (with NULL account_number) appear last. + ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Renames an account identified by wallet id, scope tuple, and current account name. + UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) + // Renames an account identified by wallet id, scope tuple, and account number. + UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go new file mode 100644 index 0000000000..6e4cd25831 --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -0,0 +1,711 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: accounts.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" + "time" +) + +const CreateAccountSecret = `-- name: CreateAccountSecret :exec +INSERT INTO account_secrets ( + account_id, + encrypted_private_key +) VALUES ( + ?, ? +) +` + +type CreateAccountSecretParams struct { + AccountID int64 + EncryptedPrivateKey []byte +} + +// Inserts the encrypted private key material for an account. +func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error { + _, err := q.exec(ctx, q.createAccountSecretStmt, CreateAccountSecret, arg.AccountID, arg.EncryptedPrivateKey) + return err +} + +const CreateDerivedAccount = `-- name: CreateDerivedAccount :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING id, account_number, created_at +` + +type CreateDerivedAccountParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool +} + +type CreateDerivedAccountRow struct { + ID int64 + AccountNumber sql.NullInt64 + CreatedAt time.Time +} + +// Creates a new derived account under the given scope, using a caller-provided +// account number. +// +// NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation +// step with an INSERT in a single CTE. +// +// We instead: +// 1. call AllocateAccountNumber (key_scopes.sql) +// 2. call CreateDerivedAccount with the returned number +// +// Both statements run within the same SQL transaction. +func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) { + row := q.queryRow(ctx, q.createDerivedAccountStmt, CreateDerivedAccount, + arg.ScopeID, + arg.AccountNumber, + arg.AccountName, + arg.OriginID, + arg.EncryptedPublicKey, + arg.MasterFingerprint, + arg.IsWatchOnly, + ) + var i CreateDerivedAccountRow + err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) + return i, err +} + +const CreateImportedAccount = `-- name: CreateImportedAccount :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + master_fingerprint, + is_watch_only +) +VALUES (?, NULL, ?, ?, ?, ?, ?) +RETURNING id, created_at +` + +type CreateImportedAccountParams struct { + ScopeID int64 + AccountName string + OriginID int64 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool +} + +type CreateImportedAccountRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates a new imported account under the given scope with NULL account +// number. Imported accounts don't follow BIP44 derivation, so they don't need +// a sequential account number. +func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) { + row := q.queryRow(ctx, q.createImportedAccountStmt, CreateImportedAccount, + arg.ScopeID, + arg.AccountName, + arg.OriginID, + arg.EncryptedPublicKey, + arg.MasterFingerprint, + arg.IsWatchOnly, + ) + var i CreateImportedAccountRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const GetAccountByScopeAndName = `-- name: GetAccountByScopeAndName :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? AND a.account_name = ? +` + +type GetAccountByScopeAndNameParams struct { + ScopeID int64 + AccountName string +} + +type GetAccountByScopeAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by scope id and account name. +func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) { + row := q.queryRow(ctx, q.getAccountByScopeAndNameStmt, GetAccountByScopeAndName, arg.ScopeID, arg.AccountName) + var i GetAccountByScopeAndNameRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByScopeAndNumber = `-- name: GetAccountByScopeAndNumber :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? AND a.account_number = ? +` + +type GetAccountByScopeAndNumberParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 +} + +type GetAccountByScopeAndNumberRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by scope id and account number. +func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccountByScopeAndNumberParams) (GetAccountByScopeAndNumberRow, error) { + row := q.queryRow(ctx, q.getAccountByScopeAndNumberStmt, GetAccountByScopeAndNumber, arg.ScopeID, arg.AccountNumber) + var i GetAccountByScopeAndNumberRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByWalletScopeAndName = `-- name: GetAccountByWalletScopeAndName :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? + AND a.account_name = ? +` + +type GetAccountByWalletScopeAndNameParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string +} + +type GetAccountByWalletScopeAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by wallet id, scope tuple, and account name. +func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAccountByWalletScopeAndNameParams) (GetAccountByWalletScopeAndNameRow, error) { + row := q.queryRow(ctx, q.getAccountByWalletScopeAndNameStmt, GetAccountByWalletScopeAndName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountName, + ) + var i GetAccountByWalletScopeAndNameRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountByWalletScopeAndNumber = `-- name: GetAccountByWalletScopeAndNumber :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? + AND a.account_number = ? +` + +type GetAccountByWalletScopeAndNumberParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 +} + +type GetAccountByWalletScopeAndNumberRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Returns a single account by wallet id, scope tuple, and account number. +func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) { + row := q.queryRow(ctx, q.getAccountByWalletScopeAndNumberStmt, GetAccountByWalletScopeAndNumber, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + ) + var i GetAccountByWalletScopeAndNumberRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ) + return i, err +} + +const GetAccountPropsById = `-- name: GetAccountPropsById :one +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.encrypted_public_key, + a.master_fingerprint, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type, + ks.internal_type_id, + ks.external_type_id +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.id = ? +` + +type GetAccountPropsByIdRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + InternalTypeID int64 + ExternalTypeID int64 +} + +// Returns full account properties by account id. +func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) { + row := q.queryRow(ctx, q.getAccountPropsByIdStmt, GetAccountPropsById, id) + var i GetAccountPropsByIdRow + err := row.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.EncryptedPublicKey, + &i.MasterFingerprint, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + &i.InternalTypeID, + &i.ExternalTypeID, + ) + return i, err +} + +const ListAccountsByScope = `-- name: ListAccountsByScope :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE a.scope_id = ? +ORDER BY a.account_number IS NULL, a.account_number +` + +type ListAccountsByScopeRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts in a scope, ordered by account number. Imported accounts +// (with NULL account_number) appear last. +func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]ListAccountsByScopeRow, error) { + rows, err := q.query(ctx, q.listAccountsByScopeStmt, ListAccountsByScope, scopeID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByScopeRow + for rows.Next() { + var i ListAccountsByScopeRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); 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 ListAccountsByWallet = `-- name: ListAccountsByWallet :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = ? +ORDER BY a.account_number IS NULL, a.account_number +` + +type ListAccountsByWalletRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet, ordered by account number. Imported +// accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]ListAccountsByWalletRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletStmt, ListAccountsByWallet, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletRow + for rows.Next() { + var i ListAccountsByWalletRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); 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 ListAccountsByWalletAndName = `-- name: ListAccountsByWalletAndName :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE ks.wallet_id = ? AND a.account_name = ? +ORDER BY a.account_number IS NULL, a.account_number +` + +type ListAccountsByWalletAndNameParams struct { + WalletID int64 + AccountName string +} + +type ListAccountsByWalletAndNameRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet filtered by account name, ordered by account +// number. Imported accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletAndNameStmt, ListAccountsByWalletAndName, arg.WalletID, arg.AccountName) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletAndNameRow + for rows.Next() { + var i ListAccountsByWalletAndNameRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); 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 ListAccountsByWalletScope = `-- name: ListAccountsByWalletScope :many +SELECT + a.account_number, + a.account_name, + a.origin_id, + a.is_watch_only, + a.created_at, + ks.purpose, + ks.coin_type +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +WHERE + ks.wallet_id = ? + AND ks.purpose = ? + AND ks.coin_type = ? +ORDER BY a.account_number IS NULL, a.account_number +` + +type ListAccountsByWalletScopeParams struct { + WalletID int64 + Purpose int64 + CoinType int64 +} + +type ListAccountsByWalletScopeRow struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 +} + +// Lists all accounts for a wallet and scope tuple, ordered by account number. +// Imported accounts (with NULL account_number) appear last. +func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) { + rows, err := q.query(ctx, q.listAccountsByWalletScopeStmt, ListAccountsByWalletScope, arg.WalletID, arg.Purpose, arg.CoinType) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountsByWalletScopeRow + for rows.Next() { + var i ListAccountsByWalletScopeRow + if err := rows.Scan( + &i.AccountNumber, + &i.AccountName, + &i.OriginID, + &i.IsWatchOnly, + &i.CreatedAt, + &i.Purpose, + &i.CoinType, + ); 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 UpdateAccountNameByWalletScopeAndName = `-- name: UpdateAccountNameByWalletScopeAndName :execrows +UPDATE accounts +SET account_name = ?1 +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = ?2 + AND purpose = ?3 + AND coin_type = ?4 + ) + AND account_name = ?5 +` + +type UpdateAccountNameByWalletScopeAndNameParams struct { + NewName string + WalletID int64 + Purpose int64 + CoinType int64 + OldName string +} + +// Renames an account identified by wallet id, scope tuple, and current account name. +func (q *Queries) UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) { + result, err := q.exec(ctx, q.updateAccountNameByWalletScopeAndNameStmt, UpdateAccountNameByWalletScopeAndName, + arg.NewName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.OldName, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateAccountNameByWalletScopeAndNumber = `-- name: UpdateAccountNameByWalletScopeAndNumber :execrows +UPDATE accounts +SET account_name = ?1 +WHERE + scope_id IN ( + SELECT id + FROM key_scopes + WHERE + wallet_id = ?2 + AND purpose = ?3 + AND coin_type = ?4 + ) + AND account_number = ?5 +` + +type UpdateAccountNameByWalletScopeAndNumberParams struct { + NewName string + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 +} + +// Renames an account identified by wallet id, scope tuple, and account number. +func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) { + result, err := q.exec(ctx, q.updateAccountNameByWalletScopeAndNumberStmt, UpdateAccountNameByWalletScopeAndNumber, + arg.NewName, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index a0079857b9..789f34aaa0 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,6 +24,18 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.allocateAccountNumberStmt, err = db.PrepareContext(ctx, AllocateAccountNumber); err != nil { + return nil, fmt.Errorf("error preparing query AllocateAccountNumber: %w", err) + } + if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { + return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) + } + if q.createDerivedAccountStmt, err = db.PrepareContext(ctx, CreateDerivedAccount); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAccount: %w", err) + } + if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { + return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) + } if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) } @@ -39,6 +51,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) } + if q.getAccountByScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByScopeAndName: %w", err) + } + if q.getAccountByScopeAndNumberStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByScopeAndNumber: %w", err) + } + if q.getAccountByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByWalletScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByWalletScopeAndName: %w", err) + } + if q.getAccountByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, GetAccountByWalletScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountByWalletScopeAndNumber: %w", err) + } + if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } @@ -75,6 +102,18 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertWalletSyncStateStmt, err = db.PrepareContext(ctx, InsertWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSyncState: %w", err) } + if q.listAccountsByScopeStmt, err = db.PrepareContext(ctx, ListAccountsByScope); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByScope: %w", err) + } + if q.listAccountsByWalletStmt, err = db.PrepareContext(ctx, ListAccountsByWallet); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWallet: %w", err) + } + if q.listAccountsByWalletAndNameStmt, err = db.PrepareContext(ctx, ListAccountsByWalletAndName); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWalletAndName: %w", err) + } + if q.listAccountsByWalletScopeStmt, err = db.PrepareContext(ctx, ListAccountsByWalletScope); err != nil { + return nil, fmt.Errorf("error preparing query ListAccountsByWalletScope: %w", err) + } if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } @@ -84,6 +123,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) + } + if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -95,6 +140,26 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.allocateAccountNumberStmt != nil { + if cerr := q.allocateAccountNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing allocateAccountNumberStmt: %w", cerr) + } + } + if q.createAccountSecretStmt != nil { + if cerr := q.createAccountSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) + } + } + if q.createDerivedAccountStmt != nil { + if cerr := q.createDerivedAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAccountStmt: %w", cerr) + } + } + if q.createImportedAccountStmt != nil { + if cerr := q.createImportedAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) + } + } if q.createKeyScopeStmt != nil { if cerr := q.createKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) @@ -120,6 +185,31 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) } } + if q.getAccountByScopeAndNameStmt != nil { + if cerr := q.getAccountByScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByScopeAndNameStmt: %w", cerr) + } + } + if q.getAccountByScopeAndNumberStmt != nil { + if cerr := q.getAccountByScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByScopeAndNumberStmt: %w", cerr) + } + } + if q.getAccountByWalletScopeAndNameStmt != nil { + if cerr := q.getAccountByWalletScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByWalletScopeAndNameStmt: %w", cerr) + } + } + if q.getAccountByWalletScopeAndNumberStmt != nil { + if cerr := q.getAccountByWalletScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountByWalletScopeAndNumberStmt: %w", cerr) + } + } + if q.getAccountPropsByIdStmt != nil { + if cerr := q.getAccountPropsByIdStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -180,6 +270,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertWalletSyncStateStmt: %w", cerr) } } + if q.listAccountsByScopeStmt != nil { + if cerr := q.listAccountsByScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByScopeStmt: %w", cerr) + } + } + if q.listAccountsByWalletStmt != nil { + if cerr := q.listAccountsByWalletStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletStmt: %w", cerr) + } + } + if q.listAccountsByWalletAndNameStmt != nil { + if cerr := q.listAccountsByWalletAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletAndNameStmt: %w", cerr) + } + } + if q.listAccountsByWalletScopeStmt != nil { + if cerr := q.listAccountsByWalletScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) + } + } if q.listAddressTypesStmt != nil { if cerr := q.listAddressTypesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) @@ -195,6 +305,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.updateAccountNameByWalletScopeAndNameStmt != nil { + if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) + } + } + if q.updateAccountNameByWalletScopeAndNumberStmt != nil { + if cerr := q.updateAccountNameByWalletScopeAndNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -242,57 +362,87 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createKeyScopeStmt *sql.Stmt - createWalletStmt *sql.Stmt - deleteBlockStmt *sql.Stmt - deleteKeyScopeStmt *sql.Stmt - deleteKeyScopeSecretsStmt *sql.Stmt - getAddressTypeByIDStmt *sql.Stmt - getBlockByHeightStmt *sql.Stmt - getKeyScopeByIDStmt *sql.Stmt - getKeyScopeByWalletAndScopeStmt *sql.Stmt - getKeyScopeSecretsStmt *sql.Stmt - getWalletByIDStmt *sql.Stmt - getWalletByNameStmt *sql.Stmt - getWalletSecretsStmt *sql.Stmt - insertBlockStmt *sql.Stmt - insertKeyScopeSecretsStmt *sql.Stmt - insertWalletSecretsStmt *sql.Stmt - insertWalletSyncStateStmt *sql.Stmt - listAddressTypesStmt *sql.Stmt - listKeyScopesByWalletStmt *sql.Stmt - listWalletsStmt *sql.Stmt - updateWalletSecretsStmt *sql.Stmt - updateWalletSyncStateStmt *sql.Stmt + db DBTX + tx *sql.Tx + allocateAccountNumberStmt *sql.Stmt + createAccountSecretStmt *sql.Stmt + createDerivedAccountStmt *sql.Stmt + createImportedAccountStmt *sql.Stmt + createKeyScopeStmt *sql.Stmt + createWalletStmt *sql.Stmt + deleteBlockStmt *sql.Stmt + deleteKeyScopeStmt *sql.Stmt + deleteKeyScopeSecretsStmt *sql.Stmt + getAccountByScopeAndNameStmt *sql.Stmt + getAccountByScopeAndNumberStmt *sql.Stmt + getAccountByWalletScopeAndNameStmt *sql.Stmt + getAccountByWalletScopeAndNumberStmt *sql.Stmt + getAccountPropsByIdStmt *sql.Stmt + getAddressTypeByIDStmt *sql.Stmt + getBlockByHeightStmt *sql.Stmt + getKeyScopeByIDStmt *sql.Stmt + getKeyScopeByWalletAndScopeStmt *sql.Stmt + getKeyScopeSecretsStmt *sql.Stmt + getWalletByIDStmt *sql.Stmt + getWalletByNameStmt *sql.Stmt + getWalletSecretsStmt *sql.Stmt + insertBlockStmt *sql.Stmt + insertKeyScopeSecretsStmt *sql.Stmt + insertWalletSecretsStmt *sql.Stmt + insertWalletSyncStateStmt *sql.Stmt + listAccountsByScopeStmt *sql.Stmt + listAccountsByWalletStmt *sql.Stmt + listAccountsByWalletAndNameStmt *sql.Stmt + listAccountsByWalletScopeStmt *sql.Stmt + listAddressTypesStmt *sql.Stmt + listKeyScopesByWalletStmt *sql.Stmt + listWalletsStmt *sql.Stmt + updateAccountNameByWalletScopeAndNameStmt *sql.Stmt + updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateWalletSecretsStmt *sql.Stmt + updateWalletSyncStateStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createKeyScopeStmt: q.createKeyScopeStmt, - createWalletStmt: q.createWalletStmt, - deleteBlockStmt: q.deleteBlockStmt, - deleteKeyScopeStmt: q.deleteKeyScopeStmt, - deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, - getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, - getBlockByHeightStmt: q.getBlockByHeightStmt, - getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, - getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, - getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, - getWalletByIDStmt: q.getWalletByIDStmt, - getWalletByNameStmt: q.getWalletByNameStmt, - getWalletSecretsStmt: q.getWalletSecretsStmt, - insertBlockStmt: q.insertBlockStmt, - insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, - insertWalletSecretsStmt: q.insertWalletSecretsStmt, - insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, - listAddressTypesStmt: q.listAddressTypesStmt, - listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, - listWalletsStmt: q.listWalletsStmt, - updateWalletSecretsStmt: q.updateWalletSecretsStmt, - updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, + db: tx, + tx: tx, + allocateAccountNumberStmt: q.allocateAccountNumberStmt, + createAccountSecretStmt: q.createAccountSecretStmt, + createDerivedAccountStmt: q.createDerivedAccountStmt, + createImportedAccountStmt: q.createImportedAccountStmt, + createKeyScopeStmt: q.createKeyScopeStmt, + createWalletStmt: q.createWalletStmt, + deleteBlockStmt: q.deleteBlockStmt, + deleteKeyScopeStmt: q.deleteKeyScopeStmt, + deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + getAccountByScopeAndNameStmt: q.getAccountByScopeAndNameStmt, + getAccountByScopeAndNumberStmt: q.getAccountByScopeAndNumberStmt, + getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, + getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, + getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getBlockByHeightStmt: q.getBlockByHeightStmt, + getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, + getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, + getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getWalletByIDStmt: q.getWalletByIDStmt, + getWalletByNameStmt: q.getWalletByNameStmt, + getWalletSecretsStmt: q.getWalletSecretsStmt, + insertBlockStmt: q.insertBlockStmt, + insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertWalletSecretsStmt: q.insertWalletSecretsStmt, + insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, + listAccountsByScopeStmt: q.listAccountsByScopeStmt, + listAccountsByWalletStmt: q.listAccountsByWalletStmt, + listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, + listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listAddressTypesStmt: q.listAddressTypesStmt, + listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listWalletsStmt: q.listWalletsStmt, + updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, + updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateWalletSecretsStmt: q.updateWalletSecretsStmt, + updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } } diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go index 241da81ea9..e464c5ce8d 100644 --- a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -9,6 +9,30 @@ import ( "context" ) +const AllocateAccountNumber = `-- name: AllocateAccountNumber :one +UPDATE key_scopes +SET last_account_number = last_account_number + 1 +WHERE id = ? +RETURNING id, last_account_number +` + +type AllocateAccountNumberRow struct { + ID int64 + LastAccountNumber int64 +} + +// Atomically allocates the next account number for a key scope. +// Returns the scope_id and the allocated account number. +// SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single +// CTE (unlike Postgres). So, we call this first, then pass the returned number +// to CreateAccount, within the same SQL transaction. +func (q *Queries) AllocateAccountNumber(ctx context.Context, id int64) (AllocateAccountNumberRow, error) { + row := q.queryRow(ctx, q.allocateAccountNumberStmt, AllocateAccountNumber, id) + var i AllocateAccountNumberRow + err := row.Scan(&i.ID, &i.LastAccountNumber) + return i, err +} + const CreateKeyScope = `-- name: CreateKeyScope :one INSERT INTO key_scopes ( wallet_id, @@ -20,6 +44,7 @@ INSERT INTO key_scopes ( ) VALUES ( ?, ?, ?, ?, ?, ? ) +ON CONFLICT (wallet_id, purpose, coin_type) DO NOTHING RETURNING id ` diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 27e37f69b7..b05c8a8ac9 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -9,6 +9,30 @@ import ( ) type Querier interface { + // Atomically allocates the next account number for a key scope. + // Returns the scope_id and the allocated account number. + // SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single + // CTE (unlike Postgres). So, we call this first, then pass the returned number + // to CreateAccount, within the same SQL transaction. + AllocateAccountNumber(ctx context.Context, id int64) (AllocateAccountNumberRow, error) + // Inserts the encrypted private key material for an account. + CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error + // Creates a new derived account under the given scope, using a caller-provided + // account number. + // + // NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation + // step with an INSERT in a single CTE. + // + // We instead: + // 1) call AllocateAccountNumber (key_scopes.sql) + // 2) call CreateDerivedAccount with the returned number + // + // Both statements run within the same SQL transaction. + CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) + // Creates a new imported account under the given scope with NULL account + // number. Imported accounts don't follow BIP44 derivation, so they don't need + // a sequential account number. + CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) // Creates a new key scope for a wallet and returns its ID. CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) @@ -17,6 +41,16 @@ type Querier interface { DeleteKeyScope(ctx context.Context, id int64) (int64, error) // Deletes the secrets for a key scope. DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) + // Returns a single account by scope id and account name. + GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) + // Returns a single account by scope id and account number. + GetAccountByScopeAndNumber(ctx context.Context, arg GetAccountByScopeAndNumberParams) (GetAccountByScopeAndNumberRow, error) + // Returns a single account by wallet id, scope tuple, and account name. + GetAccountByWalletScopeAndName(ctx context.Context, arg GetAccountByWalletScopeAndNameParams) (GetAccountByWalletScopeAndNameRow, error) + // Returns a single account by wallet id, scope tuple, and account number. + GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) + // Returns full account properties by account id. + GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) @@ -35,11 +69,27 @@ type Querier interface { InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error + // Lists all accounts in a scope, ordered by account number. Imported accounts + // (with NULL account_number) appear last. + ListAccountsByScope(ctx context.Context, scopeID int64) ([]ListAccountsByScopeRow, error) + // Lists all accounts for a wallet, ordered by account number. Imported + // accounts (with NULL account_number) appear last. + ListAccountsByWallet(ctx context.Context, walletID int64) ([]ListAccountsByWalletRow, error) + // Lists all accounts for a wallet filtered by account name, ordered by account + // number. Imported accounts (with NULL account_number) appear last. + ListAccountsByWalletAndName(ctx context.Context, arg ListAccountsByWalletAndNameParams) ([]ListAccountsByWalletAndNameRow, error) + // Lists all accounts for a wallet and scope tuple, ordered by account number. + // Imported accounts (with NULL account_number) appear last. + ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Renames an account identified by wallet id, scope tuple, and current account name. + UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) + // Renames an account identified by wallet id, scope tuple, and account number. + UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } From 6ea565fac36e0fa8c2154ff128c11ac7a1e9210a Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:04:06 -0300 Subject: [PATCH 360/691] wallet: refactor AccountStore interface to respect SRP --- wallet/internal/db/data_types.go | 54 ++++++++++++++------------------ wallet/internal/db/interface.go | 28 ++++++++--------- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index ee5ef8e126..dd4bcb4b92 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -8,7 +8,6 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" ) @@ -368,8 +367,9 @@ type ScopeAddrSchema struct { InternalAddrType AddressType } -// CreateAccountParams contains the parameters for creating a new account. -type CreateAccountParams struct { +// CreateDerivedAccountParams contains the parameters for creating a new derived +// account. +type CreateDerivedAccountParams struct { // WalletID is the ID of the wallet to create the account in. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -383,9 +383,9 @@ type CreateAccountParams struct { Name string } -// ImportAccountParams contains the data required to import an account from an -// extended key. -type ImportAccountParams struct { +// CreateImportedAccountParams contains the data required to store an imported +// account from an external extended key. +type CreateImportedAccountParams struct { // WalletID is the ID of the wallet to import the account into. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -395,29 +395,22 @@ type ImportAccountParams struct { // Name is the name of the account to import. Name string - // AccountKey is the extended key for the account. - AccountKey *hdkeychain.ExtendedKey - - // MasterKeyFingerprint is the fingerprint of the master key. - MasterKeyFingerprint uint32 - // Scope is the key scope for the account. The address schema for the - // account will be determined by the default mapping for this scope. + // scope will be determined by the default mapping for this scope. Scope KeyScope -} -// ImportAccountResult holds the results of an account import operation. -type ImportAccountResult struct { - // AccountProperties contains the properties of the imported account. - AccountProperties *AccountProperties + // MasterFingerprint is the fingerprint of the master key. + MasterFingerprint uint32 - // ExternalAddrs contains the derived external addresses if the import - // was a dry run. - ExternalAddrs []AddressInfo + // EncryptedPublicKey is the encrypted extended public key for the + // account. This should be encrypted by the caller before being passed + // to the database layer. + EncryptedPublicKey []byte - // InternalAddrs contains the derived internal addresses if the import - // was a dry run. - InternalAddrs []AddressInfo + // EncryptedPrivateKey is the encrypted extended private key for the + // account. This should be encrypted by the caller before being passed + // to the database layer. A nil or empty slice indicates watch-only. + EncryptedPrivateKey []byte } // AccountProperties contains properties associated with each account, such as @@ -441,11 +434,11 @@ type AccountProperties struct { // account. ImportedKeyCount uint32 - // AccountPubKey is the account's public key that can be used to - // derive any address relevant to said account. - // - // NOTE: This may be nil for imported accounts. - AccountPubKey *hdkeychain.ExtendedKey + // EncryptedPublicKey is the encrypted account public key. This is the + // encrypted form of the extended public key that can be used to derive + // addresses for the account. The caller must decrypt this using the + // appropriate crypto key to use it. + EncryptedPublicKey []byte // MasterKeyFingerprint represents the fingerprint of the root key // corresponding to the master public key (also known as the key with @@ -520,7 +513,8 @@ type RenameAccountParams struct { Scope KeyScope // OldName is the current name of the account. This is used to identify - // the account if AccountNumber is not provided. + // the account if AccountNumber is not provided. An empty string means + // this field is not provided (use AccountNumber instead). OldName string // AccountNumber is the number of the account to rename. This is used diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 18e6de92c7..317a006a1e 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -64,21 +64,21 @@ type WalletStore interface { // AccountStore defines the database actions for managing accounts. type AccountStore interface { - // CreateAccount creates a new account with the given name and scope. It - // returns the properties of the newly created account or an error if - // the - // creation fails. - CreateAccount(ctx context.Context, params CreateAccountParams) ( - *AccountInfo, error) + // CreateDerivedAccount creates a new derived account with the given name + // and scope. + // + // If the key scope does not exist, it will be automatically created with + // NULL encrypted keys using the address schema from ScopeAddrMap. + CreateDerivedAccount(ctx context.Context, + params CreateDerivedAccountParams) (*AccountInfo, error) - // ImportAccount imports an account from an extended key. This method - // supports normal imports, imports with a specific scope, and dry-run - // imports. The behavior is controlled by the fields in the - // ImportAccountParams struct. It returns the properties of the imported - // account and derived addresses (for dry runs) or an error if the - // import fails. The returned addresses are of type AddressInfo. - ImportAccount(ctx context.Context, params ImportAccountParams) ( - *ImportAccountResult, error) + // CreateImportedAccount stores an imported account identified by an + // extended public key. + // + // If the key scope does not exist, it will be automatically created with + // NULL encrypted keys using the address schema from ScopeAddrMap. + CreateImportedAccount(ctx context.Context, + params CreateImportedAccountParams) (*AccountProperties, error) // GetAccount retrieves information about a specific account, // identified by its name or account number within a given key scope. From 172335ba68e274a6780037314b98fca3a2b752f8 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 17 Jan 2026 17:27:54 -0300 Subject: [PATCH 361/691] wallet: add created at field to account info and properties --- wallet/internal/db/data_types.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index dd4bcb4b92..f31bab1c2f 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -350,6 +350,9 @@ type AccountInfo struct { // IsWatchOnly indicates whether the account is in watch-only mode. IsWatchOnly bool + // CreatedAt is the timestamp when the account was created in the database. + CreatedAt time.Time + // KeyScope is the key scope the account belongs to. This determines the // derivation path and the default address schema. KeyScope KeyScope @@ -453,6 +456,9 @@ type AccountProperties struct { // doesn't contain any private key information. IsWatchOnly bool + // CreatedAt is the timestamp when the account was created in the database. + CreatedAt time.Time + // AddrSchema, if non-nil, specifies an address schema override for // address generation only applicable to the account. AddrSchema *ScopeAddrSchema From 5c1eac59e883f2e673bd2c92ee9daac92960ac8d Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 23:21:37 -0300 Subject: [PATCH 362/691] wallet: add account origin type --- wallet/internal/db/data_types.go | 36 ++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index f31bab1c2f..b49f52239d 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -149,6 +149,24 @@ var ( } ) +// AccountOrigin specifies the origin of an account. This is used to identify +// the account origin type, such as derived from the wallet's HD seed or +// imported from an external source. +// +// The enum values MUST match the IDs in the account_origins database table. +// See migration 000005_accounts for the canonical descriptions. +type AccountOrigin uint8 + +const ( + // DerivedAccount indicates the account was derived from a hierarchical + // deterministic key. + DerivedAccount AccountOrigin = iota + + // ImportedAccount indicates the account was imported from an external + // source. + ImportedAccount +) + // Tapscript represents a Taproot script leaf, which includes the script itself // and its corresponding control block. This is used for spending Taproot // outputs. @@ -322,12 +340,19 @@ type UpdateWalletSecretsParams struct { // AccountInfo contains all information about a single account, including its // properties and balances. type AccountInfo struct { - // AccountNumber is the unique identifier for the account. + // AccountNumber is the BIP44 account index used for derived accounts. + // Imported accounts do not follow BIP44 derivation and therefore do not + // have a meaningful account index. For those accounts, this field is + // set to 0 and must not be used when Origin is ImportedAccount. AccountNumber uint32 // AccountName is the human-readable name of the account. AccountName string + // Origin indicates whether the account was derived from the wallet's + // HD seed or imported from an external source. + Origin AccountOrigin + // ExternalKeyCount is the number of external keys that have been // derived. ExternalKeyCount uint32 @@ -419,12 +444,19 @@ type CreateImportedAccountParams struct { // AccountProperties contains properties associated with each account, such as // the account name, number, and the nubmer of derived and imported keys. type AccountProperties struct { - // AccountNumber is the internal number used to reference the account. + // AccountNumber is the BIP44 account index used for derived accounts. + // Imported accounts do not follow BIP44 derivation and therefore do not + // have a meaningful account index. For those accounts, this field is + // set to 0 and must not be used when Origin is ImportedAccount. AccountNumber uint32 // AccountName is the user-identifying name of the account. AccountName string + // Origin indicates whether the account was derived from the wallet's + // HD seed or imported from an external source. + Origin AccountOrigin + // ExternalKeyCount is the number of internal keys that have been // derived for the account. ExternalKeyCount uint32 From 50ec3e1167983df73f8671214d88567e1ce9d18f Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:11:07 -0300 Subject: [PATCH 363/691] config: relax funlen to allow verbose names --- config/.golangci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/config/.golangci.yml b/config/.golangci.yml index b805751b2e..3f2e149991 100644 --- a/config/.golangci.yml +++ b/config/.golangci.yml @@ -51,7 +51,12 @@ linters: funlen: # Checks the number of lines in a function. # If lower than 0, disable the check. - lines: 100 + # Increased from 100 to 200 to allow more verbose parameters while still + # keeping low complexity. e.g.: + # func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, + # params CreateDerivedAccountParams) (*AccountInfo, error) { + lines: 200 + # Checks the number of statements in a function. statements: 50 From 2afcafde4a1436b0efbee31b68122d63e8ef63ca Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 31 Dec 2025 01:30:53 -0300 Subject: [PATCH 364/691] wallet: add enum range check to address types --- wallet/internal/db/address_types_common.go | 74 ++++++++++++---------- wallet/internal/db/address_types_pg.go | 23 ++++--- wallet/internal/db/address_types_sqlite.go | 23 ++++--- 3 files changed, 66 insertions(+), 54 deletions(-) diff --git a/wallet/internal/db/address_types_common.go b/wallet/internal/db/address_types_common.go index fa69e047b0..044f245203 100644 --- a/wallet/internal/db/address_types_common.go +++ b/wallet/internal/db/address_types_common.go @@ -7,10 +7,35 @@ import ( "fmt" ) -func addressTypeInfosFromRows[T any](rows []T, - toInfo func(T) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { +// errInvalidAddressType is returned when an address type ID from the database +// does not fit in AddressType (uint8). In practice, this should never happen, +// but it's possible if the database is modified incorrectly or the query is +// incorrect. +var errInvalidAddressType = errors.New("invalid address type") + +// idToAddressType safely converts an integer to AddressType. It returns an +// error if the value does not correspond to a known AddressType value. +func idToAddressType[T ~int16 | ~int64](v T) (AddressType, error) { + if v < 0 || v > T(Anchor) { + return 0, fmt.Errorf("%w: %d", errInvalidAddressType, v) + } + + return AddressType(v), nil +} + +// listAddressTypes is a generic helper that retrieves all address types from +// the database and converts them to AddressTypeInfo structs. +func listAddressTypes[Row any](ctx context.Context, + lister func(context.Context) ([]Row, error), + toInfo func(Row) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { + + rows, err := lister(ctx) + if err != nil { + return nil, fmt.Errorf("list address types: %w", err) + } types := make([]AddressTypeInfo, len(rows)) + for i, row := range rows { info, err := toInfo(row) if err != nil { @@ -23,41 +48,22 @@ func addressTypeInfosFromRows[T any](rows []T, return types, nil } -func listAddressTypes[T any](ctx context.Context, - list func(context.Context) ([]T, error), - toInfo func(T) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { +// getAddressTypeByID is a generic helper that retrieves a single address type +// by its ID and converts it to an AddressTypeInfo struct. It returns +// ErrAddressTypeNotFound if no matching type is found. +func getAddressTypeByID[Row any, ID any](ctx context.Context, + getter func(context.Context, ID) (Row, error), queryID ID, id AddressType, + toInfo func(Row) (AddressTypeInfo, error)) (AddressTypeInfo, error) { - rows, err := list(ctx) - if err != nil { - return nil, fmt.Errorf("list address types: %w", err) - } - - return addressTypeInfosFromRows(rows, toInfo) -} - -func getAddressTypeByID[T any, ID any](ctx context.Context, - get func(context.Context, ID) (T, error), queryID ID, - id AddressType, toInfo func(T) (AddressTypeInfo, error)) ( - AddressTypeInfo, error) { - - row, err := get(ctx, queryID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return AddressTypeInfo{}, fmt.Errorf( - "address type %d: %w", id, - ErrAddressTypeNotFound, - ) - } - - return AddressTypeInfo{}, fmt.Errorf("get address type: %w", - err) + row, err := getter(ctx, queryID) + if err == nil { + return toInfo(row) } - info, err := toInfo(row) - if err != nil { - return AddressTypeInfo{}, fmt.Errorf("get address type: %w", - err) + if errors.Is(err, sql.ErrNoRows) { + return AddressTypeInfo{}, fmt.Errorf("address type %d: %w", id, + ErrAddressTypeNotFound) } - return info, nil + return AddressTypeInfo{}, fmt.Errorf("get address type: %w", err) } diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/address_types_pg.go index 6f906ccc5d..242c121a6e 100644 --- a/wallet/internal/db/address_types_pg.go +++ b/wallet/internal/db/address_types_pg.go @@ -2,20 +2,20 @@ package db import ( "context" - "fmt" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -func pgAddressTypeToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { - id, err := int16ToUint8(row.ID) +// pgAddressTypeRowToInfo converts a PostgreSQL address type row to an +// AddressTypeInfo struct. +func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { + addrType, err := idToAddressType(row.ID) if err != nil { - return AddressTypeInfo{}, fmt.Errorf("address type id %d: %w", - row.ID, err) + return AddressTypeInfo{}, err } return AddressTypeInfo{ - Type: AddressType(id), + Type: addrType, Description: row.Description, }, nil } @@ -25,8 +25,9 @@ func pgAddressTypeToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { func (w *PostgresWalletDB) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { - return listAddressTypes(ctx, w.queries.ListAddressTypes, - pgAddressTypeToInfo) + return listAddressTypes( + ctx, w.queries.ListAddressTypes, pgAddressTypeRowToInfo, + ) } // GetAddressType returns the AddressTypeInfo associated with the given address @@ -34,6 +35,8 @@ func (w *PostgresWalletDB) ListAddressTypes(ctx context.Context) ( func (w *PostgresWalletDB) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { - return getAddressTypeByID(ctx, w.queries.GetAddressTypeByID, - int16(id), id, pgAddressTypeToInfo) + return getAddressTypeByID( + ctx, w.queries.GetAddressTypeByID, int16(id), id, + pgAddressTypeRowToInfo, + ) } diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/address_types_sqlite.go index b1b2d998bd..ceda868208 100644 --- a/wallet/internal/db/address_types_sqlite.go +++ b/wallet/internal/db/address_types_sqlite.go @@ -2,22 +2,22 @@ package db import ( "context" - "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -func sqliteAddressTypeToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, +// sqliteAddressTypeRowToInfo converts a SQLite address type row to an +// AddressTypeInfo struct. +func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, error) { - id, err := int64ToUint8(row.ID) + addrType, err := idToAddressType(row.ID) if err != nil { - return AddressTypeInfo{}, fmt.Errorf("address type id %d: %w", - row.ID, err) + return AddressTypeInfo{}, err } return AddressTypeInfo{ - Type: AddressType(id), + Type: addrType, Description: row.Description, }, nil } @@ -27,8 +27,9 @@ func sqliteAddressTypeToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, func (w *SQLiteWalletDB) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { - return listAddressTypes(ctx, w.queries.ListAddressTypes, - sqliteAddressTypeToInfo) + return listAddressTypes( + ctx, w.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, + ) } // GetAddressType returns the AddressTypeInfo associated with the given address @@ -36,6 +37,8 @@ func (w *SQLiteWalletDB) ListAddressTypes(ctx context.Context) ( func (w *SQLiteWalletDB) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { - return getAddressTypeByID(ctx, w.queries.GetAddressTypeByID, - int64(id), id, sqliteAddressTypeToInfo) + return getAddressTypeByID( + ctx, w.queries.GetAddressTypeByID, int64(id), id, + sqliteAddressTypeRowToInfo, + ) } From ba5fcecf7b1adbc777799bc470b22320baa99b1a Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:27:08 -0300 Subject: [PATCH 365/691] wallet: add AccountStore implementation --- wallet/internal/db/accounts_common.go | 494 ++++++++++++++++++++++++++ wallet/internal/db/accounts_pg.go | 388 ++++++++++++++++++++ wallet/internal/db/accounts_sqlite.go | 422 ++++++++++++++++++++++ wallet/internal/db/interface.go | 35 +- 4 files changed, 1337 insertions(+), 2 deletions(-) create mode 100644 wallet/internal/db/accounts_common.go create mode 100644 wallet/internal/db/accounts_pg.go create mode 100644 wallet/internal/db/accounts_sqlite.go diff --git a/wallet/internal/db/accounts_common.go b/wallet/internal/db/accounts_common.go new file mode 100644 index 0000000000..8c2681f3ad --- /dev/null +++ b/wallet/internal/db/accounts_common.go @@ -0,0 +1,494 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +var ( + // errNilDBAccountNumber is returned when the database returns a nil account + // number. In practice, this should never happen, but it's possible if the + // database is modified incorrectly or the query is incorrect. + errNilDBAccountNumber = errors.New("database returned nil account number") + + // errInvalidAccountOrigin is returned when an account origin ID from the + // database does not correspond to a known AccountOrigin value. In practice, + // this should never happen, but it's possible if the database is modified + // incorrectly or the query is incorrect. + errInvalidAccountOrigin = errors.New("invalid account origin") +) + +// validate validates required fields for creating a derived account. +func (params *CreateDerivedAccountParams) validate() error { + if params.Name == "" { + return ErrMissingAccountName + } + + return nil +} + +// validate validates required fields for creating an imported account. +func (params *CreateImportedAccountParams) validate() error { + if params.Name == "" { + return ErrMissingAccountName + } + + if len(params.EncryptedPublicKey) == 0 { + return ErrMissingAccountPublicKey + } + + return nil +} + +// isWatchOnly returns true if the account is watch-only. +func (params *CreateImportedAccountParams) isWatchOnly() bool { + return len(params.EncryptedPrivateKey) == 0 +} + +// accountPropsRow represents the raw database fields needed to construct +// AccountProperties. +type accountPropsRow[AddrTypeId, AccOriginId any] struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID AccOriginId + EncryptedPublicKey []byte + MasterFingerprint sql.NullInt64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + InternalTypeID AddrTypeId + ExternalTypeID AddrTypeId + IDToAddrType func(AddrTypeId) (AddressType, error) + IDToOriginType func(AccOriginId) (AccountOrigin, error) +} + +// accountPropsRowToProps converts a database row containing full account +// properties into an AccountProperties struct. The idToAddrType function is +// used to convert the internal and external address type IDs to AddressType +// values. +// +// TODO(stingelin): Add address counting support after address management is +// implemented. +func accountPropsRowToProps[AddrTypeId, AccOriginId any]( + row accountPropsRow[AddrTypeId, AccOriginId]) (*AccountProperties, error) { + + var accountNum uint32 + if row.AccountNumber.Valid { + var err error + + accountNum, err = int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return nil, fmt.Errorf("account number: %w", err) + } + } + + origin, err := row.IDToOriginType(row.OriginID) + if err != nil { + return nil, fmt.Errorf("origin: %w", err) + } + + purposeNum, err := int64ToUint32(row.Purpose) + if err != nil { + return nil, fmt.Errorf("purpose: %w", err) + } + + coinTypeNum, err := int64ToUint32(row.CoinType) + if err != nil { + return nil, fmt.Errorf("coin type: %w", err) + } + + internalType, err := row.IDToAddrType(row.InternalTypeID) + if err != nil { + return nil, fmt.Errorf("internal type: %w", err) + } + + externalType, err := row.IDToAddrType(row.ExternalTypeID) + if err != nil { + return nil, fmt.Errorf("external type: %w", err) + } + + var fingerprint uint32 + if row.MasterFingerprint.Valid { + fingerprint, err = int64ToUint32(row.MasterFingerprint.Int64) + if err != nil { + return nil, fmt.Errorf("master fingerprint: %w", err) + } + } + + return &AccountProperties{ + AccountNumber: accountNum, + AccountName: row.AccountName, + Origin: origin, + ExternalKeyCount: 0, + InternalKeyCount: 0, + ImportedKeyCount: 0, + EncryptedPublicKey: row.EncryptedPublicKey, + MasterKeyFingerprint: fingerprint, + KeyScope: KeyScope{ + Purpose: purposeNum, + Coin: coinTypeNum, + }, + IsWatchOnly: row.IsWatchOnly, + CreatedAt: row.CreatedAt, + AddrSchema: &ScopeAddrSchema{ + InternalAddrType: internalType, + ExternalAddrType: externalType, + }, + }, nil +} + +// accountInfosFromRows converts a slice of database rows into AccountInfo +// structs using the provided conversion function. +func accountInfosFromRows[T any](rows []T, + toInfo func(T) (*AccountInfo, error)) ([]AccountInfo, error) { + + accounts := make([]AccountInfo, len(rows)) + for i, row := range rows { + info, err := toInfo(row) + if err != nil { + return nil, err + } + + accounts[i] = *info + } + + return accounts, nil +} + +// listAccounts is a generic helper that retrieves accounts using the provided +// list function and converts the results to AccountInfo structs. +func listAccounts[T any, Args any](ctx context.Context, + lister func(context.Context, Args) ([]T, error), args Args, + toInfo func(T) (*AccountInfo, error)) ([]AccountInfo, error) { + + rows, err := lister(ctx, args) + if err != nil { + return nil, fmt.Errorf("list accounts: %w", err) + } + + return accountInfosFromRows(rows, toInfo) +} + +// getAddrSchemaForScope returns the address schema for a given key scope or +// returns an error if the scope is not in ScopeAddrMap. +func getAddrSchemaForScope(scope KeyScope) (ScopeAddrSchema, error) { + addrSchema, exists := ScopeAddrMap[scope] + if !exists { + return ScopeAddrSchema{}, fmt.Errorf("%w: scope %d/%d", + ErrUnknownKeyScope, scope.Purpose, scope.Coin) + } + + return addrSchema, nil +} + +// buildAccountInfo creates an AccountInfo with the provided values and zeroed +// balances and key counts while we do not yet support address counting. +// +// TODO(stingelin): Add address counting support after address management is +// implemented. +// TODO(stingelin): Add balance tracking support after transaction management is +// implemented. +func buildAccountInfo(accountNum uint32, accountName string, + origin AccountOrigin, isWatchOnly bool, createdAt time.Time, + scope KeyScope) *AccountInfo { + + return &AccountInfo{ + AccountNumber: accountNum, + AccountName: accountName, + Origin: origin, + ExternalKeyCount: 0, + InternalKeyCount: 0, + ImportedKeyCount: 0, + ConfirmedBalance: 0, + UnconfirmedBalance: 0, + IsWatchOnly: isWatchOnly, + CreatedAt: createdAt, + KeyScope: scope, + } +} + +// idToAccountOrigin safely converts an integer to AccountOrigin. It returns an +// error if the value does not correspond to a known AccountOrigin value. +func idToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { + if v < 0 || v > T(ImportedAccount) { + return 0, fmt.Errorf("%w: %d", errInvalidAccountOrigin, v) + } + + return AccountOrigin(v), nil +} + +// accountInfoRow represents the raw database fields needed to construct +// AccountInfo. +type accountInfoRow[AccOriginId any] struct { + AccountNumber sql.NullInt64 + AccountName string + OriginID AccOriginId + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + IDToOriginType func(AccOriginId) (AccountOrigin, error) +} + +// accountRowToInfo converts raw database field values into an AccountInfo +// struct. It handles type conversion and validation for each field. +func accountRowToInfo[AccOriginId any]( + row accountInfoRow[AccOriginId]) (*AccountInfo, error) { + + var accountNum uint32 + if row.AccountNumber.Valid { + var err error + + accountNum, err = int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return nil, fmt.Errorf("account number: %w", err) + } + } + + origin, err := row.IDToOriginType(row.OriginID) + if err != nil { + return nil, fmt.Errorf("origin: %w", err) + } + + purposeNum, err := int64ToUint32(row.Purpose) + if err != nil { + return nil, fmt.Errorf("purpose: %w", err) + } + + coinTypeNum, err := int64ToUint32(row.CoinType) + if err != nil { + return nil, fmt.Errorf("coin type: %w", err) + } + + return buildAccountInfo( + accountNum, row.AccountName, origin, row.IsWatchOnly, + row.CreatedAt, KeyScope{ + Purpose: purposeNum, + Coin: coinTypeNum, + }, + ), nil +} + +// ensureKeyScope retrieves an existing key scope or creates it if missing. It +// returns the scope ID once available. +func ensureKeyScope[Row any, GetArgs any, CreateArgs any]( + ctx context.Context, getter func(context.Context, GetArgs) (Row, error), + getArgs GetArgs, creator func(context.Context, CreateArgs) (int64, error), + createArgs func(ScopeAddrSchema) CreateArgs, rowToID func(Row) int64, + scope KeyScope) (int64, error) { + + scopeInfo, err := getter(ctx, getArgs) + if err == nil { + // Fast path: when the scope already exists. + return rowToID(scopeInfo), nil + } + + if !errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("check key scope: %w", err) + } + + defaultAddrSchema, err := getAddrSchemaForScope(scope) + if err != nil { + return 0, err + } + + // Slow path: needs to create the scope. The SQL uses + // "ON CONFLICT ... DO NOTHING RETURNING id", which means: + // - If INSERT succeeds (no conflict): returns the new row's id. + // - If INSERT conflicts (scope exists): returns NO rows, causing sqlc to + // return sql.ErrNoRows. + id, err := creator(ctx, createArgs(defaultAddrSchema)) + if err == nil { + return id, nil + } + + if !errors.Is(err, sql.ErrNoRows) { + // A real database error occurred (not a conflict). + return 0, fmt.Errorf("create key scope: %w", err) + } + + // ErrNoRows means the scope was created concurrently by another process + // (the INSERT hit DO NOTHING due to conflict). Re-fetch the scope that + // now exists. + scopeInfo, err = getter(ctx, getArgs) + if err != nil { + return 0, fmt.Errorf("get scope after create: %w", err) + } + + return rowToID(scopeInfo), nil +} + +// getAccountFunc defines a function signature for retrieving a single account. +type getAccountFunc func(context.Context, GetAccountQuery) (*AccountInfo, error) + +// getAccountByQuery dispatches to the appropriate query based on the provided +// account identifier. +func getAccountByQuery(ctx context.Context, query GetAccountQuery, + getByNumber getAccountFunc, getByName getAccountFunc) (*AccountInfo, + error) { + + switch { + case query.AccountNumber != nil && query.Name == nil: + return getByNumber(ctx, query) + + case query.Name != nil && query.AccountNumber == nil: + return getByName(ctx, query) + + default: + return nil, ErrInvalidAccountQuery + } +} + +// listAccountsFunc defines a function signature for listing accounts. +type listAccountsFunc func(context.Context, ListAccountsQuery) ([]AccountInfo, + error) + +// listAccountsByQuery dispatches to the appropriate list query based on the +// provided filters. It returns an error if both scope and name filters are +// provided, as they are mutually exclusive. +func listAccountsByQuery(ctx context.Context, query ListAccountsQuery, + listByScope listAccountsFunc, listByName listAccountsFunc, + listAll listAccountsFunc) ([]AccountInfo, error) { + + switch { + case query.Scope != nil && query.Name != nil: + return nil, ErrInvalidAccountQuery + + case query.Scope != nil: + return listByScope(ctx, query) + + case query.Name != nil: + return listByName(ctx, query) + + default: + return listAll(ctx, query) + } +} + +// renameAccountFunc defines a function signature for renaming an account. +type renameAccountFunc func(context.Context, RenameAccountParams) error + +// renameAccountByQuery dispatches to the appropriate rename query based on the +// provided account identifier (either account number or old name). +func renameAccountByQuery(ctx context.Context, params RenameAccountParams, + renameByNumber renameAccountFunc, renameByName renameAccountFunc) error { + + if params.NewName == "" { + return ErrMissingAccountName + } + + switch { + case params.AccountNumber != nil && params.OldName == "": + return renameByNumber(ctx, params) + + case params.OldName != "" && params.AccountNumber == nil: + return renameByName(ctx, params) + + default: + return ErrInvalidAccountQuery + } +} + +// getAccount is a generic helper that retrieves an account using the provided +// query function. It handles error mapping and delegates conversion to the +// toInfo function. +func getAccount[T any, Args any](ctx context.Context, + getter func(context.Context, Args) (T, error), args Args, + query GetAccountQuery, toInfo func(T) (*AccountInfo, error)) (*AccountInfo, + error) { + + row, err := getter(ctx, args) + if err == nil { + return toInfo(row) + } + + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get account: %w", err) + } + + if query.Name != nil { + return nil, fmt.Errorf("account %q in scope %d/%d: %w", *query.Name, + query.Scope.Purpose, query.Scope.Coin, ErrAccountNotFound) + } + + return nil, fmt.Errorf("account %d in scope %d/%d: %w", + *query.AccountNumber, query.Scope.Purpose, query.Scope.Coin, + ErrAccountNotFound) +} + +// renameAccount is a generic helper that updates an account name using the +// provided update function. It checks rows affected and returns an error if +// the account was not found. +func renameAccount[Args any](ctx context.Context, + update func(context.Context, Args) (int64, error), args Args, + params RenameAccountParams) error { + + rowsAffected, err := update(ctx, args) + if err != nil { + return fmt.Errorf("rename account: %w", err) + } + + if rowsAffected != 0 { + return nil + } + + if params.OldName != "" { + return fmt.Errorf("account %q in scope %d/%d: %w", params.OldName, + params.Scope.Purpose, params.Scope.Coin, ErrAccountNotFound) + } + + return fmt.Errorf("account %d in scope %d/%d: %w", *params.AccountNumber, + params.Scope.Purpose, params.Scope.Coin, ErrAccountNotFound) +} + +// createImportedAccount is a generic helper that creates an imported account. +// It handles ensuring the key scope exists, creating the account record, +// optionally creating the account secret for non-watch-only accounts, and +// fetching the full account properties from the database. +func createImportedAccount[CreateArgs any, CreateRow any, SecretArgs any]( + ctx context.Context, params CreateImportedAccountParams, + ensureScope func() (int64, error), + createAccount func(context.Context, CreateArgs) (CreateRow, error), + buildCreateArgs func(scopeID int64, isWatchOnly bool) CreateArgs, + rowToID func(CreateRow) int64, + createSecret func(context.Context, SecretArgs) error, + buildSecretArgs func(accountID int64) SecretArgs, + getProps func(accountID int64) (*AccountProperties, error), +) (*AccountProperties, error) { + + err := params.validate() + if err != nil { + return nil, err + } + + isWatchOnly := params.isWatchOnly() + + scopeID, err := ensureScope() + if err != nil { + return nil, err + } + + createArgs := buildCreateArgs(scopeID, isWatchOnly) + + row, err := createAccount(ctx, createArgs) + if err != nil { + return nil, fmt.Errorf("create account: %w", err) + } + + accountID := rowToID(row) + + if isWatchOnly { + return getProps(accountID) + } + + err = createSecret(ctx, buildSecretArgs(accountID)) + if err != nil { + return nil, fmt.Errorf("insert account secrets: %w", err) + } + + return getProps(accountID) +} diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go new file mode 100644 index 0000000000..fd9f3dfcf0 --- /dev/null +++ b/wallet/internal/db/accounts_pg.go @@ -0,0 +1,388 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// Ensure PostgresWalletDB satisfies the AccountStore interface. +var _ AccountStore = (*PostgresWalletDB)(nil) + +// GetAccount retrieves information about a specific account, identified by its +// name or account number within a given key scope. +func (w *PostgresWalletDB) GetAccount(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + getQueries := pgAccountGetQueries{q: w.queries} + + return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) +} + +// ListAccounts returns a slice of AccountInfo for all accounts, optionally +// filtered by name or key scope. +func (w *PostgresWalletDB) ListAccounts(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + listQueries := pgAccountListQueries{q: w.queries} + + return listAccountsByQuery( + ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, + ) +} + +// RenameAccount changes the name of an account. The account can be identified +// by its old name or its account number. +func (w *PostgresWalletDB) RenameAccount(ctx context.Context, + params RenameAccountParams) error { + + renameQueries := pgAccountRenameQueries{q: w.queries} + + return renameAccountByQuery( + ctx, params, renameQueries.byNumber, renameQueries.byName, + ) +} + +// CreateDerivedAccount creates a new derived account with the given name and +// scope. If the key scope does not exist, it is created with NULL encrypted +// keys using the address schema provided by the caller. +func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, + params CreateDerivedAccountParams) (*AccountInfo, error) { + + paramsErr := params.validate() + if paramsErr != nil { + return nil, paramsErr + } + + var info *AccountInfo + + err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + scopeID, err := pgEnsureKeyScope( + ctx, qtx, params.WalletID, params.Scope, + ) + if err != nil { + return err + } + + row, err := qtx.CreateDerivedAccount( + ctx, sqlcpg.CreateDerivedAccountParams{ + ID: scopeID, + AccountName: params.Name, + OriginID: int16(DerivedAccount), + IsWatchOnly: false, + }, + ) + if err != nil { + return fmt.Errorf("create account: %w", err) + } + + if !row.AccountNumber.Valid { + // This should never happen unless the query is modified + // incorrectly. + return errNilDBAccountNumber + } + + accNumber, err := int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) + } + + info = buildAccountInfo( + accNumber, params.Name, DerivedAccount, false, + row.CreatedAt, params.Scope, + ) + + return nil + }) + if err != nil { + return nil, err + } + + return info, nil +} + +// CreateImportedAccount stores an imported account identified by an extended +// public key. If the key scope does not exist, it is created with NULL +// encrypted keys using the address schema provided by the caller. Imported +// accounts have NULL account_number since they don't follow BIP44 derivation. +func (w *PostgresWalletDB) CreateImportedAccount(ctx context.Context, + params CreateImportedAccountParams) (*AccountProperties, error) { + + var props *AccountProperties + + err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + var err error + + props, err = createImportedAccount( + ctx, params, func() (int64, error) { + return pgEnsureKeyScope(ctx, qtx, params.WalletID, params.Scope) + }, qtx.CreateImportedAccount, + pgBuildCreateImportedAccountArgs(params), + func(row sqlcpg.CreateImportedAccountRow) int64 { return row.ID }, + qtx.CreateAccountSecret, pgBuildCreateAccountSecretArgs(params), + func(accountID int64) (*AccountProperties, error) { + return pgGetAccountProps(ctx, qtx, accountID) + }, + ) + + return err + }) + if err != nil { + return nil, err + } + + return props, nil +} + +// pgBuildCreateImportedAccountArgs returns a function that builds the +// CreateImportedAccountParams for PostgreSQL. +func pgBuildCreateImportedAccountArgs( + params CreateImportedAccountParams, +) func(int64, bool) sqlcpg.CreateImportedAccountParams { + + return func(scopeID int64, + isWatchOnly bool) sqlcpg.CreateImportedAccountParams { + + return sqlcpg.CreateImportedAccountParams{ + ScopeID: scopeID, + AccountName: params.Name, + OriginID: int16(ImportedAccount), + EncryptedPublicKey: params.EncryptedPublicKey, + MasterFingerprint: sql.NullInt64{ + Int64: int64(params.MasterFingerprint), + Valid: true, + }, + IsWatchOnly: isWatchOnly, + } + } +} + +// pgBuildCreateAccountSecretArgs returns a function that builds the +// CreateAccountSecretParams for PostgreSQL. +func pgBuildCreateAccountSecretArgs( + params CreateImportedAccountParams, +) func(int64) sqlcpg.CreateAccountSecretParams { + + return func(accountID int64) sqlcpg.CreateAccountSecretParams { + return sqlcpg.CreateAccountSecretParams{ + AccountID: accountID, + EncryptedPrivateKey: params.EncryptedPrivateKey, + } + } +} + +// pgGetAccountProps fetches full account properties from the database and +// converts the row to AccountProperties. +func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, + accountID int64) (*AccountProperties, error) { + + row, err := qtx.GetAccountPropsById(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account props: %w", err) + } + + return accountPropsRowToProps(accountPropsRow[int16, int16]{ + AccountNumber: row.AccountNumber, + AccountName: row.AccountName, + OriginID: row.OriginID, + EncryptedPublicKey: row.EncryptedPublicKey, + MasterFingerprint: row.MasterFingerprint, + IsWatchOnly: row.IsWatchOnly, + CreatedAt: row.CreatedAt, + Purpose: row.Purpose, + CoinType: row.CoinType, + InternalTypeID: row.InternalTypeID, + ExternalTypeID: row.ExternalTypeID, + IDToAddrType: idToAddressType[int16], + IDToOriginType: idToAccountOrigin[int16], + }) +} + +// pgEnsureKeyScope retrieves an existing key scope or creates it if missing for +// PostgreSQL. It returns the scope ID once available. +func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, + scope KeyScope) (int64, error) { + + return ensureKeyScope( + ctx, qtx.GetKeyScopeByWalletAndScope, + sqlcpg.GetKeyScopeByWalletAndScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + }, qtx.CreateKeyScope, + func(addrSchema ScopeAddrSchema) sqlcpg.CreateKeyScopeParams { + return sqlcpg.CreateKeyScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + EncryptedCoinPubKey: nil, + InternalTypeID: int16( + addrSchema.InternalAddrType, + ), + ExternalTypeID: int16( + addrSchema.ExternalAddrType, + ), + } + }, + func(row sqlcpg.GetKeyScopeByWalletAndScopeRow) int64 { + return row.ID + }, scope, + ) +} + +// pgAccountInfoRow is a type constraint for PostgreSQL account info row types +// that share the same field structure. This enables a single generic conversion +// function to handle all account query result types. +type pgAccountInfoRow interface { + sqlcpg.GetAccountByScopeAndNameRow | + sqlcpg.GetAccountByScopeAndNumberRow | + sqlcpg.GetAccountByWalletScopeAndNameRow | + sqlcpg.GetAccountByWalletScopeAndNumberRow | + sqlcpg.ListAccountsByWalletRow | + sqlcpg.ListAccountsByWalletScopeRow | + sqlcpg.ListAccountsByWalletAndNameRow +} + +// pgAccountRowToInfo converts a PostgreSQL account row to an AccountInfo +// struct. It uses type conversion since all pgAccountInfoRow types have +// identical fields. +func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { + // Direct conversion works only because all constraint types have + // identical fields. If sqlc types diverge, compilation will fail. + base := sqlcpg.GetAccountByScopeAndNameRow(row) + + return accountRowToInfo(accountInfoRow[int16]{ + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + OriginID: base.OriginID, + IsWatchOnly: base.IsWatchOnly, + CreatedAt: base.CreatedAt, + Purpose: base.Purpose, + CoinType: base.CoinType, + IDToOriginType: idToAccountOrigin[int16], + }) +} + +// pgAccountListQueries groups PostgreSQL account listing query methods. +type pgAccountListQueries struct { + q *sqlcpg.Queries +} + +// byScope lists accounts filtered by wallet ID and key scope. +func (p pgAccountListQueries) byScope(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, p.q.ListAccountsByWalletScope, + sqlcpg.ListAccountsByWalletScopeParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + }, pgAccountRowToInfo, + ) +} + +// byName lists accounts filtered by wallet ID and account name. +func (p pgAccountListQueries) byName(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, p.q.ListAccountsByWalletAndName, + sqlcpg.ListAccountsByWalletAndNameParams{ + WalletID: int64(query.WalletID), + AccountName: *query.Name, + }, pgAccountRowToInfo, + ) +} + +// all lists all accounts for a wallet. +func (p pgAccountListQueries) all(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, p.q.ListAccountsByWallet, int64(query.WalletID), + pgAccountRowToInfo, + ) +} + +// pgAccountGetQueries groups PostgreSQL account retrieval query methods. +type pgAccountGetQueries struct { + q *sqlcpg.Queries +} + +// byNumber retrieves an account by wallet ID, scope, and account number. +func (p pgAccountGetQueries) byNumber(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + return getAccount( + ctx, p.q.GetAccountByWalletScopeAndNumber, + sqlcpg.GetAccountByWalletScopeAndNumberParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: sql.NullInt64{ + Int64: int64(*query.AccountNumber), + Valid: true, + }, + }, query, pgAccountRowToInfo, + ) +} + +// byName retrieves an account by wallet ID, scope, and account name. +func (p pgAccountGetQueries) byName(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + return getAccount( + ctx, p.q.GetAccountByWalletScopeAndName, + sqlcpg.GetAccountByWalletScopeAndNameParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountName: *query.Name, + }, query, pgAccountRowToInfo, + ) +} + +// pgAccountRenameQueries groups PostgreSQL account rename query methods. +type pgAccountRenameQueries struct { + q *sqlcpg.Queries +} + +// byNumber renames an account identified by wallet ID, scope, and account +// number. +func (p pgAccountRenameQueries) byNumber(ctx context.Context, + params RenameAccountParams) error { + + return renameAccount( + ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, + sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountNumber: sql.NullInt64{ + Int64: int64(*params.AccountNumber), + Valid: true, + }, + }, params, + ) +} + +// byName renames an account identified by wallet ID, scope, and old account +// name. +func (p pgAccountRenameQueries) byName(ctx context.Context, + params RenameAccountParams) error { + + return renameAccount( + ctx, p.q.UpdateAccountNameByWalletScopeAndName, + sqlcpg.UpdateAccountNameByWalletScopeAndNameParams{ + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + OldName: params.OldName, + }, params, + ) +} diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go new file mode 100644 index 0000000000..c099ac47ee --- /dev/null +++ b/wallet/internal/db/accounts_sqlite.go @@ -0,0 +1,422 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// Ensure SQLiteWalletDB satisfies the AccountStore interface. +var _ AccountStore = (*SQLiteWalletDB)(nil) + +// GetAccount retrieves information about a specific account, identified by its +// name or account number within a given key scope. +func (w *SQLiteWalletDB) GetAccount(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + getQueries := sqliteAccountGetQueries{q: w.queries} + + return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) +} + +// ListAccounts returns a slice of AccountInfo for all accounts, optionally +// filtered by name or key scope. +func (w *SQLiteWalletDB) ListAccounts(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + listQueries := sqliteAccountListQueries{q: w.queries} + + return listAccountsByQuery( + ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, + ) +} + +// RenameAccount changes the name of an account. The account can be identified +// by its old name or its account number. +func (w *SQLiteWalletDB) RenameAccount(ctx context.Context, + params RenameAccountParams) error { + + renameQueries := sqliteAccountRenameQueries{q: w.queries} + + return renameAccountByQuery( + ctx, params, renameQueries.byNumber, renameQueries.byName, + ) +} + +// CreateDerivedAccount creates a new derived account with the given name and +// scope. If the key scope does not exist, it is created with NULL encrypted +// keys using the address schema provided by the caller. +func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, + params CreateDerivedAccountParams) (*AccountInfo, error) { + + paramsErr := params.validate() + if paramsErr != nil { + return nil, paramsErr + } + + var info *AccountInfo + + err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + scopeID, err := sqliteEnsureKeyScope( + ctx, qtx, params.WalletID, params.Scope, + ) + if err != nil { + return err + } + + row, err := sqliteAllocateAndCreateAccount( + ctx, qtx, scopeID, params.Name, + ) + if err != nil { + return fmt.Errorf("create account: %w", err) + } + + if !row.AccountNumber.Valid { + // This should never happen unless the query is modified + // incorrectly. + return errNilDBAccountNumber + } + + accNumber, err := int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) + } + + info = buildAccountInfo( + accNumber, params.Name, DerivedAccount, false, + row.CreatedAt, params.Scope, + ) + + return nil + }) + if err != nil { + return nil, err + } + + return info, nil +} + +// sqliteEnsureKeyScope retrieves an existing key scope or creates it if missing +// for SQLite. It returns the scope ID once available. +func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, + walletID uint32, scope KeyScope) (int64, error) { + + return ensureKeyScope( + ctx, qtx.GetKeyScopeByWalletAndScope, + sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + }, qtx.CreateKeyScope, + func(addrSchema ScopeAddrSchema) sqlcsqlite.CreateKeyScopeParams { + return sqlcsqlite.CreateKeyScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + EncryptedCoinPubKey: nil, + InternalTypeID: int64( + addrSchema.InternalAddrType, + ), + ExternalTypeID: int64( + addrSchema.ExternalAddrType, + ), + } + }, + func(row sqlcsqlite.GetKeyScopeByWalletAndScopeRow) int64 { + return row.ID + }, scope, + ) +} + +// sqliteAllocateAndCreateAccount allocates a new sequential account number and +// creates a derived account in a single atomic operation. SQLite requires a +// two-step process because it lacks PostgreSQL's UPDATE ... RETURNING clause. +func sqliteAllocateAndCreateAccount(ctx context.Context, + qtx *sqlcsqlite.Queries, scopeID int64, + accountName string) (sqlcsqlite.CreateDerivedAccountRow, error) { + + allocated, err := qtx.AllocateAccountNumber(ctx, scopeID) + if err != nil { + return sqlcsqlite.CreateDerivedAccountRow{}, + fmt.Errorf("allocate account number: %w", err) + } + + row, err := qtx.CreateDerivedAccount(ctx, + sqlcsqlite.CreateDerivedAccountParams{ + ScopeID: scopeID, + AccountNumber: sql.NullInt64{ + Int64: allocated.LastAccountNumber, + Valid: true, + }, + AccountName: accountName, + OriginID: int64(DerivedAccount), + IsWatchOnly: false, + }) + if err != nil { + return sqlcsqlite.CreateDerivedAccountRow{}, + fmt.Errorf("create account: %w", err) + } + + return row, nil +} + +// CreateImportedAccount stores an imported account identified by an extended +// public key. If the key scope does not exist, it is created with NULL +// encrypted keys using the address schema provided by the caller. Imported +// accounts have NULL account_number since they don't follow BIP44 derivation. +func (w *SQLiteWalletDB) CreateImportedAccount(ctx context.Context, + params CreateImportedAccountParams) (*AccountProperties, error) { + + var props *AccountProperties + + err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + var err error + + props, err = createImportedAccount( + ctx, params, + func() (int64, error) { + return sqliteEnsureKeyScope( + ctx, qtx, params.WalletID, params.Scope, + ) + }, + qtx.CreateImportedAccount, + sqliteBuildCreateImportedAccountArgs(params), + func(row sqlcsqlite.CreateImportedAccountRow) int64 { + return row.ID + }, + qtx.CreateAccountSecret, sqliteBuildCreateAccountSecretArgs(params), + func(accountID int64) (*AccountProperties, error) { + return sqliteGetAccountProps(ctx, qtx, accountID) + }, + ) + + return err + }) + if err != nil { + return nil, err + } + + return props, nil +} + +// sqliteBuildCreateImportedAccountArgs returns a function that builds the +// CreateImportedAccountParams for SQLite. +func sqliteBuildCreateImportedAccountArgs( + params CreateImportedAccountParams, +) func(int64, bool) sqlcsqlite.CreateImportedAccountParams { + + return func(scopeID int64, + isWatchOnly bool) sqlcsqlite.CreateImportedAccountParams { + + return sqlcsqlite.CreateImportedAccountParams{ + ScopeID: scopeID, + AccountName: params.Name, + OriginID: int64(ImportedAccount), + EncryptedPublicKey: params.EncryptedPublicKey, + MasterFingerprint: sql.NullInt64{ + Int64: int64(params.MasterFingerprint), + Valid: true, + }, + IsWatchOnly: isWatchOnly, + } + } +} + +// sqliteBuildCreateAccountSecretArgs returns a function that builds the +// CreateAccountSecretParams for SQLite. +func sqliteBuildCreateAccountSecretArgs( + params CreateImportedAccountParams, +) func(int64) sqlcsqlite.CreateAccountSecretParams { + + return func(accountID int64) sqlcsqlite.CreateAccountSecretParams { + return sqlcsqlite.CreateAccountSecretParams{ + AccountID: accountID, + EncryptedPrivateKey: params.EncryptedPrivateKey, + } + } +} + +// sqliteGetAccountProps fetches full account properties from the database and +// converts the row to AccountProperties. +func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, + accountID int64) (*AccountProperties, error) { + + row, err := qtx.GetAccountPropsById(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account props: %w", err) + } + + return accountPropsRowToProps(accountPropsRow[int64, int64]{ + AccountNumber: row.AccountNumber, + AccountName: row.AccountName, + OriginID: row.OriginID, + EncryptedPublicKey: row.EncryptedPublicKey, + MasterFingerprint: row.MasterFingerprint, + IsWatchOnly: row.IsWatchOnly, + CreatedAt: row.CreatedAt, + Purpose: row.Purpose, + CoinType: row.CoinType, + InternalTypeID: row.InternalTypeID, + ExternalTypeID: row.ExternalTypeID, + IDToAddrType: idToAddressType[int64], + IDToOriginType: idToAccountOrigin[int64], + }) +} + +// sqliteAccountInfoRow is a type constraint for SQLite account info row types +// that share the same field structure. This enables a single generic conversion +// function to handle all account query result types. +type sqliteAccountInfoRow interface { + sqlcsqlite.GetAccountByScopeAndNameRow | + sqlcsqlite.GetAccountByScopeAndNumberRow | + sqlcsqlite.GetAccountByWalletScopeAndNameRow | + sqlcsqlite.GetAccountByWalletScopeAndNumberRow | + sqlcsqlite.ListAccountsByWalletRow | + sqlcsqlite.ListAccountsByWalletScopeRow | + sqlcsqlite.ListAccountsByWalletAndNameRow +} + +// sqliteAccountRowToInfo converts a SQLite account row to an AccountInfo +// struct. It uses type conversion since all sqliteAccountInfoRow types have +// identical fields. +func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, + error) { + + // Direct conversion works only because all constraint types have + // identical fields. If sqlc types diverge, compilation will fail. + base := sqlcsqlite.GetAccountByScopeAndNameRow(row) + + return accountRowToInfo(accountInfoRow[int64]{ + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + OriginID: base.OriginID, + IsWatchOnly: base.IsWatchOnly, + CreatedAt: base.CreatedAt, + Purpose: base.Purpose, + CoinType: base.CoinType, + IDToOriginType: idToAccountOrigin[int64], + }) +} + +// sqliteAccountListQueries groups SQLite account listing query methods. +type sqliteAccountListQueries struct { + q *sqlcsqlite.Queries +} + +// byScope lists accounts filtered by wallet ID and key scope. +func (s sqliteAccountListQueries) byScope(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, s.q.ListAccountsByWalletScope, + sqlcsqlite.ListAccountsByWalletScopeParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + }, sqliteAccountRowToInfo, + ) +} + +// byName lists accounts filtered by wallet ID and account name. +func (s sqliteAccountListQueries) byName(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, s.q.ListAccountsByWalletAndName, + sqlcsqlite.ListAccountsByWalletAndNameParams{ + WalletID: int64(query.WalletID), + AccountName: *query.Name, + }, sqliteAccountRowToInfo, + ) +} + +// all lists all accounts for a wallet. +func (s sqliteAccountListQueries) all(ctx context.Context, + query ListAccountsQuery) ([]AccountInfo, error) { + + return listAccounts( + ctx, s.q.ListAccountsByWallet, int64(query.WalletID), + sqliteAccountRowToInfo, + ) +} + +// sqliteAccountGetQueries groups SQLite account retrieval query methods. +type sqliteAccountGetQueries struct { + q *sqlcsqlite.Queries +} + +// byNumber retrieves an account by wallet ID, scope, and account number. +func (s sqliteAccountGetQueries) byNumber(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + return getAccount( + ctx, s.q.GetAccountByWalletScopeAndNumber, + sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: sql.NullInt64{ + Int64: int64(*query.AccountNumber), + Valid: true, + }, + }, query, sqliteAccountRowToInfo, + ) +} + +// byName retrieves an account by wallet ID, scope, and account name. +func (s sqliteAccountGetQueries) byName(ctx context.Context, + query GetAccountQuery) (*AccountInfo, error) { + + return getAccount(ctx, s.q.GetAccountByWalletScopeAndName, + sqlcsqlite.GetAccountByWalletScopeAndNameParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountName: *query.Name, + }, query, sqliteAccountRowToInfo, + ) +} + +// sqliteAccountRenameQueries groups SQLite account rename query methods. +type sqliteAccountRenameQueries struct { + q *sqlcsqlite.Queries +} + +// byNumber renames an account identified by wallet ID, scope, and account +// number. +func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, + params RenameAccountParams) error { + + return renameAccount( + ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, + sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountNumber: sql.NullInt64{ + Int64: int64(*params.AccountNumber), + Valid: true, + }, + }, params, + ) +} + +// byName renames an account identified by wallet ID, scope, and old account +// name. +func (s sqliteAccountRenameQueries) byName(ctx context.Context, + params RenameAccountParams) error { + + return renameAccount( + ctx, s.q.UpdateAccountNameByWalletScopeAndName, + sqlcsqlite.UpdateAccountNameByWalletScopeAndNameParams{ + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + OldName: params.OldName, + }, params, + ) +} diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 317a006a1e..a995f06775 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -22,9 +22,40 @@ var ( // ErrNilDB is returned when a nil database connection pointer is // provided to the wallet. - ErrNilDB = errors.New( - "wallet requires a non-nil database connection", + ErrNilDB = errors.New("wallet requires a non-nil database connection") + + // ErrAccountNotFound is returned when an account is not found in the + // database. + ErrAccountNotFound = errors.New("account not found") + + // ErrKeyScopeNotFound is returned when a key scope is not found in the + // database. + ErrKeyScopeNotFound = errors.New("key scope not found") + + // ErrUnknownKeyScope is returned when a key scope is not found in + // ScopeAddrMap. + ErrUnknownKeyScope = errors.New("unknown scope in ScopeAddrMap") + + // ErrInvalidAccountQuery is returned when both or neither account filters + // are provided in GetAccount or RenameAccount. + ErrInvalidAccountQuery = errors.New( + "exactly one of Name or AccountNumber must be provided", + ) + + // ErrMissingAccountPublicKey is returned when an imported account is + // missing the encrypted public key. + ErrMissingAccountPublicKey = errors.New( + "imported account requires an encrypted public key", ) + + // ErrMissingAccountName is returned when an account is being created + // without a name. + ErrMissingAccountName = errors.New("account name is required") + + // ErrMaxAccountNumberReached indicates that no more accounts can be created + // within a key scope because the account number counter has reached its + // maximum representable value. + ErrMaxAccountNumberReached = errors.New("max account number reached") ) // WalletStore defines the methods for wallet-level operations. From dc5cd3875dff169894e2d14e431cff45a227be2c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:45:05 -0300 Subject: [PATCH 366/691] wallet: refactor NewTestStore to return Queries instead of raw DB --- .../internal/db/itest/fixtures_common_test.go | 265 ++++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 29 ++ .../internal/db/itest/fixtures_sqlite_test.go | 29 ++ wallet/internal/db/itest/fixtures_test.go | 100 ------- wallet/internal/db/itest/pg_test.go | 7 +- wallet/internal/db/itest/sqlite_test.go | 7 +- wallet/internal/db/itest/wallet_store_test.go | 8 +- 7 files changed, 337 insertions(+), 108 deletions(-) create mode 100644 wallet/internal/db/itest/fixtures_common_test.go create mode 100644 wallet/internal/db/itest/fixtures_pg_test.go create mode 100644 wallet/internal/db/itest/fixtures_sqlite_test.go delete mode 100644 wallet/internal/db/itest/fixtures_test.go diff --git a/wallet/internal/db/itest/fixtures_common_test.go b/wallet/internal/db/itest/fixtures_common_test.go new file mode 100644 index 0000000000..870d05a743 --- /dev/null +++ b/wallet/internal/db/itest/fixtures_common_test.go @@ -0,0 +1,265 @@ +package itest + +import ( + "crypto/rand" + "fmt" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcwallet/wallet/internal/db" +) + +// CreateWalletParamsFixture creates test parameters for wallet creation. +func CreateWalletParamsFixture(name string) db.CreateWalletParams { + return db.CreateWalletParams{ + Name: name, + IsImported: false, + ManagerVersion: 1, + IsWatchOnly: false, + EncryptedMasterPrivKey: RandomBytes(32), + EncryptedMasterPubKey: RandomBytes(32), + MasterKeyPubParams: RandomBytes(16), + MasterKeyPrivParams: RandomBytes(16), + EncryptedCryptoPrivKey: RandomBytes(32), + EncryptedCryptoPubKey: RandomBytes(32), + EncryptedCryptoScriptKey: RandomBytes(32), + } +} + +// CreateImportedWalletParams creates test parameters for an imported wallet. +func CreateImportedWalletParams(name string) db.CreateWalletParams { + params := CreateWalletParamsFixture(name) + params.IsImported = true + + return params +} + +// CreateWatchOnlyWalletParams creates test parameters for a watch-only wallet. +func CreateWatchOnlyWalletParams(name string) db.CreateWalletParams { + params := CreateWalletParamsFixture(name) + params.IsWatchOnly = true + params.EncryptedMasterPrivKey = nil + params.MasterKeyPrivParams = nil + params.EncryptedCryptoPrivKey = nil + params.EncryptedCryptoScriptKey = nil + + return params +} + +// RandomBytes generates random bytes for test data. +func RandomBytes(n int) []byte { + b := make([]byte, n) + + _, err := rand.Read(b) + if err != nil { + // This should never happen. + panic(fmt.Sprintf("failed to generate random bytes: %v", err)) + } + + return b +} + +// RandomHash generates a random chainhash.Hash for testing. +func RandomHash() chainhash.Hash { + var h chainhash.Hash + + _, err := rand.Read(h[:]) + if err != nil { + // This should never happen. + panic(fmt.Sprintf("failed to generate random hash: %v", err)) + } + + return h +} + +// NewBlockFixture creates a new Block with the provided height, current time, +// and random hash. +func NewBlockFixture(height uint32) db.Block { + hash := RandomHash() + timestamp := time.Now().UTC() + + return db.Block{ + Hash: hash, + Height: height, + Timestamp: timestamp, + } +} + +// AccountTestCase defines a reusable account fixture for tests. It provides +// a unified way to describe both derived and imported accounts across all +// standard key scopes. +type AccountTestCase struct { + // Name is the account name to use in tests. + Name string + + // Scope is the key scope for the account. + Scope db.KeyScope + + // Origin indicates whether the account is derived or imported. + Origin db.AccountOrigin + + // IsWatchOnly indicates whether the account has no private key. + // Only relevant for imported accounts. + IsWatchOnly bool +} + +// DerivedAccountCases contains derived account fixtures across all standard +// key scopes, with multiple accounts per scope. +var DerivedAccountCases = []AccountTestCase{ + { + Name: "derived-bip84-default", + Scope: db.KeyScopeBIP0084, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip84-savings", + Scope: db.KeyScopeBIP0084, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip86-default", + Scope: db.KeyScopeBIP0086, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip86-savings", + Scope: db.KeyScopeBIP0086, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip44-default", + Scope: db.KeyScopeBIP0044, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip44-savings", + Scope: db.KeyScopeBIP0044, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip49-default", + Scope: db.KeyScopeBIP0049Plus, + Origin: db.DerivedAccount, + }, + { + Name: "derived-bip49-savings", + Scope: db.KeyScopeBIP0049Plus, + Origin: db.DerivedAccount, + }, +} + +// ImportedAccountCases contains imported account fixtures (with private keys) +// across multiple key scopes. +var ImportedAccountCases = []AccountTestCase{ + { + Name: "imported-bip84-main", + Scope: db.KeyScopeBIP0084, + Origin: db.ImportedAccount, + }, + { + Name: "imported-bip84-hardware", + Scope: db.KeyScopeBIP0084, + Origin: db.ImportedAccount, + }, + { + Name: "imported-bip86-main", + Scope: db.KeyScopeBIP0086, + Origin: db.ImportedAccount, + }, + { + Name: "imported-bip86-hardware", + Scope: db.KeyScopeBIP0086, + Origin: db.ImportedAccount, + }, + { + Name: "imported-bip44-legacy", + Scope: db.KeyScopeBIP0044, + Origin: db.ImportedAccount, + }, +} + +// WatchOnlyAccountCases contains watch-only imported account fixtures (no +// private keys) across multiple key scopes. +var WatchOnlyAccountCases = []AccountTestCase{ + { + Name: "watchonly-bip84-cold", + Scope: db.KeyScopeBIP0084, + Origin: db.ImportedAccount, + IsWatchOnly: true, + }, + { + Name: "watchonly-bip84-monitor", + Scope: db.KeyScopeBIP0084, + Origin: db.ImportedAccount, + IsWatchOnly: true, + }, + { + Name: "watchonly-bip86-cold", + Scope: db.KeyScopeBIP0086, + Origin: db.ImportedAccount, + IsWatchOnly: true, + }, + { + Name: "watchonly-bip86-monitor", + Scope: db.KeyScopeBIP0086, + Origin: db.ImportedAccount, + IsWatchOnly: true, + }, +} + +// AllAccountCases combines all account test cases (derived, imported, and +// watch-only) into a single slice. +var AllAccountCases = append( + append(DerivedAccountCases, ImportedAccountCases...), + WatchOnlyAccountCases..., +) + +// AllImportedAccountCases combines imported and watch-only account cases. +var AllImportedAccountCases = append( + ImportedAccountCases, WatchOnlyAccountCases..., +) + +// DerivedParams converts the test case to CreateDerivedAccountParams. +func (tc AccountTestCase) DerivedParams( + walletID uint32) db.CreateDerivedAccountParams { + + return db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: tc.Scope, + Name: tc.Name, + } +} + +// ImportedParams converts the test case to CreateImportedAccountParams. If +// IsWatchOnly is true, EncryptedPrivateKey will be nil. +func (tc AccountTestCase) ImportedParams( + walletID uint32) db.CreateImportedAccountParams { + + params := db.CreateImportedAccountParams{ + WalletID: walletID, + Name: tc.Name, + Scope: tc.Scope, + MasterFingerprint: 12345, + EncryptedPublicKey: RandomBytes(32), + } + + if !tc.IsWatchOnly { + params.EncryptedPrivateKey = RandomBytes(32) + } + + return params +} + +// FilterAccountsByScope returns a slice of AccountTestCases filtered by the +// given key scope. +func FilterAccountsByScope(scope db.KeyScope) []AccountTestCase { + var filtered []AccountTestCase + + for _, tc := range AllAccountCases { + if tc.Scope == scope { + filtered = append(filtered, tc) + } + } + + return filtered +} diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go new file mode 100644 index 0000000000..02c54504c3 --- /dev/null +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -0,0 +1,29 @@ +//go:build itest && test_db_postgres + +package itest + +import ( + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + "github.com/stretchr/testify/require" +) + +// CreateBlockFixture inserts a test block into the database and returns it. +func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, + height uint32) db.Block { + t.Helper() + + block := NewBlockFixture(height) + err := queries.InsertBlock( + t.Context(), sqlcpg.InsertBlockParams{ + BlockHeight: int32(block.Height), + HeaderHash: block.Hash[:], + BlockTimestamp: block.Timestamp.Unix(), + }, + ) + require.NoError(t, err, "failed to insert block") + + return block +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go new file mode 100644 index 0000000000..3881ad6328 --- /dev/null +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -0,0 +1,29 @@ +//go:build itest && !test_db_postgres + +package itest + +import ( + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +// CreateBlockFixture inserts a test block into the database and returns it. +func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, + height uint32) db.Block { + t.Helper() + + block := NewBlockFixture(height) + err := queries.InsertBlock( + t.Context(), sqlcsqlite.InsertBlockParams{ + BlockHeight: int64(block.Height), + HeaderHash: block.Hash[:], + BlockTimestamp: block.Timestamp.Unix(), + }, + ) + require.NoError(t, err, "failed to insert block") + + return block +} diff --git a/wallet/internal/db/itest/fixtures_test.go b/wallet/internal/db/itest/fixtures_test.go deleted file mode 100644 index f1b146d71f..0000000000 --- a/wallet/internal/db/itest/fixtures_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package itest - -import ( - "crypto/rand" - "database/sql" - "fmt" - "testing" - "time" - - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcwallet/wallet/internal/db" - "github.com/stretchr/testify/require" -) - -// CreateWalletParamsFixture creates test parameters for wallet creation. -func CreateWalletParamsFixture(name string) db.CreateWalletParams { - return db.CreateWalletParams{ - Name: name, - IsImported: false, - ManagerVersion: 1, - IsWatchOnly: false, - EncryptedMasterPrivKey: RandomBytes(32), - EncryptedMasterPubKey: RandomBytes(32), - MasterKeyPubParams: RandomBytes(16), - MasterKeyPrivParams: RandomBytes(16), - EncryptedCryptoPrivKey: RandomBytes(32), - EncryptedCryptoPubKey: RandomBytes(32), - EncryptedCryptoScriptKey: RandomBytes(32), - } -} - -// CreateImportedWalletParams creates test parameters for an imported wallet. -func CreateImportedWalletParams(name string) db.CreateWalletParams { - params := CreateWalletParamsFixture(name) - params.IsImported = true - - return params -} - -// CreateWatchOnlyWalletParams creates test parameters for a watch-only wallet. -func CreateWatchOnlyWalletParams(name string) db.CreateWalletParams { - params := CreateWalletParamsFixture(name) - params.IsWatchOnly = true - params.EncryptedMasterPrivKey = nil - params.MasterKeyPrivParams = nil - params.EncryptedCryptoPrivKey = nil - params.EncryptedCryptoScriptKey = nil - - return params -} - -// CreateBlockFixture inserts a test block into the database and returns it. -func CreateBlockFixture(t *testing.T, dbConn *sql.DB, height uint32) db.Block { - t.Helper() - - hash := RandomHash() - timestamp := time.Now().UTC() - - // TODO(gustavostingelin): use the block store to insert the block when - // available. - query := ` - INSERT INTO blocks (block_height, header_hash, block_timestamp) - VALUES ($1, $2, $3) - ` - _, err := dbConn.ExecContext(t.Context(), query, - height, hash[:], timestamp.Unix()) - require.NoError(t, err, "failed to insert block") - - return db.Block{ - Hash: hash, - Height: height, - Timestamp: timestamp, - } -} - -// RandomBytes generates random bytes for test data. -func RandomBytes(n int) []byte { - b := make([]byte, n) - - _, err := rand.Read(b) - if err != nil { - // This should never happen. - panic(fmt.Sprintf("failed to generate random bytes: %v", err)) - } - - return b -} - -// RandomHash generates a random chainhash.Hash for testing. -func RandomHash() chainhash.Hash { - var h chainhash.Hash - - _, err := rand.Read(h[:]) - if err != nil { - // This should never happen. - panic(fmt.Sprintf("failed to generate random hash: %v", err)) - } - - return h -} diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 1d6f6f2710..e93c96a87d 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" _ "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -181,7 +182,7 @@ func NewPostgresDB(t *testing.T) *sql.DB { // NewTestStore creates a PostgreSQL wallet store and returns it along with the // underlying database connection for tests that also need direct DB access. -func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sql.DB) { +func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { t.Helper() dbConn := NewPostgresDB(t) @@ -189,5 +190,7 @@ func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sql.DB) { store, err := db.NewPostgresWalletDB(dbConn) require.NoError(t, err, "failed to create wallet store") - return store, dbConn + queries := sqlcpg.New(dbConn) + + return store, queries } diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 30b6c648a2..4f29aff1c7 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) @@ -40,7 +41,7 @@ func NewSQLiteDB(t *testing.T) *sql.DB { // NewTestStore creates the SQLite wallet store and returns it along with the // underlying database connection for tests that also need direct DB access. -func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sql.DB) { +func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { t.Helper() dbConn := NewSQLiteDB(t) @@ -48,5 +49,7 @@ func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sql.DB) { store, err := db.NewSQLiteWalletDB(dbConn) require.NoError(t, err, "failed to create wallet store") - return store, dbConn + queries := sqlcsqlite.New(dbConn) + + return store, queries } diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index a311fc2997..5408b66bc5 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -181,13 +181,13 @@ func TestListWallets(t *testing.T) { func TestUpdateWallet_SyncedTo(t *testing.T) { t.Parallel() - store, dbConn := NewTestStore(t) + store, queries := NewTestStore(t) params := CreateWalletParamsFixture("update-sync-wallet") created, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) - block := CreateBlockFixture(t, dbConn, 100) + block := CreateBlockFixture(t, queries, 100) updateParams := db.UpdateWalletParams{ WalletID: created.ID, @@ -215,7 +215,7 @@ func TestUpdateWallet_SyncedTo(t *testing.T) { func TestUpdateWallet_BirthdayBlock(t *testing.T) { t.Parallel() - store, dbConn := NewTestStore(t) + store, queries := NewTestStore(t) params := CreateWalletParamsFixture("update-birthday-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -224,7 +224,7 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { // Initially, BirthdayBlock should be nil. require.Nil(t, created.BirthdayBlock) - block := CreateBlockFixture(t, dbConn, 50) + block := CreateBlockFixture(t, queries, 50) updateParams := db.UpdateWalletParams{ WalletID: created.ID, From 246d6ea008f2adefe765e9c1242d6a29a558150c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 30 Dec 2025 11:45:22 -0300 Subject: [PATCH 367/691] wallet: add AccountStore tests --- .../internal/db/itest/account_store_test.go | 804 ++++++++++++++++++ wallet/internal/db/itest/sqlite_test.go | 11 + 2 files changed, 815 insertions(+) create mode 100644 wallet/internal/db/itest/account_store_test.go diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go new file mode 100644 index 0000000000..c16263716c --- /dev/null +++ b/wallet/internal/db/itest/account_store_test.go @@ -0,0 +1,804 @@ +//go:build itest + +package itest + +import ( + "context" + "slices" + "sort" + "strconv" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateAccounts verifies that Create**Account correctly creates accounts +// across all standard key scopes between multiple wallets. +func TestCreateAccounts(t *testing.T) { + t.Parallel() + store, _ := NewTestStore(t) + + // Create 3 wallets to ensure wallet_id scoping works. + for i := range 3 { + walletID := newWallet(t, store, "wallet-"+strconv.Itoa(i)) + + for _, tc := range DerivedAccountCases { + params := tc.DerivedParams(walletID) + info, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, info) + requireAccountMatches(t, info, tc) + } + + for _, tc := range ImportedAccountCases { + params := tc.ImportedParams(walletID) + props, err := store.CreateImportedAccount(t.Context(), params) + require.NoError(t, err) + require.NotNil(t, props) + requireAccountPropertiesMatches(t, props, tc) + require.NotEmpty(t, props.EncryptedPublicKey) + } + } +} + +// TestCreateDerivedAccountErrors verifies that CreateDerivedAccount returns +// appropriate errors for invalid inputs. +func TestCreateDerivedAccountErrors(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-create-derived-account-errors") + + tests := []struct { + name string + params db.CreateDerivedAccountParams + wantErr error + }{ + { + name: "missing name", + params: db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + Name: "", + }, + wantErr: db.ErrMissingAccountName, + }, + { + name: "unknown scope", + params: db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScope{Purpose: 999, Coin: 999}, + Name: "unknown-scope-account", + }, + wantErr: db.ErrUnknownKeyScope, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + info, err := store.CreateDerivedAccount(t.Context(), tc.params) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, info) + }) + } +} + +// TestCreateDerivedAccountDuplicateName verifies that creating a derived +// account with a duplicate name in the same scope fails. +func TestCreateDerivedAccountDuplicateName(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "duplicate-name-wallet") + + params := db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + Name: "duplicate-account", + } + + _, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + + // Attempt to create second account with same name in same scope. + _, err = store.CreateDerivedAccount(t.Context(), params) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + +// TestCreateDerivedAccountSameNameDifferentScopes verifies that accounts with +// the same name can exist in different scopes. +func TestCreateDerivedAccountSameNameDifferentScopes(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "multi-scope-wallet") + + accountName := "shared-name" + scopes := []db.KeyScope{ + db.KeyScopeBIP0084, + db.KeyScopeBIP0086, + db.KeyScopeBIP0044, + db.KeyScopeBIP0049Plus, + } + + for _, scope := range scopes { + params := db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: accountName, + } + + info, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + require.Equal(t, accountName, info.AccountName) + require.Equal(t, scope, info.KeyScope) + } +} + +// TestCreateDerivedAccountSequentialNumbers verifies that derived accounts +// within the same scope receive sequential account numbers starting from 0. +func TestCreateDerivedAccountSequentialNumbers(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "sequential-wallet") + + scope := db.KeyScopeBIP0084 + const numAccounts = 5 + + for i := range numAccounts { + params := db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: "account-" + strconv.Itoa(i), + } + + info, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + require.Equal(t, uint32(i), info.AccountNumber, + "account %d should have number %d", i, i) + } +} + +// TestCreateDerivedAccountConcurrent verifies that concurrent account creation +// yields unique, sequential account numbers without errors. +func TestCreateDerivedAccountConcurrent(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "concurrent-wallet") + + scope := db.KeyScopeBIP0084 + + const workers = 20 + results := make([]uint32, workers) + var wg sync.WaitGroup + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + for i := range workers { + wg.Add(1) + go func(i int) { + defer wg.Done() + info, err := store.CreateDerivedAccount( + ctx, db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: "acct-concurrent-" + strconv.Itoa(i), + }, + ) + require.NoError(t, err) + results[i] = info.AccountNumber + }(i) + } + + wg.Wait() + + // Verify all numbers are unique and sequential. + sort.Slice(results, func(i, j int) bool { + return results[i] < results[j] + }) + for i := range workers { + require.Equal(t, uint32(i), results[i]) + } +} + +// TestCreateImportedAccountErrors verifies that CreateImportedAccount returns +// appropriate errors for invalid inputs. +func TestCreateImportedAccountErrors(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-create-imported-account-errors") + + tests := []struct { + name string + params db.CreateImportedAccountParams + wantErr error + }{ + { + name: "missing name", + params: db.CreateImportedAccountParams{ + WalletID: walletID, + Name: "", + Scope: db.KeyScopeBIP0084, + EncryptedPublicKey: RandomBytes(32), + }, + wantErr: db.ErrMissingAccountName, + }, + { + name: "missing public key", + params: db.CreateImportedAccountParams{ + WalletID: walletID, + Name: "missing-pubkey", + Scope: db.KeyScopeBIP0084, + EncryptedPublicKey: nil, + }, + wantErr: db.ErrMissingAccountPublicKey, + }, + { + name: "unknown scope", + params: db.CreateImportedAccountParams{ + WalletID: walletID, + Name: "unknown-scope", + Scope: db.KeyScope{Purpose: 999, Coin: 999}, + EncryptedPublicKey: RandomBytes(32), + }, + wantErr: db.ErrUnknownKeyScope, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + props, err := store.CreateImportedAccount(t.Context(), tc.params) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, props) + }) + } +} + +// TestCreateImportedAccountDuplicateName verifies that creating an imported +// account with a duplicate name in the same scope fails. +func TestCreateImportedAccountDuplicateName(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "imported-duplicate-name-wallet") + + params := db.CreateImportedAccountParams{ + WalletID: walletID, + Name: "duplicate-imported", + Scope: db.KeyScopeBIP0084, + EncryptedPublicKey: RandomBytes(32), + } + + _, err := store.CreateImportedAccount(t.Context(), params) + require.NoError(t, err) + + // Attempt to create second imported account with same name in same + // scope. + params.EncryptedPublicKey = RandomBytes(32) + _, err = store.CreateImportedAccount(t.Context(), params) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + +// TestGetAccount verifies that GetAccount correctly retrieves accounts +// by name or account number. +func TestGetAccount(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-get-account") + + createAllAccounts(t, store, walletID) + + for _, tc := range AllAccountCases { + accNumber := uint32(0) + + t.Run("by name-"+tc.Name, func(t *testing.T) { + query := getAccountQueryByName(walletID, tc.Scope, tc.Name) + info, err := store.GetAccount(t.Context(), query) + require.NoError(t, err) + require.NotNil(t, info) + requireAccountMatches(t, info, tc) + accNumber = info.AccountNumber + }) + + if tc.Origin == db.ImportedAccount { + continue + } + + t.Run(fmt.Sprintf("by number-%d-%s", accNumber, tc.Name), + func(t *testing.T) { + + query := getAccountQueryByNumber(walletID, tc.Scope, accNumber) + info, err := store.GetAccount(t.Context(), query) + require.NoError(t, err) + require.NotNil(t, info) + requireAccountMatches(t, info, tc) + }) + } +} + +// TestGetAccountNotFound verifies that GetAccount returns ErrAccountNotFound +// when querying a non-existent account. +func TestGetAccountNotFound(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-get-account-not-found") + + createAllAccounts(t, store, walletID) + + scope := db.KeyScopeBIP0084 + + t.Run("by name", func(t *testing.T) { + t.Parallel() + + query := getAccountQueryByName(walletID, scope, "non-existent") + info, err := store.GetAccount(t.Context(), query) + require.ErrorIs(t, err, db.ErrAccountNotFound) + require.Nil(t, info) + }) + + t.Run("by number", func(t *testing.T) { + t.Parallel() + + query := getAccountQueryByNumber(walletID, scope, 99999) + info, err := store.GetAccount(t.Context(), query) + require.ErrorIs(t, err, db.ErrAccountNotFound) + require.Nil(t, info) + }) +} + +// TestListAccounts verifies that ListAccounts returns accounts for a +// wallet with various filters. +func TestListAccounts(t *testing.T) { + t.Parallel() + + // Ensure that has at least 3 accounts to be tested. + require.GreaterOrEqual(t, len(AllAccountCases), 3) + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-list-accounts") + + createAllAccounts(t, store, walletID) + + t.Run("all accounts", func(t *testing.T) { + t.Parallel() + + query := db.ListAccountsQuery{WalletID: walletID} + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + require.Len(t, accounts, len(AllAccountCases)) + for _, tc := range AllAccountCases { + acc := findAccountInList(t, accounts, tc) + requireAccountMatches(t, &acc, tc) + } + }) + + t.Run("filter by scope", func(t *testing.T) { + t.Parallel() + + scope := db.KeyScopeBIP0084 + query := db.ListAccountsQuery{ + WalletID: walletID, + Scope: &scope, + } + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + + cases := FilterAccountsByScope(scope) + + require.Len(t, accounts, len(cases)) + for _, tc := range cases { + acc := findAccountInList(t, accounts, tc) + requireAccountMatches(t, &acc, tc) + } + }) + + t.Run("filter by name", func(t *testing.T) { + t.Parallel() + + // Ensure that has at least 3 derived accounts to be tested. + require.GreaterOrEqual(t, len(DerivedAccountCases), 3) + + // Pick an acc that exists in our fixtures. + tc := DerivedAccountCases[1] + query := db.ListAccountsQuery{ + WalletID: walletID, + Name: &tc.Name, + } + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + require.Len(t, accounts, 1) + requireAccountMatches(t, &accounts[0], tc) + }) + + t.Run("empty result", func(t *testing.T) { + t.Parallel() + + // Create a new wallet with no accounts. + emptyWalletID := newWallet(t, store, "wallet-list-empty") + query := db.ListAccountsQuery{WalletID: emptyWalletID} + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + require.Empty(t, accounts) + }) +} + +// TestListAccountsOrdering verifies that ListAccounts returns derived accounts +// ordered by account number, with imported accounts (NULL account_number) last. +func TestListAccountsOrdering(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-list-ordering") + + scope := db.KeyScopeBIP0084 + + // Create accounts in mixed order: imported, derived, imported, derived. + createImportedAccount(t, store, walletID, scope, "imported-first") + createDerivedAccount(t, store, walletID, scope, "derived-0") + createImportedAccount(t, store, walletID, scope, "imported-second") + createDerivedAccount(t, store, walletID, scope, "derived-1") + + query := db.ListAccountsQuery{ + WalletID: walletID, + Scope: &scope, + } + accounts, err := store.ListAccounts(t.Context(), query) + require.NoError(t, err) + require.Len(t, accounts, 4) + + // Derived accounts should come first (ordered by account number). + require.Equal(t, "derived-0", accounts[0].AccountName) + require.Equal(t, db.DerivedAccount, accounts[0].Origin) + require.Equal(t, uint32(0), accounts[0].AccountNumber) + + require.Equal(t, "derived-1", accounts[1].AccountName) + require.Equal(t, db.DerivedAccount, accounts[1].Origin) + require.Equal(t, uint32(1), accounts[1].AccountNumber) + + // Imported accounts should come last. + require.Equal(t, db.ImportedAccount, accounts[2].Origin) + require.Equal(t, db.ImportedAccount, accounts[3].Origin) +} + +// TestAccountCreatedAtTimestamp verifies that accounts have their CreatedAt +// field properly set and that it reflects the order of account creation. +func TestAccountCreatedAtTimestamp(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-created-at") + + scope := db.KeyScopeBIP0084 + + // Create three accounts with slight delays to ensure different + // timestamps. + var accounts []db.AccountInfo + for i := range 3 { + time.Sleep(1 * time.Second) + params := db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: fmt.Sprintf("account-%d", i), + } + info, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + accounts = append(accounts, *info) + } + + // Verify all accounts have CreatedAt populated. + for i, acc := range accounts { + require.False(t, acc.CreatedAt.IsZero(), + "account %d should have CreatedAt set", i) + require.WithinDuration(t, time.Now(), acc.CreatedAt, 5*time.Second, + "account %d CreatedAt should be recent", i) + } + + // Verify accounts are ordered by creation time. + require.True(t, accounts[0].CreatedAt.Before(accounts[1].CreatedAt), + "account 0 should have CreatedAt before account 1") + require.True(t, accounts[1].CreatedAt.Before(accounts[2].CreatedAt), + "account 1 should have CreatedAt before account 2") +} + +// TestRenameAccount verifies that RenameAccount successfully renames accounts +// by name and by account number. +func TestRenameAccount(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-rename-account") + + scope := db.KeyScopeBIP0084 + + // Create two accounts to rename. First account should get number 0. + createDerivedAccount(t, store, walletID, scope, "original-name-1") + createDerivedAccount(t, store, walletID, scope, "original-name-2") + + t.Run("rename by name", func(t *testing.T) { + oldName := "original-name-1" + newName := "renamed-by-name" + + err := store.RenameAccount(t.Context(), db.RenameAccountParams{ + WalletID: walletID, + Scope: scope, + OldName: oldName, + NewName: newName, + }) + require.NoError(t, err) + + // Verify the rename worked. + query := getAccountQueryByName(walletID, scope, newName) + info, err := store.GetAccount(t.Context(), query) + require.NoError(t, err) + require.Equal(t, newName, info.AccountName) + require.Equal(t, uint32(0), info.AccountNumber) + + // Verify the old name no longer exists. + oldQuery := getAccountQueryByName(walletID, scope, oldName) + _, err = store.GetAccount(t.Context(), oldQuery) + require.ErrorIs(t, err, db.ErrAccountNotFound) + }) + + t.Run("rename by number", func(t *testing.T) { + // First derived account has number 0. + accNum := uint32(0) + newName := "renamed-by-number" + + err := store.RenameAccount(t.Context(), db.RenameAccountParams{ + WalletID: walletID, + Scope: scope, + AccountNumber: &accNum, + NewName: newName, + }) + require.NoError(t, err) + + // Verify the rename worked. + query := getAccountQueryByNumber(walletID, scope, accNum) + info, err := store.GetAccount(t.Context(), query) + require.NoError(t, err) + require.Equal(t, newName, info.AccountName) + }) +} + +// TestRenameAccountErrors verifies that RenameAccount returns appropriate +// errors for invalid inputs and missing accounts. +func TestRenameAccountErrors(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + + walletID := newWallet(t, store, "wallet-rename-account-errors") + + createAllAccounts(t, store, walletID) + + nonExistentName := "nonexistent" + nonExistentNum := uint32(99999) + + tests := []struct { + name string + params db.RenameAccountParams + wantErr error + }{ + { + name: "not found", + params: db.RenameAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + OldName: nonExistentName, + NewName: "new-name", + }, + wantErr: db.ErrAccountNotFound, + }, + { + name: "invalid - both set", + params: db.RenameAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + OldName: nonExistentName, + AccountNumber: &nonExistentNum, + NewName: "new-name", + }, + wantErr: db.ErrInvalidAccountQuery, + }, + { + name: "invalid - neither set", + params: db.RenameAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + NewName: "new-name", + }, + wantErr: db.ErrInvalidAccountQuery, + }, + { + name: "invalid - empty new name", + params: db.RenameAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + OldName: nonExistentName, + NewName: "", + }, + wantErr: db.ErrMissingAccountName, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := store.RenameAccount(t.Context(), tc.params) + require.ErrorIs(t, err, tc.wantErr) + }) + } +} + +func newWallet(t *testing.T, store db.WalletStore, name string) uint32 { + t.Helper() + + walletParams := CreateWalletParamsFixture(name) + walletInfo, err := store.CreateWallet(t.Context(), walletParams) + require.NoError(t, err) + + return walletInfo.ID +} + +// createAllAccounts creates all accounts from AllAccountCases for the given +// wallet ID using the provided account store. +func createAllAccounts(t *testing.T, store db.AccountStore, walletID uint32) { + t.Helper() + + for _, tc := range AllAccountCases { + switch tc.Origin { + case db.DerivedAccount: + params := tc.DerivedParams(walletID) + _, err := store.CreateDerivedAccount(t.Context(), params) + require.NoError(t, err) + + case db.ImportedAccount: + params := tc.ImportedParams(walletID) + _, err := store.CreateImportedAccount(t.Context(), params) + require.NoError(t, err) + } + } +} + +// getAccountQueryByName creates a GetAccountQuery for looking up an account +// by name within a specific wallet and scope. +func getAccountQueryByName(walletID uint32, scope db.KeyScope, + name string) db.GetAccountQuery { + + return db.GetAccountQuery{ + WalletID: walletID, + Scope: scope, + Name: &name, + } +} + +// getAccountQueryByNumber creates a GetAccountQuery for looking up an +// account by account number within a specific wallet and scope. +func getAccountQueryByNumber(walletID uint32, scope db.KeyScope, + num uint32) db.GetAccountQuery { + + return db.GetAccountQuery{ + WalletID: walletID, + Scope: scope, + AccountNumber: &num, + } +} + +// createDerivedAccount creates a new derived account with the given name, +// scope, and wallet ID using the provided account store. +func createDerivedAccount(t *testing.T, store db.AccountStore, walletID uint32, + scope db.KeyScope, name string) { + + t.Helper() + + _, err := store.CreateDerivedAccount( + t.Context(), db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: name, + }, + ) + require.NoError(t, err) +} + +// createImportedAccount creates a new imported account with the given name, +// scope, and wallet ID using the provided account store. A random public key +// is generated for the account. +func createImportedAccount(t *testing.T, store db.AccountStore, walletID uint32, + scope db.KeyScope, name string) { + + t.Helper() + + _, err := store.CreateImportedAccount( + t.Context(), db.CreateImportedAccountParams{ + WalletID: walletID, + Name: name, + Scope: scope, + EncryptedPublicKey: RandomBytes(32), + }, + ) + require.NoError(t, err) +} + +// requireAccountMatches asserts that the provided AccountInfo matches the +// expected AccountTestCase, including name, scope, origin, watch-only status, +// and creation timestamp. +func requireAccountMatches(t *testing.T, info *db.AccountInfo, + tc AccountTestCase) { + + t.Helper() + + require.Equal(t, tc.Name, info.AccountName) + require.Equal(t, tc.Scope, info.KeyScope) + require.Equal(t, tc.Origin, info.Origin) + require.Equal(t, tc.IsWatchOnly, info.IsWatchOnly) + + // Verify CreatedAt is populated and recent. + require.False(t, info.CreatedAt.IsZero(), "CreatedAt should be set") + require.WithinDuration(t, time.Now(), info.CreatedAt, 5*time.Second, + "CreatedAt should be recent") +} + +// requireAccountPropertiesMatches asserts that the provided AccountProperties +// matches the expected AccountTestCase, including name, scope, origin, +// watch-only status, and creation timestamp. +func requireAccountPropertiesMatches(t *testing.T, props *db.AccountProperties, + tc AccountTestCase) { + + t.Helper() + + require.Equal(t, tc.Name, props.AccountName) + require.Equal(t, tc.Scope, props.KeyScope) + require.Equal(t, tc.Origin, props.Origin) + require.Equal(t, tc.IsWatchOnly, props.IsWatchOnly) + + // Verify CreatedAt is populated and recent. + require.False(t, props.CreatedAt.IsZero(), "CreatedAt should be set") + require.WithinDuration(t, time.Now(), props.CreatedAt, 5*time.Second, + "CreatedAt should be recent") +} + +// findAccountInList searches for an account in the provided list that matches +// the expected AccountTestCase by name and scope. It fails the test if the +// account is not found. +func findAccountInList(t *testing.T, accounts []db.AccountInfo, + tc AccountTestCase) db.AccountInfo { + + t.Helper() + + i := slices.IndexFunc(accounts, func(acc db.AccountInfo) bool { + return acc.AccountName == tc.Name && acc.KeyScope == tc.Scope + }) + require.GreaterOrEqual(t, i, 0, "expected account %s in list", tc.Name) + + return accounts[i] +} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 4f29aff1c7..666b32c444 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -24,6 +24,17 @@ func NewSQLiteDB(t *testing.T) *sql.DB { // Enable foreign keys (required for proper constraint enforcement). dsn := dbPath + "?_pragma=foreign_keys=on" + // Enable WAL mode for better concurrency. WAL allows multiple readers and + // reduces lock contention for concurrent writers. + dsn = dsn + "&_pragma=journal_mode=WAL" + + // Enable immediate transaction locking to avoid races. + dsn = dsn + "&_txlock=immediate" + + // Set busy timeout to 5 seconds. This makes SQLite retry acquiring locks + // instead of immediately returning SQLITE_BUSY errors. + dsn = dsn + "&_pragma=busy_timeout=5000" + // TODO(gustavostingelin): replace with the real SQLite database // connection constructor when available. dbConn, err := sql.Open("sqlite", dsn) From b381cb4f36480a939a9e5d4eb7715da41454ee85 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 2 Jan 2026 15:07:12 -0300 Subject: [PATCH 368/691] wallet: add derive max account number test --- .../internal/db/itest/account_store_test.go | 39 +++++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 32 +++++++++++++++ .../internal/db/itest/fixtures_sqlite_test.go | 32 +++++++++++++++ .../db/queries/postgres/key_scopes.sql | 7 ++++ .../internal/db/queries/sqlite/key_scopes.sql | 7 ++++ wallet/internal/db/sqlc/postgres/db.go | 10 +++++ .../db/sqlc/postgres/key_scopes.sql.go | 18 +++++++++ wallet/internal/db/sqlc/postgres/querier.go | 3 ++ wallet/internal/db/sqlc/sqlite/db.go | 10 +++++ .../internal/db/sqlc/sqlite/key_scopes.sql.go | 18 +++++++++ wallet/internal/db/sqlc/sqlite/querier.go | 3 ++ 11 files changed, 179 insertions(+) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index c16263716c..3931223800 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -4,6 +4,8 @@ package itest import ( "context" + "fmt" + "math" "slices" "sort" "strconv" @@ -659,6 +661,43 @@ func TestRenameAccountErrors(t *testing.T) { } } +// TestCreateDerivedAccountMaxAccountNumber verifies that CreateDerivedAccount +// returns ErrMaxAccountNumberReached when the account number counter exceeds +// the maximum uint32 value. +func TestCreateDerivedAccountMaxAccountNumber(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-max-account") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "account-0") + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + SetLastAccountNumber(t, queries, scopeID, math.MaxUint32-1) + + // This should succeed with account_number = MaxUint32. + info, err := store.CreateDerivedAccount( + t.Context(), db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + Name: "account-max", + }, + ) + require.NoError(t, err) + require.Equal(t, uint32(math.MaxUint32), info.AccountNumber) + + // This should fail; the next allocation would be MaxUint32 + 1, which + // overflows uint32. + _, err = store.CreateDerivedAccount( + t.Context(), db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + Name: "account-overflow", + }, + ) + require.ErrorIs(t, err, db.ErrMaxAccountNumberReached) +} + +// newWallet creates a new wallet with the given name using the provided +// store and returns its ID. func newWallet(t *testing.T, store db.WalletStore, name string) uint32 { t.Helper() diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 02c54504c3..87662523da 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -27,3 +27,35 @@ func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, return block } + +// SetLastAccountNumber sets the last_account_number for a key scope. +// Used to test account number overflow without creating billions of accounts. +func SetLastAccountNumber(t *testing.T, queries *sqlcpg.Queries, + scopeID int64, lastAccountNumber int64) { + t.Helper() + + err := queries.SetLastAccountNumber( + t.Context(), sqlcpg.SetLastAccountNumberParams{ + LastAccountNumber: lastAccountNumber, + ID: scopeID, + }, + ) + require.NoError(t, err) +} + +// GetKeyScopeID retrieves the scope ID for a given wallet and key scope. +func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, + walletID uint32, scope db.KeyScope) int64 { + t.Helper() + + row, err := queries.GetKeyScopeByWalletAndScope( + t.Context(), sqlcpg.GetKeyScopeByWalletAndScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + }, + ) + require.NoError(t, err) + + return row.ID +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 3881ad6328..92e6bc1812 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -27,3 +27,35 @@ func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, return block } + +// SetLastAccountNumber sets the last_account_number for a key scope. +// Used to test account number overflow without creating billions of accounts. +func SetLastAccountNumber(t *testing.T, queries *sqlcsqlite.Queries, + scopeID int64, lastAccountNumber int64) { + t.Helper() + + err := queries.SetLastAccountNumber( + t.Context(), sqlcsqlite.SetLastAccountNumberParams{ + LastAccountNumber: lastAccountNumber, + ID: scopeID, + }, + ) + require.NoError(t, err) +} + +// GetKeyScopeID retrieves the scope ID for a given wallet and key scope. +func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, + walletID uint32, scope db.KeyScope) int64 { + t.Helper() + + row, err := queries.GetKeyScopeByWalletAndScope( + t.Context(), sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ + WalletID: int64(walletID), + Purpose: int64(scope.Purpose), + CoinType: int64(scope.Coin), + }, + ) + require.NoError(t, err) + + return row.ID +} diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/db/queries/postgres/key_scopes.sql index 5b9d31fb01..7a3200035a 100644 --- a/wallet/internal/db/queries/postgres/key_scopes.sql +++ b/wallet/internal/db/queries/postgres/key_scopes.sql @@ -80,3 +80,10 @@ WHERE scope_id = $1; -- Deletes a key scope by its ID. DELETE FROM key_scopes WHERE id = $1; + +-- name: SetLastAccountNumber :exec +-- Sets the last_account_number for a key scope. This is intended for testing +-- the account number overflow behavior without creating billions of accounts. +UPDATE key_scopes +SET last_account_number = $1 +WHERE id = $2; diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/db/queries/sqlite/key_scopes.sql index 1635336b66..17edfe779e 100644 --- a/wallet/internal/db/queries/sqlite/key_scopes.sql +++ b/wallet/internal/db/queries/sqlite/key_scopes.sql @@ -91,3 +91,10 @@ WHERE scope_id = ?; -- Deletes a key scope by its ID. DELETE FROM key_scopes WHERE id = ?; + +-- name: SetLastAccountNumber :exec +-- Sets the last_account_number for a key scope. This is intended for testing +-- the account number overflow behavior without creating billions of accounts. +UPDATE key_scopes +SET last_account_number = ? +WHERE id = ?; diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 22925ed29e..eecbb41552 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -120,6 +120,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.setLastAccountNumberStmt, err = db.PrepareContext(ctx, SetLastAccountNumber); err != nil { + return nil, fmt.Errorf("error preparing query SetLastAccountNumber: %w", err) + } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } @@ -297,6 +300,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.setLastAccountNumberStmt != nil { + if cerr := q.setLastAccountNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing setLastAccountNumberStmt: %w", cerr) + } + } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -388,6 +396,7 @@ type Queries struct { listAddressTypesStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt + setLastAccountNumberStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt @@ -430,6 +439,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAddressTypesStmt: q.listAddressTypesStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, + setLastAccountNumberStmt: q.setLastAccountNumberStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go index a7e3e5968f..60dfb27b24 100644 --- a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -252,3 +252,21 @@ func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([] } return items, nil } + +const SetLastAccountNumber = `-- name: SetLastAccountNumber :exec +UPDATE key_scopes +SET last_account_number = $1 +WHERE id = $2 +` + +type SetLastAccountNumberParams struct { + LastAccountNumber int64 + ID int64 +} + +// Sets the last_account_number for a key scope. This is intended for testing +// the account number overflow behavior without creating billions of accounts. +func (q *Queries) SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error { + _, err := q.exec(ctx, q.setLastAccountNumberStmt, SetLastAccountNumber, arg.LastAccountNumber, arg.ID) + return err +} diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 41471588e8..233c578e7a 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -73,6 +73,9 @@ type Querier interface { // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Sets the last_account_number for a key scope. This is intended for testing + // the account number overflow behavior without creating billions of accounts. + SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 789f34aaa0..a202663a0e 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -123,6 +123,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.setLastAccountNumberStmt, err = db.PrepareContext(ctx, SetLastAccountNumber); err != nil { + return nil, fmt.Errorf("error preparing query SetLastAccountNumber: %w", err) + } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } @@ -305,6 +308,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.setLastAccountNumberStmt != nil { + if cerr := q.setLastAccountNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing setLastAccountNumberStmt: %w", cerr) + } + } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -397,6 +405,7 @@ type Queries struct { listAddressTypesStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt + setLastAccountNumberStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt @@ -440,6 +449,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAddressTypesStmt: q.listAddressTypesStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, + setLastAccountNumberStmt: q.setLastAccountNumberStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go index e464c5ce8d..df22bbd344 100644 --- a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -276,3 +276,21 @@ func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([] } return items, nil } + +const SetLastAccountNumber = `-- name: SetLastAccountNumber :exec +UPDATE key_scopes +SET last_account_number = ? +WHERE id = ? +` + +type SetLastAccountNumberParams struct { + LastAccountNumber int64 + ID int64 +} + +// Sets the last_account_number for a key scope. This is intended for testing +// the account number overflow behavior without creating billions of accounts. +func (q *Queries) SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error { + _, err := q.exec(ctx, q.setLastAccountNumberStmt, SetLastAccountNumber, arg.LastAccountNumber, arg.ID) + return err +} diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index b05c8a8ac9..9d6877b17b 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -86,6 +86,9 @@ type Querier interface { // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Sets the last_account_number for a key scope. This is intended for testing + // the account number overflow behavior without creating billions of accounts. + SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. From 959ee9c09d1dc34f1b3027b1af9165cdd86ea0d1 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 19 Jan 2026 00:29:53 -0300 Subject: [PATCH 369/691] wallet: replace last_account_number counter with dynamic MAX computation --- wallet/internal/db/accounts_pg.go | 14 ++- wallet/internal/db/accounts_sqlite.go | 45 ++----- .../internal/db/itest/account_store_test.go | 2 +- wallet/internal/db/itest/fixtures_pg_test.go | 18 +-- .../internal/db/itest/fixtures_sqlite_test.go | 18 +-- .../postgres/000004_key_scopes.up.sql | 8 -- .../sqlite/000004_key_scopes.up.sql | 8 -- .../internal/db/queries/postgres/accounts.sql | 75 ++++++++---- .../db/queries/postgres/key_scopes.sql | 7 -- .../internal/db/queries/sqlite/accounts.sql | 34 ++++-- .../internal/db/queries/sqlite/key_scopes.sql | 18 --- .../internal/db/sqlc/postgres/accounts.sql.go | 112 ++++++++++++++---- wallet/internal/db/sqlc/postgres/db.go | 24 ++-- .../db/sqlc/postgres/key_scopes.sql.go | 62 ++-------- wallet/internal/db/sqlc/postgres/models.go | 1 - wallet/internal/db/sqlc/postgres/querier.go | 44 +++++-- .../internal/db/sqlc/sqlite/accounts.sql.go | 64 +++++++--- wallet/internal/db/sqlc/sqlite/db.go | 30 ++--- .../internal/db/sqlc/sqlite/key_scopes.sql.go | 86 ++------------ wallet/internal/db/sqlc/sqlite/models.go | 1 - wallet/internal/db/sqlc/sqlite/querier.go | 32 ++--- 21 files changed, 342 insertions(+), 361 deletions(-) diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index fd9f3dfcf0..b8d6e17ff8 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -66,9 +66,19 @@ func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, return err } + // Acquire an advisory lock for this scope to serialize account creation + // and prevent race conditions when computing MAX(account_number). This + // MUST be a separate statement that completes before + // qtx.CreateDerivedAccount runs. See the LockAccountScope comments for + // why single-statement approaches don't work. + err = qtx.LockAccountScope(ctx, scopeID) + if err != nil { + return fmt.Errorf("lock account scope: %w", err) + } + row, err := qtx.CreateDerivedAccount( ctx, sqlcpg.CreateDerivedAccountParams{ - ID: scopeID, + ScopeID: scopeID, AccountName: params.Name, OriginID: int16(DerivedAccount), IsWatchOnly: false, @@ -226,7 +236,7 @@ func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, ), } }, - func(row sqlcpg.GetKeyScopeByWalletAndScopeRow) int64 { + func(row sqlcpg.KeyScope) int64 { return row.ID }, scope, ) diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index c099ac47ee..94f665c1a0 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -66,8 +66,13 @@ func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, return err } - row, err := sqliteAllocateAndCreateAccount( - ctx, qtx, scopeID, params.Name, + row, err := qtx.CreateDerivedAccount( + ctx, sqlcsqlite.CreateDerivedAccountParams{ + ScopeID: scopeID, + AccountName: params.Name, + OriginID: int64(DerivedAccount), + IsWatchOnly: false, + }, ) if err != nil { return fmt.Errorf("create account: %w", err) @@ -124,44 +129,10 @@ func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, ), } }, - func(row sqlcsqlite.GetKeyScopeByWalletAndScopeRow) int64 { - return row.ID - }, scope, + func(row sqlcsqlite.KeyScope) int64 { return row.ID }, scope, ) } -// sqliteAllocateAndCreateAccount allocates a new sequential account number and -// creates a derived account in a single atomic operation. SQLite requires a -// two-step process because it lacks PostgreSQL's UPDATE ... RETURNING clause. -func sqliteAllocateAndCreateAccount(ctx context.Context, - qtx *sqlcsqlite.Queries, scopeID int64, - accountName string) (sqlcsqlite.CreateDerivedAccountRow, error) { - - allocated, err := qtx.AllocateAccountNumber(ctx, scopeID) - if err != nil { - return sqlcsqlite.CreateDerivedAccountRow{}, - fmt.Errorf("allocate account number: %w", err) - } - - row, err := qtx.CreateDerivedAccount(ctx, - sqlcsqlite.CreateDerivedAccountParams{ - ScopeID: scopeID, - AccountNumber: sql.NullInt64{ - Int64: allocated.LastAccountNumber, - Valid: true, - }, - AccountName: accountName, - OriginID: int64(DerivedAccount), - IsWatchOnly: false, - }) - if err != nil { - return sqlcsqlite.CreateDerivedAccountRow{}, - fmt.Errorf("create account: %w", err) - } - - return row, nil -} - // CreateImportedAccount stores an imported account identified by an extended // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 3931223800..02789da0b8 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -671,7 +671,7 @@ func TestCreateDerivedAccountMaxAccountNumber(t *testing.T) { walletID := newWallet(t, store, "wallet-max-account") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "account-0") scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) - SetLastAccountNumber(t, queries, scopeID, math.MaxUint32-1) + CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, "account-near-max") // This should succeed with account_number = MaxUint32. info, err := store.CreateDerivedAccount( diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 87662523da..6fc07f9446 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -3,6 +3,7 @@ package itest import ( + "database/sql" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -28,16 +29,19 @@ func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, return block } -// SetLastAccountNumber sets the last_account_number for a key scope. +// CreateAccountWithNumber creates an account with a specific account number. // Used to test account number overflow without creating billions of accounts. -func SetLastAccountNumber(t *testing.T, queries *sqlcpg.Queries, - scopeID int64, lastAccountNumber int64) { +func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, + scopeID int64, accountNumber uint32, name string) { t.Helper() - err := queries.SetLastAccountNumber( - t.Context(), sqlcpg.SetLastAccountNumberParams{ - LastAccountNumber: lastAccountNumber, - ID: scopeID, + _, err := queries.CreateDerivedAccountWithNumber( + t.Context(), sqlcpg.CreateDerivedAccountWithNumberParams{ + ScopeID: scopeID, + AccountNumber: sql.NullInt64{Int64: int64(accountNumber), Valid: true}, + AccountName: name, + OriginID: int16(db.DerivedAccount), + IsWatchOnly: false, }, ) require.NoError(t, err) diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 92e6bc1812..3d5dfcf676 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -3,6 +3,7 @@ package itest import ( + "database/sql" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -28,16 +29,19 @@ func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, return block } -// SetLastAccountNumber sets the last_account_number for a key scope. +// CreateAccountWithNumber creates an account with a specific account number. // Used to test account number overflow without creating billions of accounts. -func SetLastAccountNumber(t *testing.T, queries *sqlcsqlite.Queries, - scopeID int64, lastAccountNumber int64) { +func CreateAccountWithNumber(t *testing.T, queries *sqlcsqlite.Queries, + scopeID int64, accountNumber uint32, name string) { t.Helper() - err := queries.SetLastAccountNumber( - t.Context(), sqlcsqlite.SetLastAccountNumberParams{ - LastAccountNumber: lastAccountNumber, - ID: scopeID, + _, err := queries.CreateDerivedAccountWithNumber( + t.Context(), sqlcsqlite.CreateDerivedAccountWithNumberParams{ + ScopeID: scopeID, + AccountNumber: sql.NullInt64{Int64: int64(accountNumber), Valid: true}, + AccountName: name, + OriginID: int64(db.DerivedAccount), + IsWatchOnly: false, }, ) require.NoError(t, err) diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql index 9af8cabd0a..1162ebd008 100644 --- a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql @@ -27,14 +27,6 @@ CREATE TABLE key_scopes ( -- Reference to the address type used for external/receiving addresses. external_type_id SMALLINT NOT NULL, - -- Counter used to allocate sequential account numbers within this scope. - -- This avoids scanning the accounts table to compute MAX(account_number). - -- The value is updated atomically via UPDATE with RETURNING, which allows - -- concurrent account creation without additional locking logic. - -- The counter starts at minus one. Each new account consumes the current - -- value plus one, then stores the updated value for the next allocation. - last_account_number BIGINT NOT NULL DEFAULT -1, - -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if key scopes still exist. FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql index 37432efdfc..702758d4ab 100644 --- a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql @@ -27,14 +27,6 @@ CREATE TABLE key_scopes ( -- Reference to the address type used for external/receiving addresses. external_type_id INTEGER NOT NULL, - -- Counter used to allocate sequential account numbers within this scope. - -- This avoids scanning the accounts table to compute MAX(account_number). - -- The value is updated atomically via UPDATE with RETURNING, which allows - -- concurrent account creation without additional locking logic. - -- The counter starts at minus one. Each new account consumes the current - -- value plus one, then stores the updated value for the next allocation. - last_account_number INTEGER NOT NULL DEFAULT -1, - -- Foreign key constraint to wallet. Using ON DELETE RESTRICT to ensure -- that the wallet cannot be deleted if key scopes still exist. FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index bf5f02ed9f..9538e355f8 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -1,15 +1,35 @@ --- name: CreateDerivedAccount :one --- Creates a new derived account under the given scope, allocating a fresh --- sequential account number from key_scopes.last_account_number. --- The allocation is atomic: the UPDATE takes the row lock on the scope row, --- returns the allocated number, and updates the counter for the next call. -WITH allocated_number AS ( - UPDATE key_scopes - SET last_account_number = last_account_number + 1 - WHERE key_scopes.id = $1 - RETURNING key_scopes.id, last_account_number AS account_number -) +-- name: LockAccountScope :exec +-- Acquires a transaction-level advisory lock to serialize account creation within a scope. +-- The lock is automatically released upon transaction commit or rollback. +-- This MUST be called immediately before 'CreateDerivedAccount' within the same transaction. +-- +-- We explicitly use a two-statement pattern because single-statement CTE/Join +-- approaches failed to prevent race conditions during concurrent account generation. +-- The following "one-query" strategies were tested and proven unreliable: +-- +-- 1. CTE with CROSS/INNER JOIN: The PostgreSQL optimizer may evaluate the +-- MAX(account_number) subquery using a snapshot taken before the lock CTE +-- is fully processed, leading to duplicate account numbers. +-- +-- 2. CTE with OFFSET 0: Designed to force materialization, this still fails to +-- guarantee that the lock is held before the aggregate subquery begins its +-- read operation. +-- +-- 3. FOR UPDATE in Subqueries: Since FOR UPDATE targets existing rows, it fails +-- to "lock the gap" for new inserts or handle empty tables, allowing +-- concurrent processes to calculate identical MAX() values. +-- +-- Using two separate calls ensures the application pauses until +-- LockAccountScope returns, guaranteeing that the subsequent SELECT MAX() +-- operates inside a strictly serialized execution window for that scope. +SELECT pg_advisory_xact_lock(hashtextextended('account_scope', $1::BIGINT)); + +-- name: CreateDerivedAccount :one +-- Creates a new derived account under the given scope, computing the next +-- account number atomically. The caller MUST call LockAccountScope first +-- to acquire the advisory lock and prevent race conditions. +-- See LockAccountScope comments for why this is a separate statement. INSERT INTO accounts ( scope_id, account_number, @@ -19,16 +39,16 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -SELECT - allocated_number.id AS scope_id, - allocated_number.account_number, - $2 AS account_name, - $3 AS origin_id, - $4 AS encrypted_public_key, - $5 AS master_fingerprint, - $6 AS is_watch_only -FROM allocated_number -RETURNING accounts.id, accounts.account_number, accounts.created_at; +VALUES ( + $1, + ( + SELECT coalesce(max(account_number), -1) + 1 + FROM accounts + WHERE scope_id = $1 + ), + $2, $3, $4, $5, $6 +) +RETURNING id, account_number, created_at; -- name: CreateImportedAccount :one -- Creates a new imported account under the given scope with NULL account @@ -233,3 +253,16 @@ WHERE AND coin_type = sqlc.arg(coin_type) ) AND account_name = sqlc.arg(old_name); + +-- name: CreateDerivedAccountWithNumber :one +-- Test-only: Creates a derived account with a specific account number. +-- Used for testing account number overflow without creating billions of accounts. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + is_watch_only +) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, account_number, created_at; diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/db/queries/postgres/key_scopes.sql index 7a3200035a..5b9d31fb01 100644 --- a/wallet/internal/db/queries/postgres/key_scopes.sql +++ b/wallet/internal/db/queries/postgres/key_scopes.sql @@ -80,10 +80,3 @@ WHERE scope_id = $1; -- Deletes a key scope by its ID. DELETE FROM key_scopes WHERE id = $1; - --- name: SetLastAccountNumber :exec --- Sets the last_account_number for a key scope. This is intended for testing --- the account number overflow behavior without creating billions of accounts. -UPDATE key_scopes -SET last_account_number = $1 -WHERE id = $2; diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index 5d033dec88..f325be0520 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -1,15 +1,7 @@ -- name: CreateDerivedAccount :one --- Creates a new derived account under the given scope, using a caller-provided --- account number. --- --- NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation --- step with an INSERT in a single CTE. --- --- We instead: --- 1) call AllocateAccountNumber (key_scopes.sql) --- 2) call CreateDerivedAccount with the returned number --- --- Both statements run within the same SQL transaction. +-- Creates a new derived account under the given scope, computing the next +-- account number from existing accounts. SQLite's _txlock=immediate ensures +-- only one writer at a time, preventing concurrent allocation conflicts. INSERT INTO accounts ( scope_id, account_number, @@ -20,7 +12,12 @@ INSERT INTO accounts ( is_watch_only ) VALUES ( - ?, ?, ?, ?, ?, ?, ? + ?1, + ( + SELECT coalesce(max(account_number), -1) + 1 FROM accounts + WHERE scope_id = ?1 + ), + ?2, ?3, ?4, ?5, ?6 ) RETURNING id, account_number, created_at; @@ -227,3 +224,16 @@ WHERE AND coin_type = sqlc.arg(coin_type) ) AND account_name = sqlc.arg(old_name); + +-- name: CreateDerivedAccountWithNumber :one +-- Test-only: Creates a derived account with a specific account number. +-- Used for testing account number overflow without creating billions of accounts. +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + is_watch_only +) +VALUES (?, ?, ?, ?, ?) +RETURNING id, account_number, created_at; diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/db/queries/sqlite/key_scopes.sql index 17edfe779e..4f372584e9 100644 --- a/wallet/internal/db/queries/sqlite/key_scopes.sql +++ b/wallet/internal/db/queries/sqlite/key_scopes.sql @@ -49,17 +49,6 @@ SELECT FROM key_scopes WHERE wallet_id = ? AND purpose = ? AND coin_type = ?; --- name: AllocateAccountNumber :one --- Atomically allocates the next account number for a key scope. --- Returns the scope_id and the allocated account number. --- SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single --- CTE (unlike Postgres). So, we call this first, then pass the returned number --- to CreateAccount, within the same SQL transaction. -UPDATE key_scopes -SET last_account_number = last_account_number + 1 -WHERE id = ? -RETURNING id, last_account_number; - -- name: ListKeyScopesByWallet :many -- Lists all key scopes for a wallet, ordered by ID. SELECT @@ -91,10 +80,3 @@ WHERE scope_id = ?; -- Deletes a key scope by its ID. DELETE FROM key_scopes WHERE id = ?; - --- name: SetLastAccountNumber :exec --- Sets the last_account_number for a key scope. This is intended for testing --- the account number overflow behavior without creating billions of accounts. -UPDATE key_scopes -SET last_account_number = ? -WHERE id = ?; diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index b5b5de9ade..0d2e667312 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -32,13 +32,6 @@ func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecr } const CreateDerivedAccount = `-- name: CreateDerivedAccount :one -WITH allocated_number AS ( - UPDATE key_scopes - SET last_account_number = last_account_number + 1 - WHERE key_scopes.id = $1 - RETURNING key_scopes.id, last_account_number AS account_number -) - INSERT INTO accounts ( scope_id, account_number, @@ -48,20 +41,20 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -SELECT - allocated_number.id AS scope_id, - allocated_number.account_number, - $2 AS account_name, - $3 AS origin_id, - $4 AS encrypted_public_key, - $5 AS master_fingerprint, - $6 AS is_watch_only -FROM allocated_number -RETURNING accounts.id, accounts.account_number, accounts.created_at +VALUES ( + $1, + ( + SELECT coalesce(max(account_number), -1) + 1 + FROM accounts + WHERE scope_id = $1 + ), + $2, $3, $4, $5, $6 +) +RETURNING id, account_number, created_at ` type CreateDerivedAccountParams struct { - ID int64 + ScopeID int64 AccountName string OriginID int16 EncryptedPublicKey []byte @@ -75,13 +68,13 @@ type CreateDerivedAccountRow struct { CreatedAt time.Time } -// Creates a new derived account under the given scope, allocating a fresh -// sequential account number from key_scopes.last_account_number. -// The allocation is atomic: the UPDATE takes the row lock on the scope row, -// returns the allocated number, and updates the counter for the next call. +// Creates a new derived account under the given scope, computing the next +// account number atomically. The caller MUST call LockAccountScope first +// to acquire the advisory lock and prevent race conditions. +// See LockAccountScope comments for why this is a separate statement. func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) { row := q.queryRow(ctx, q.createDerivedAccountStmt, CreateDerivedAccount, - arg.ID, + arg.ScopeID, arg.AccountName, arg.OriginID, arg.EncryptedPublicKey, @@ -93,6 +86,47 @@ func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAcc return i, err } +const CreateDerivedAccountWithNumber = `-- name: CreateDerivedAccountWithNumber :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + is_watch_only +) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, account_number, created_at +` + +type CreateDerivedAccountWithNumberParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool +} + +type CreateDerivedAccountWithNumberRow struct { + ID int64 + AccountNumber sql.NullInt64 + CreatedAt time.Time +} + +// Test-only: Creates a derived account with a specific account number. +// Used for testing account number overflow without creating billions of accounts. +func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) { + row := q.queryRow(ctx, q.createDerivedAccountWithNumberStmt, CreateDerivedAccountWithNumber, + arg.ScopeID, + arg.AccountNumber, + arg.AccountName, + arg.OriginID, + arg.IsWatchOnly, + ) + var i CreateDerivedAccountWithNumberRow + err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) + return i, err +} + const CreateImportedAccount = `-- name: CreateImportedAccount :one INSERT INTO accounts ( scope_id, @@ -638,6 +672,38 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount return items, nil } +const LockAccountScope = `-- name: LockAccountScope :exec +SELECT pg_advisory_xact_lock(hashtextextended('account_scope', $1::BIGINT)) +` + +// Acquires a transaction-level advisory lock to serialize account creation within a scope. +// The lock is automatically released upon transaction commit or rollback. +// This MUST be called immediately before 'CreateDerivedAccount' within the same transaction. +// +// We explicitly use a two-statement pattern because single-statement CTE/Join +// approaches failed to prevent race conditions during concurrent account generation. +// The following "one-query" strategies were tested and proven unreliable: +// +// 1. CTE with CROSS/INNER JOIN: The PostgreSQL optimizer may evaluate the +// MAX(account_number) subquery using a snapshot taken before the lock CTE +// is fully processed, leading to duplicate account numbers. +// +// 2. CTE with OFFSET 0: Designed to force materialization, this still fails to +// guarantee that the lock is held before the aggregate subquery begins its +// read operation. +// +// 3. FOR UPDATE in Subqueries: Since FOR UPDATE targets existing rows, it fails +// to "lock the gap" for new inserts or handle empty tables, allowing +// concurrent processes to calculate identical MAX() values. +// +// Using two separate calls ensures the application pauses until +// LockAccountScope returns, guaranteeing that the subsequent SELECT MAX() +// operates inside a strictly serialized execution window for that scope. +func (q *Queries) LockAccountScope(ctx context.Context, dollar_1 int64) error { + _, err := q.exec(ctx, q.lockAccountScopeStmt, LockAccountScope, dollar_1) + return err +} + const UpdateAccountNameByWalletScopeAndName = `-- name: UpdateAccountNameByWalletScopeAndName :execrows UPDATE accounts SET account_name = $1 diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index eecbb41552..7f9d3d60c6 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -30,6 +30,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createDerivedAccountStmt, err = db.PrepareContext(ctx, CreateDerivedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateDerivedAccount: %w", err) } + if q.createDerivedAccountWithNumberStmt, err = db.PrepareContext(ctx, CreateDerivedAccountWithNumber); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAccountWithNumber: %w", err) + } if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) } @@ -120,8 +123,8 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } - if q.setLastAccountNumberStmt, err = db.PrepareContext(ctx, SetLastAccountNumber); err != nil { - return nil, fmt.Errorf("error preparing query SetLastAccountNumber: %w", err) + if q.lockAccountScopeStmt, err = db.PrepareContext(ctx, LockAccountScope); err != nil { + return nil, fmt.Errorf("error preparing query LockAccountScope: %w", err) } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) @@ -150,6 +153,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createDerivedAccountStmt: %w", cerr) } } + if q.createDerivedAccountWithNumberStmt != nil { + if cerr := q.createDerivedAccountWithNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAccountWithNumberStmt: %w", cerr) + } + } if q.createImportedAccountStmt != nil { if cerr := q.createImportedAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) @@ -300,9 +308,9 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } - if q.setLastAccountNumberStmt != nil { - if cerr := q.setLastAccountNumberStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing setLastAccountNumberStmt: %w", cerr) + if q.lockAccountScopeStmt != nil { + if cerr := q.lockAccountScopeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing lockAccountScopeStmt: %w", cerr) } } if q.updateAccountNameByWalletScopeAndNameStmt != nil { @@ -366,6 +374,7 @@ type Queries struct { tx *sql.Tx createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt + createDerivedAccountWithNumberStmt *sql.Stmt createImportedAccountStmt *sql.Stmt createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt @@ -396,7 +405,7 @@ type Queries struct { listAddressTypesStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt - setLastAccountNumberStmt *sql.Stmt + lockAccountScopeStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt @@ -409,6 +418,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { tx: tx, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, + createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, createImportedAccountStmt: q.createImportedAccountStmt, createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, @@ -439,7 +449,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAddressTypesStmt: q.listAddressTypesStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, - setLastAccountNumberStmt: q.setLastAccountNumberStmt, + lockAccountScopeStmt: q.lockAccountScopeStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go index 60dfb27b24..2afd0e7aaa 100644 --- a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/postgres/key_scopes.sql.go @@ -89,20 +89,10 @@ FROM key_scopes WHERE id = $1 ` -type GetKeyScopeByIDRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int16 - ExternalTypeID int16 -} - // Retrieves a key scope by its ID. -func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) { +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) - var i GetKeyScopeByIDRow + var i KeyScope err := row.Scan( &i.ID, &i.WalletID, @@ -134,20 +124,10 @@ type GetKeyScopeByWalletAndScopeParams struct { CoinType int64 } -type GetKeyScopeByWalletAndScopeRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int16 - ExternalTypeID int16 -} - // Retrieves a key scope by wallet ID, purpose, and coin type. -func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) { +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) - var i GetKeyScopeByWalletAndScopeRow + var i KeyScope err := row.Scan( &i.ID, &i.WalletID, @@ -211,26 +191,16 @@ WHERE wallet_id = $1 ORDER BY id ` -type ListKeyScopesByWalletRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int16 - ExternalTypeID int16 -} - // Lists all key scopes for a wallet, ordered by ID. -func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) { +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) if err != nil { return nil, err } defer rows.Close() - var items []ListKeyScopesByWalletRow + var items []KeyScope for rows.Next() { - var i ListKeyScopesByWalletRow + var i KeyScope if err := rows.Scan( &i.ID, &i.WalletID, @@ -252,21 +222,3 @@ func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([] } return items, nil } - -const SetLastAccountNumber = `-- name: SetLastAccountNumber :exec -UPDATE key_scopes -SET last_account_number = $1 -WHERE id = $2 -` - -type SetLastAccountNumberParams struct { - LastAccountNumber int64 - ID int64 -} - -// Sets the last_account_number for a key scope. This is intended for testing -// the account number overflow behavior without creating billions of accounts. -func (q *Queries) SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error { - _, err := q.exec(ctx, q.setLastAccountNumberStmt, SetLastAccountNumber, arg.LastAccountNumber, arg.ID) - return err -} diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 2957b82a6e..4c4bdc252c 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -50,7 +50,6 @@ type KeyScope struct { EncryptedCoinPubKey []byte InternalTypeID int16 ExternalTypeID int16 - LastAccountNumber int64 } type KeyScopeSecret struct { diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 233c578e7a..114ed39acf 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -11,11 +11,14 @@ import ( type Querier interface { // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error - // Creates a new derived account under the given scope, allocating a fresh - // sequential account number from key_scopes.last_account_number. - // The allocation is atomic: the UPDATE takes the row lock on the scope row, - // returns the allocated number, and updates the counter for the next call. + // Creates a new derived account under the given scope, computing the next + // account number atomically. The caller MUST call LockAccountScope first + // to acquire the advisory lock and prevent race conditions. + // See LockAccountScope comments for why this is a separate statement. CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) + // Test-only: Creates a derived account with a specific account number. + // Used for testing account number overflow without creating billions of accounts. + CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need // a sequential account number. @@ -42,9 +45,9 @@ type Querier interface { GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) // Retrieves a key scope by its ID. - GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) + GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) // Retrieves a key scope by wallet ID, purpose, and coin type. - GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) @@ -71,11 +74,32 @@ type Querier interface { // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. - ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) - // Sets the last_account_number for a key scope. This is intended for testing - // the account number overflow behavior without creating billions of accounts. - SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error + // Acquires a transaction-level advisory lock to serialize account creation within a scope. + // The lock is automatically released upon transaction commit or rollback. + // This MUST be called immediately before 'CreateDerivedAccount' within the same transaction. + // + // We explicitly use a two-statement pattern because single-statement CTE/Join + // approaches failed to prevent race conditions during concurrent account generation. + // The following "one-query" strategies were tested and proven unreliable: + // + // 1. CTE with CROSS/INNER JOIN: The PostgreSQL optimizer may evaluate the + // MAX(account_number) subquery using a snapshot taken before the lock CTE + // is fully processed, leading to duplicate account numbers. + // + // 2. CTE with OFFSET 0: Designed to force materialization, this still fails to + // guarantee that the lock is held before the aggregate subquery begins its + // read operation. + // + // 3. FOR UPDATE in Subqueries: Since FOR UPDATE targets existing rows, it fails + // to "lock the gap" for new inserts or handle empty tables, allowing + // concurrent processes to calculate identical MAX() values. + // + // Using two separate calls ensures the application pauses until + // LockAccountScope returns, guaranteeing that the subsequent SELECT MAX() + // operates inside a strictly serialized execution window for that scope. + LockAccountScope(ctx context.Context, dollar_1 int64) error // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 6e4cd25831..90a939d402 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -42,14 +42,18 @@ INSERT INTO accounts ( is_watch_only ) VALUES ( - ?, ?, ?, ?, ?, ?, ? + ?1, + ( + SELECT coalesce(max(account_number), -1) + 1 FROM accounts + WHERE scope_id = ?1 + ), + ?2, ?3, ?4, ?5, ?6 ) RETURNING id, account_number, created_at ` type CreateDerivedAccountParams struct { ScopeID int64 - AccountNumber sql.NullInt64 AccountName string OriginID int64 EncryptedPublicKey []byte @@ -63,21 +67,12 @@ type CreateDerivedAccountRow struct { CreatedAt time.Time } -// Creates a new derived account under the given scope, using a caller-provided -// account number. -// -// NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation -// step with an INSERT in a single CTE. -// -// We instead: -// 1. call AllocateAccountNumber (key_scopes.sql) -// 2. call CreateDerivedAccount with the returned number -// -// Both statements run within the same SQL transaction. +// Creates a new derived account under the given scope, computing the next +// account number from existing accounts. SQLite's _txlock=immediate ensures +// only one writer at a time, preventing concurrent allocation conflicts. func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) { row := q.queryRow(ctx, q.createDerivedAccountStmt, CreateDerivedAccount, arg.ScopeID, - arg.AccountNumber, arg.AccountName, arg.OriginID, arg.EncryptedPublicKey, @@ -89,6 +84,47 @@ func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAcc return i, err } +const CreateDerivedAccountWithNumber = `-- name: CreateDerivedAccountWithNumber :one +INSERT INTO accounts ( + scope_id, + account_number, + account_name, + origin_id, + is_watch_only +) +VALUES (?, ?, ?, ?, ?) +RETURNING id, account_number, created_at +` + +type CreateDerivedAccountWithNumberParams struct { + ScopeID int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool +} + +type CreateDerivedAccountWithNumberRow struct { + ID int64 + AccountNumber sql.NullInt64 + CreatedAt time.Time +} + +// Test-only: Creates a derived account with a specific account number. +// Used for testing account number overflow without creating billions of accounts. +func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) { + row := q.queryRow(ctx, q.createDerivedAccountWithNumberStmt, CreateDerivedAccountWithNumber, + arg.ScopeID, + arg.AccountNumber, + arg.AccountName, + arg.OriginID, + arg.IsWatchOnly, + ) + var i CreateDerivedAccountWithNumberRow + err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) + return i, err +} + const CreateImportedAccount = `-- name: CreateImportedAccount :one INSERT INTO accounts ( scope_id, diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index a202663a0e..2b4a10c8cd 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,15 +24,15 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error - if q.allocateAccountNumberStmt, err = db.PrepareContext(ctx, AllocateAccountNumber); err != nil { - return nil, fmt.Errorf("error preparing query AllocateAccountNumber: %w", err) - } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } if q.createDerivedAccountStmt, err = db.PrepareContext(ctx, CreateDerivedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateDerivedAccount: %w", err) } + if q.createDerivedAccountWithNumberStmt, err = db.PrepareContext(ctx, CreateDerivedAccountWithNumber); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAccountWithNumber: %w", err) + } if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) } @@ -123,9 +123,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } - if q.setLastAccountNumberStmt, err = db.PrepareContext(ctx, SetLastAccountNumber); err != nil { - return nil, fmt.Errorf("error preparing query SetLastAccountNumber: %w", err) - } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } @@ -143,11 +140,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error - if q.allocateAccountNumberStmt != nil { - if cerr := q.allocateAccountNumberStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing allocateAccountNumberStmt: %w", cerr) - } - } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -158,6 +150,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createDerivedAccountStmt: %w", cerr) } } + if q.createDerivedAccountWithNumberStmt != nil { + if cerr := q.createDerivedAccountWithNumberStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAccountWithNumberStmt: %w", cerr) + } + } if q.createImportedAccountStmt != nil { if cerr := q.createImportedAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) @@ -308,11 +305,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } - if q.setLastAccountNumberStmt != nil { - if cerr := q.setLastAccountNumberStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing setLastAccountNumberStmt: %w", cerr) - } - } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -372,9 +364,9 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar type Queries struct { db DBTX tx *sql.Tx - allocateAccountNumberStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt + createDerivedAccountWithNumberStmt *sql.Stmt createImportedAccountStmt *sql.Stmt createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt @@ -405,7 +397,6 @@ type Queries struct { listAddressTypesStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt - setLastAccountNumberStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt @@ -416,9 +407,9 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ db: tx, tx: tx, - allocateAccountNumberStmt: q.allocateAccountNumberStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, + createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, createImportedAccountStmt: q.createImportedAccountStmt, createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, @@ -449,7 +440,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAddressTypesStmt: q.listAddressTypesStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, - setLastAccountNumberStmt: q.setLastAccountNumberStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go index df22bbd344..7305a3c014 100644 --- a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go +++ b/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go @@ -9,30 +9,6 @@ import ( "context" ) -const AllocateAccountNumber = `-- name: AllocateAccountNumber :one -UPDATE key_scopes -SET last_account_number = last_account_number + 1 -WHERE id = ? -RETURNING id, last_account_number -` - -type AllocateAccountNumberRow struct { - ID int64 - LastAccountNumber int64 -} - -// Atomically allocates the next account number for a key scope. -// Returns the scope_id and the allocated account number. -// SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single -// CTE (unlike Postgres). So, we call this first, then pass the returned number -// to CreateAccount, within the same SQL transaction. -func (q *Queries) AllocateAccountNumber(ctx context.Context, id int64) (AllocateAccountNumberRow, error) { - row := q.queryRow(ctx, q.allocateAccountNumberStmt, AllocateAccountNumber, id) - var i AllocateAccountNumberRow - err := row.Scan(&i.ID, &i.LastAccountNumber) - return i, err -} - const CreateKeyScope = `-- name: CreateKeyScope :one INSERT INTO key_scopes ( wallet_id, @@ -113,20 +89,10 @@ FROM key_scopes WHERE id = ? ` -type GetKeyScopeByIDRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int64 - ExternalTypeID int64 -} - // Retrieves a key scope by its ID. -func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) { +func (q *Queries) GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) { row := q.queryRow(ctx, q.getKeyScopeByIDStmt, GetKeyScopeByID, id) - var i GetKeyScopeByIDRow + var i KeyScope err := row.Scan( &i.ID, &i.WalletID, @@ -158,20 +124,10 @@ type GetKeyScopeByWalletAndScopeParams struct { CoinType int64 } -type GetKeyScopeByWalletAndScopeRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int64 - ExternalTypeID int64 -} - // Retrieves a key scope by wallet ID, purpose, and coin type. -func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) { +func (q *Queries) GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) { row := q.queryRow(ctx, q.getKeyScopeByWalletAndScopeStmt, GetKeyScopeByWalletAndScope, arg.WalletID, arg.Purpose, arg.CoinType) - var i GetKeyScopeByWalletAndScopeRow + var i KeyScope err := row.Scan( &i.ID, &i.WalletID, @@ -235,26 +191,16 @@ WHERE wallet_id = ? ORDER BY id ` -type ListKeyScopesByWalletRow struct { - ID int64 - WalletID int64 - Purpose int64 - CoinType int64 - EncryptedCoinPubKey []byte - InternalTypeID int64 - ExternalTypeID int64 -} - // Lists all key scopes for a wallet, ordered by ID. -func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) { +func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) { rows, err := q.query(ctx, q.listKeyScopesByWalletStmt, ListKeyScopesByWallet, walletID) if err != nil { return nil, err } defer rows.Close() - var items []ListKeyScopesByWalletRow + var items []KeyScope for rows.Next() { - var i ListKeyScopesByWalletRow + var i KeyScope if err := rows.Scan( &i.ID, &i.WalletID, @@ -276,21 +222,3 @@ func (q *Queries) ListKeyScopesByWallet(ctx context.Context, walletID int64) ([] } return items, nil } - -const SetLastAccountNumber = `-- name: SetLastAccountNumber :exec -UPDATE key_scopes -SET last_account_number = ? -WHERE id = ? -` - -type SetLastAccountNumberParams struct { - LastAccountNumber int64 - ID int64 -} - -// Sets the last_account_number for a key scope. This is intended for testing -// the account number overflow behavior without creating billions of accounts. -func (q *Queries) SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error { - _, err := q.exec(ctx, q.setLastAccountNumberStmt, SetLastAccountNumber, arg.LastAccountNumber, arg.ID) - return err -} diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 47199cd608..9427d3abb3 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -50,7 +50,6 @@ type KeyScope struct { EncryptedCoinPubKey []byte InternalTypeID int64 ExternalTypeID int64 - LastAccountNumber int64 } type KeyScopeSecret struct { diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 9d6877b17b..742741b180 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -9,26 +9,15 @@ import ( ) type Querier interface { - // Atomically allocates the next account number for a key scope. - // Returns the scope_id and the allocated account number. - // SQLite limitation: Can't combine UPDATE...RETURNING with INSERT in a single - // CTE (unlike Postgres). So, we call this first, then pass the returned number - // to CreateAccount, within the same SQL transaction. - AllocateAccountNumber(ctx context.Context, id int64) (AllocateAccountNumberRow, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error - // Creates a new derived account under the given scope, using a caller-provided - // account number. - // - // NOTE: Unlike Postgres, SQLite can't combine an UPDATE...RETURNING allocation - // step with an INSERT in a single CTE. - // - // We instead: - // 1) call AllocateAccountNumber (key_scopes.sql) - // 2) call CreateDerivedAccount with the returned number - // - // Both statements run within the same SQL transaction. + // Creates a new derived account under the given scope, computing the next + // account number from existing accounts. SQLite's _txlock=immediate ensures + // only one writer at a time, preventing concurrent allocation conflicts. CreateDerivedAccount(ctx context.Context, arg CreateDerivedAccountParams) (CreateDerivedAccountRow, error) + // Test-only: Creates a derived account with a specific account number. + // Used for testing account number overflow without creating billions of accounts. + CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need // a sequential account number. @@ -55,9 +44,9 @@ type Querier interface { GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) // Retrieves a key scope by its ID. - GetKeyScopeByID(ctx context.Context, id int64) (GetKeyScopeByIDRow, error) + GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) // Retrieves a key scope by wallet ID, purpose, and coin type. - GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (GetKeyScopeByWalletAndScopeRow, error) + GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) @@ -84,11 +73,8 @@ type Querier interface { // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all key scopes for a wallet, ordered by ID. - ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]ListKeyScopesByWalletRow, error) + ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) - // Sets the last_account_number for a key scope. This is intended for testing - // the account number overflow behavior without creating billions of accounts. - SetLastAccountNumber(ctx context.Context, arg SetLastAccountNumberParams) error // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. From 3a661898aabe4bd4b08c77352186d4d47abf5d5e Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 20 Jan 2026 20:04:01 -0300 Subject: [PATCH 370/691] scripts: add db coverage filter --- scripts/filter_coverage.sh | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100755 scripts/filter_coverage.sh diff --git a/scripts/filter_coverage.sh b/scripts/filter_coverage.sh new file mode 100755 index 0000000000..2696c04b00 --- /dev/null +++ b/scripts/filter_coverage.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Filter coverage files to exclude opposite backend implementations +# Usage: filter_coverage.sh +# Where db_type is 'sqlite' or 'postgres' + +set -e + +DB_TYPE="$1" +if [ "$DB_TYPE" != "sqlite" ] && [ "$DB_TYPE" != "postgres" ]; then + echo "Usage: $0 " + exit 1 +fi + +COVERAGE_FILE="coverage-itest-${DB_TYPE}.txt" +if [ ! -f "$COVERAGE_FILE" ]; then + echo "Coverage file $COVERAGE_FILE not found" + exit 1 +fi + +# Create filtered version +FILTERED_FILE="${COVERAGE_FILE}.filtered" + +# Keep the mode line, filter out opposite backend files +head -1 "$COVERAGE_FILE" >"$FILTERED_FILE" + +if [ "$DB_TYPE" = "sqlite" ]; then + # For sqlite: exclude postgres files + tail -n +2 "$COVERAGE_FILE" | grep -Ev 'pg|postgres' >>"$FILTERED_FILE" +else + # For postgres: exclude sqlite files + tail -n +2 "$COVERAGE_FILE" | grep -Ev 'sqlite' >>"$FILTERED_FILE" +fi + +# Replace original with filtered +mv "$FILTERED_FILE" "$COVERAGE_FILE" + +# Output the filtered coverage percentage +go tool cover -func="$COVERAGE_FILE" | + awk '/^total:/ { print "Filtered test coverage for '"$DB_TYPE"': " $3 }' From b2500caa2f37df44b15bf76640ec109e50ce5dc0 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 20 Jan 2026 20:05:01 -0300 Subject: [PATCH 371/691] Make: add cover option to itest-db --- Makefile | 4 ++++ config/testing_flags.mk | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6463ea5cd2..38a7ed583b 100644 --- a/Makefile +++ b/Makefile @@ -110,6 +110,10 @@ itest-db: fi @$(call print, "Running $(IT_DB_LABEL) integration tests.") $(ITEST_DB) + @if [ -n "$(ITEST_DB_COVERPROFILE)" ]; then \ + echo "Filtering coverage report."; \ + ./scripts/filter_coverage.sh $(IT_DB_TYPE); \ + fi #? itest-db-race: Run integration tests for wallet database with race detector itest-db-race: diff --git a/config/testing_flags.mk b/config/testing_flags.mk index 0c7951107e..6fae8ee206 100644 --- a/config/testing_flags.mk +++ b/config/testing_flags.mk @@ -7,9 +7,17 @@ IT_TAGS ?= ifeq ($(db),postgres) IT_TAGS += test_db_postgres IT_DB_LABEL := PostgreSQL +IT_DB_TYPE := postgres else IT_TAGS := IT_DB_LABEL := SQLite +IT_DB_TYPE := sqlite +endif + +# Enable integration test coverage +ifeq ($(cover),1) +ITEST_DB_COVERPROFILE = coverage-itest-$(IT_DB_TYPE).txt +ITEST_DB_COVERAGE = -coverprofile=$(ITEST_DB_COVERPROFILE) -coverpkg=$(PKG)/wallet/internal/db/... -covermode=atomic endif GOCC ?= go @@ -91,5 +99,5 @@ endif UNIT_COVER := $(GOTEST) $(COVER_FLAGS) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) $(COVER_PKG) -ITEST_DB := $(GOTEST) -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest +ITEST_DB := $(GOTEST) $(ITEST_DB_COVERAGE) -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest ITEST_DB_RACE := $(GOTEST) -race -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest From 76a05312fc258045dae94f6a07d5c6b6f8020478 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 20 Jan 2026 20:50:25 -0300 Subject: [PATCH 372/691] .gitignore: ignore itest coverage reports --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 25a01ef540..38ab05487f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ btcwallet vendor .idea coverage.txt +coverage-itest-postgres.txt +coverage-itest-sqlite.txt *.swp .vscode .DS_Store From 2127da8d7ba8ad797689541607f80e5fc620df69 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 20 Jan 2026 20:51:05 -0300 Subject: [PATCH 373/691] CI: add coverallsapp action for itest --- .github/workflows/main.yml | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f5cafec1db..45b53ae614 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -152,24 +152,26 @@ jobs: run: make ${{ matrix.unit_type }} - name: Send coverage - uses: shogo82148/actions-goveralls@v1 + uses: coverallsapp/github-action@v2 if: matrix.unit_type == 'unit-cover' continue-on-error: true with: - path-to-profile: coverage.txt + file: coverage.txt + flag-name: unit + format: golang parallel: true ######################## # run integration tests (SQLite + Postgres) ######################## itest-db: - name: integration tests (${{ matrix.db }}, ${{ matrix.target }}) + name: integration tests (${{ matrix.db }}, ${{ matrix.race && 'race' || 'cover' }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: db: [sqlite, postgres] - target: [itest-db, itest-db-race] + race: [false, true] steps: - name: git checkout uses: actions/checkout@v5 @@ -193,5 +195,35 @@ jobs: with: go-version: '${{ env.GO_VERSION }}' - - name: run ${{ matrix.db }} ${{ matrix.target }} - run: make ${{ matrix.target }} db=${{ matrix.db }} verbose=1 + - name: run ${{ matrix.db }} itest-db (coverage) + if: ${{ !matrix.race }} + run: make itest-db db=${{ matrix.db }} cover=1 verbose=1 + + - name: run ${{ matrix.db }} itest-db-race + if: matrix.race + run: make itest-db-race db=${{ matrix.db }} verbose=1 + + - name: Upload coverage to Coveralls + uses: coverallsapp/github-action@v2 + if: ${{ !matrix.race }} + continue-on-error: true + with: + file: coverage-itest-${{ matrix.db }}.txt + flag-name: itest-db-${{ matrix.db }} + format: golang + parallel: true + + ######################## + # Complete parallel coverage uploads + ######################## + finish: + name: Finish coverage upload + if: ${{ !cancelled() }} + needs: [unit-test, itest-db] + runs-on: ubuntu-latest + steps: + - name: Finish parallel Coveralls upload + uses: coverallsapp/github-action@v2 + continue-on-error: true + with: + parallel-finished: true From 055aa12928f2cda8df48d70efd7fab04dd840292 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 11:48:56 -0300 Subject: [PATCH 374/691] multi: move tools config files back to root This reverts the previous change that relocated tool configuration files under the config directory. Several tools expect these files at the repository root by default, and moving them caused unnecessary friction and extra configuration. In Go projects, a config directory is typically used for application level configuration, such as code and schemas for parsing runtime or environment config files, not for external tooling like linters or formatters. Restore the files to the root to keep tool behavior predictable and aligned with common Go conventions. --- config/.golangci.yml => .golangci.yml | 0 config/.protolint.yml => .protolint.yml | 0 config/sqlfluff.cfg => .sqlfluff | 0 Makefile | 44 ++++++++++++------------- {config => make}/testing_flags.mk | 0 config/sqlc.yaml => sqlc.yaml | 12 +++---- 6 files changed, 28 insertions(+), 28 deletions(-) rename config/.golangci.yml => .golangci.yml (100%) rename config/.protolint.yml => .protolint.yml (100%) rename config/sqlfluff.cfg => .sqlfluff (100%) rename {config => make}/testing_flags.mk (100%) rename config/sqlc.yaml => sqlc.yaml (80%) diff --git a/config/.golangci.yml b/.golangci.yml similarity index 100% rename from config/.golangci.yml rename to .golangci.yml diff --git a/config/.protolint.yml b/.protolint.yml similarity index 100% rename from config/.protolint.yml rename to .protolint.yml diff --git a/config/sqlfluff.cfg b/.sqlfluff similarity index 100% rename from config/sqlfluff.cfg rename to .sqlfluff diff --git a/Makefile b/Makefile index 38a7ed583b..8155843712 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ CP := cp MAKE := make XARGS := xargs -L 1 -include config/testing_flags.mk +include make/testing_flags.mk # Linting uses a lot of memory, so keep it under control by limiting the number # of workers if requested. @@ -148,17 +148,17 @@ rpc-format: #? lint-config-check: Verify golangci-lint configuration lint-config-check: docker-tools @$(call print, "Verifying golangci-lint configuration.") - $(DOCKER_TOOLS) golangci-lint config verify -v --config config/.golangci.yml + $(DOCKER_TOOLS) golangci-lint config verify -v --config .golangci.yml #? lint: Lint source and check errors lint-check: lint-config-check @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v --config config/.golangci.yml $(LINT_WORKERS) + $(DOCKER_TOOLS) golangci-lint run -v --config .golangci.yml $(LINT_WORKERS) #? lint: Lint source and fix lint: lint-config-check @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v --fix --config config/.golangci.yml $(LINT_WORKERS) + $(DOCKER_TOOLS) golangci-lint run -v --fix --config .golangci.yml $(LINT_WORKERS) #? docker-tools: Build tools docker image docker-tools: @@ -178,7 +178,7 @@ rpc-check: rpc #? protolint: Lint proto files using protolint protolint: @$(call print, "Linting proto files.") - $(DOCKER_TOOLS) protolint lint -config_dir_path=config rpc/ + $(DOCKER_TOOLS) protolint lint -config_dir_path=. rpc/ #? sample-conf-check: Make sure default values in the sample-btcwallet.conf file are set correctly sample-conf-check: install @@ -202,18 +202,18 @@ tidy-module-check: tidy-module #? sql-parse: Ensures SQL files are syntactically valid sql-parse: @$(call print, "Validating SQL files (postgres migrations).") - $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) @$(call print, "Validating SQL files (postgres queries).") - $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) @$(call print, "Validating SQL files (sqlite migrations).") - $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) @$(call print, "Validating SQL files (sqlite queries).") - $(SQLFLUFF) parse --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) #? sqlc: Generate Go code from SQL queries and migrations sqlc: sql-parse docker-tools @$(call print, "Generating sql models and queries in Go") - $(DOCKER_TOOLS) sqlc generate -f config/sqlc.yaml + $(DOCKER_TOOLS) sqlc generate -f sqlc.yaml #? sqlc-check: Verify generated Go SQL queries and migrations are up-to-date sqlc-check: sqlc @@ -223,13 +223,13 @@ sqlc-check: sqlc #? sql-format: Format SQL migration and query files (like 'make fmt') sql-format: @$(call print, "Formatting SQL files (postgres migrations).") - $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) @$(call print, "Formatting SQL files (postgres queries).") - $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) @$(call print, "Formatting SQL files (sqlite migrations).") - $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) @$(call print, "Formatting SQL files (sqlite queries).") - $(SQLFLUFF) format --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) #? sql-check: Verify SQL migration and query files are formatted correctly (like 'make fmt-check') sql-format-check: sql-format @@ -239,24 +239,24 @@ sql-format-check: sql-format #? sql-lint: Lint SQL migration and query files and fix issues (like 'make lint') sql-lint: @$(call print, "Linting SQL files (postgres migrations).") - $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) @$(call print, "Linting SQL files (postgres queries).") - $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) @$(call print, "Linting SQL files (sqlite migrations).") - $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) @$(call print, "Linting SQL files (sqlite queries).") - $(SQLFLUFF) fix --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) #? sql-lint-check: Lint SQL files and report errors (like 'make lint-check') sql-lint-check: @$(call print, "Linting SQL files (postgres migrations).") - $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) @$(call print, "Linting SQL files (postgres queries).") - $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) @$(call print, "Linting SQL files (sqlite migrations).") - $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) @$(call print, "Linting SQL files (sqlite queries).") - $(SQLFLUFF) lint --config /sql/config/sqlfluff.cfg --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) .PHONY: all \ default \ diff --git a/config/testing_flags.mk b/make/testing_flags.mk similarity index 100% rename from config/testing_flags.mk rename to make/testing_flags.mk diff --git a/config/sqlc.yaml b/sqlc.yaml similarity index 80% rename from config/sqlc.yaml rename to sqlc.yaml index 5e9f3a162b..3bed14788a 100644 --- a/config/sqlc.yaml +++ b/sqlc.yaml @@ -3,11 +3,11 @@ version: "2" sql: - engine: "postgresql" - schema: "../wallet/internal/db/migrations/postgres" - queries: "../wallet/internal/db/queries/postgres" + schema: "wallet/internal/db/migrations/postgres" + queries: "wallet/internal/db/queries/postgres" gen: go: - out: "../wallet/internal/db/sqlc/postgres" + out: "wallet/internal/db/sqlc/postgres" package: "sqlcpg" # This is the driver package that sqlc will use in the generated code. @@ -24,11 +24,11 @@ sql: emit_prepared_queries: true - engine: "sqlite" - schema: "../wallet/internal/db/migrations/sqlite" - queries: "../wallet/internal/db/queries/sqlite" + schema: "wallet/internal/db/migrations/sqlite" + queries: "wallet/internal/db/queries/sqlite" gen: go: - out: "../wallet/internal/db/sqlc/sqlite" + out: "wallet/internal/db/sqlc/sqlite" package: "sqlcsqlite" # This is the driver package that sqlc will use in the generated code. From 0603d267f6c6287cc876267e11e3519c523ea0cd Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 12:29:31 -0300 Subject: [PATCH 375/691] Makefile: remove sqlfluff parse output noise As the number of SQL files in the project increased, the sqlfluff parse command started flooding stdout with low value logs, often one line per operator such as an equals sign in sql. This output is noisy for humans and also problematic for LLM based tools, since the excessive logs consume context length unnecessarily. Disable this parse output to keep the CLI readable. --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 8155843712..81582e266a 100644 --- a/Makefile +++ b/Makefile @@ -202,13 +202,13 @@ tidy-module-check: tidy-module #? sql-parse: Ensures SQL files are syntactically valid sql-parse: @$(call print, "Validating SQL files (postgres migrations).") - $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) --format none @$(call print, "Validating SQL files (postgres queries).") - $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) --format none @$(call print, "Validating SQL files (sqlite migrations).") - $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) --format none @$(call print, "Validating SQL files (sqlite queries).") - $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) --format none #? sqlc: Generate Go code from SQL queries and migrations sqlc: sql-parse docker-tools From c9411de6badd269c3d03594f813aa1a98cf8c0e9 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sun, 11 Jan 2026 20:12:50 -0300 Subject: [PATCH 376/691] wallet: fix typo --- wallet/internal/db/data_types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index b49f52239d..fe8fc41d9c 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -442,7 +442,7 @@ type CreateImportedAccountParams struct { } // AccountProperties contains properties associated with each account, such as -// the account name, number, and the nubmer of derived and imported keys. +// the account name, number, and the number of derived and imported keys. type AccountProperties struct { // AccountNumber is the BIP44 account index used for derived accounts. // Imported accounts do not follow BIP44 derivation and therefore do not From 3fec49e680b768cffe314de23f35f260ca0827cd Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 12 Jan 2026 12:48:00 -0300 Subject: [PATCH 377/691] wallet: refactor AddressStore interface to respect SRP --- wallet/internal/db/data_types.go | 176 ++++++++++++++----------------- wallet/internal/db/interface.go | 69 ++++++------ 2 files changed, 118 insertions(+), 127 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index fe8fc41d9c..875dfa0610 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -6,7 +6,6 @@ package db import ( "time" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -167,18 +166,6 @@ const ( ImportedAccount ) -// Tapscript represents a Taproot script leaf, which includes the script itself -// and its corresponding control block. This is used for spending Taproot -// outputs. -type Tapscript struct { - // ControlBlock is the control block for the Taproot script, which is - // required to reveal the script path during spending. - ControlBlock []byte - - // Script is the actual script code of the Taproot leaf. - Script []byte -} - // -------------------- // WalletStore Types // -------------------- @@ -570,36 +557,69 @@ type RenameAccountParams struct { // AddressInfo represents a wallet-managed address, including its properties and // derivation information. type AddressInfo struct { - // Address is the human-readable address string. - Address btcutil.Address + // ID is the database unique identifier for the address. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + ID uint32 - // Internal indicates whether the address is for internal (change) use. - Internal bool + // AccountID is the database unique identifier for the account this address + // belongs to. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + AccountID uint32 - // Compressed indicates whether the address is compressed. - Compressed bool + // AddrType is the type of address (P2PKH, P2WPKH, P2TR, etc.). + AddrType AddressType - // Used indicates whether the address has been used in a transaction. - Used bool + // CreatedAt is when the address was created in the wallet database. + CreatedAt time.Time - // IsWatchOnly indicates whether the wallet has the private key for - // this address. + // Origin indicates whether this is a derived HD address or an imported + // address. Reuses the AccountOrigin enum. + Origin AccountOrigin + + // Branch is the BIP44 branch number (0=external, 1=internal/change). + // Zero value for imported addresses. + Branch uint32 + + // Index is the BIP44 index within the branch. Zero value for imported + // addresses. + Index uint32 + + // ScriptPubKey is the script pubkey (plaintext). Zero value for + // derived addresses. + ScriptPubKey []byte + + // PubKey is the public key (plaintext). Zero value for derived + // addresses. + PubKey []byte + + // IsWatchOnly indicates whether the wallet has the private key for this + // address. Convenience field. IsWatchOnly bool +} - // AddrType is the type of the address (P2PKH, P2SH, etc.). - AddrType AddressType +// AddressSecret contains sensitive encrypted material for an address. +type AddressSecret struct { + // AddressID is the database unique identifier for the address. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + AddressID uint32 - // DerivationInfo contains the BIP-32 derivation path information for - // the address. This will be nil for imported addresses that are not - // part of an HD account. - DerivationInfo *DerivationInfo + // EncryptedPrivKey is the encrypted private key. + EncryptedPrivKey []byte - // Script is the script associated with the address, if any. - Script []byte + // EncryptedScript is the encrypted redeem or witness script for + // P2SH/P2WSH addresses. For Taproot, this is the TLV-encoded Tapscript. + EncryptedScript []byte } -// NewAddressParams contains the parameters for creating a new address. -type NewAddressParams struct { +// NewDerivedAddressParams contains the parameters for creating a new derived +// address. +type NewDerivedAddressParams struct { // WalletID is the ID of the wallet to create the address in. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -617,45 +637,39 @@ type NewAddressParams struct { Change bool } -// ImportAddressParams encapsulates all the data needed to store a new, imported -// address, script, or private key. All imported addresses are automatically -// assigned to the wallet's logical "imported" account. The presence of a -// private key determines whether the address will be spendable or watch-only. -type ImportAddressParams struct { - // WalletID is the ID of the wallet to import the address into. +// NewImportedAddressParams defines the input required to import a single +// address into the wallet. All imported addresses are assigned to the +// wallet imported account. The caller is responsible for encrypting any +// sensitive material before populating this struct. +type NewImportedAddressParams struct { + // WalletID identifies the wallet that will own this address. // // NOTE: uint32 is used to ensure compatibility with standard SQL // databases (signed 64-bit integers). WalletID uint32 - // PrivateKey is the private key to import, in WIF format. If this is - // provided, the address will be spendable. If nil, the import will be - // watch-only. - PrivateKey *btcutil.WIF + // Scope is the key scope for the imported address. + Scope KeyScope - // PubKey is the public key to import for a watch-only address. This - // field is only used if PrivateKey is nil. - PubKey *btcec.PublicKey + // AddressType specifies the address format being imported, such as + // P2PKH, P2WPKH, or P2TR. + AddressType AddressType - // Tapscript is the Taproot script to import for a watch-only address. - // This field is only used if PrivateKey is nil. - Tapscript *Tapscript + // ScriptPubKey contains the script pubkey associated with the address + // (stored in plaintext). + ScriptPubKey []byte - // Script is the generic script to import for a watch-only address. - // This field is only used if PrivateKey is nil. - Script []byte -} + // PubKey contains the public key corresponding to the private key for + // this address (stored in plaintext). + PubKey []byte -// GetPrivateKeyParams contains the parameters for retrieving a private key. -type GetPrivateKeyParams struct { - // WalletID is the ID of the wallet to query. - // - // NOTE: uint32 is used to ensure compatibility with standard SQL - // databases (signed 64-bit integers). - WalletID uint32 + // EncryptedPrivateKey contains the encrypted private key for the address. + EncryptedPrivateKey []byte - // Address is the address for which to retrieve the private key. - Address btcutil.Address + // EncryptedScript contains the encrypted, pre serialized script. + // For P2SH and P2WSH this is the redeem or witness script. + // For Taproot this is the TLV encoded Tapscript. + EncryptedScript []byte } // GetAddressQuery contains the parameters for querying an address. @@ -666,8 +680,10 @@ type GetAddressQuery struct { // databases (signed 64-bit integers). WalletID uint32 - // Address is the address to query. - Address btcutil.Address + // ScriptPubKey is the script pubkey. If provided, the query will be + // performed using this value. Only applicable to imported addresses, + // as derived addresses have NULL script pubkeys. + ScriptPubKey []byte } // ListAddressesQuery contains the parameters for listing addresses. @@ -685,38 +701,6 @@ type ListAddressesQuery struct { Scope KeyScope } -// MarkAddressAsUsedParams contains the parameters for marking an address as -// used. -type MarkAddressAsUsedParams struct { - // WalletID is the ID of the wallet containing the address. - // - // NOTE: uint32 is used to ensure compatibility with standard SQL - // databases (signed 64-bit integers). - WalletID uint32 - - // Address is the address to mark as used. - Address btcutil.Address -} - -// DerivationInfo contains the BIP-32 derivation path information for a key. -type DerivationInfo struct { - // KeyScope is the key scope of the derivation path. - KeyScope KeyScope - - // MasterKeyFingerprint is the fingerprint of the master key. - MasterKeyFingerprint uint32 - - // Account is the account number of the derivation path. - Account uint32 - - // Branch is the branch number of the derivation path (0 for external, - // 1 for internal). - Branch uint32 - - // Index is the index of the key in the branch. - Index uint32 -} - // -------------------- // TxStore Types // -------------------- diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index a995f06775..3589383a48 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -4,7 +4,6 @@ import ( "context" "errors" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" ) @@ -130,44 +129,53 @@ type AccountStore interface { RenameAccount(ctx context.Context, params RenameAccountParams) error } +// AddressDerivationFunc is called by the database layer after allocating an +// address index to derive the actual address data (script_pub_key). As the +// database should not know about how to derive an address, we pass this as a +// callback. +type AddressDerivationFunc func(ctx context.Context, accountID uint32, + branch uint32, index uint32) (*DerivedAddressData, error) + +// DerivedAddressData contains the derived address information returned by +// the AddressDerivationFunc callback. +type DerivedAddressData struct { + // ScriptPubKey is the script public key for the derived address. + ScriptPubKey []byte +} + // AddressStore defines the database actions for managing addresses. type AddressStore interface { - // NewAddress creates a new address for a given account and key scope. - // It returns the newly created address or an error if the creation - // fails. - NewAddress(ctx context.Context, params NewAddressParams) ( - btcutil.Address, error) - - // ImportAddress imports a new address, script, or private key. If a - // private key is provided in the parameters, the address will be - // spendable. Otherwise, it will be imported as watch-only. It returns - // information about the imported address or an error if the import - // fails. - ImportAddress(ctx context.Context, params ImportAddressParams) ( - *AddressInfo, error) + // NewDerivedAddress creates a new HD-derived address for the specified + // account and key scope. The database layer allocates the address index + // atomically, then calls deriveFn to derive the actual address data. + // Returns the complete address metadata including the derived + // script_pub_key. + NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, + deriveFn AddressDerivationFunc) (*AddressInfo, error) + + // NewImportedAddress imports a new address, script, or private key. + // If a private key is provided in the parameters, the address will + // be spendable. Otherwise, it will be imported as watch-only. It + // returns information about the imported address or an error if the + // import fails. + NewImportedAddress(ctx context.Context, + params NewImportedAddressParams) (*AddressInfo, error) // GetAddress retrieves information about a specific address. It // returns an AddressInfo struct containing the address's properties or // an error if the address is not found. - GetAddress(ctx context.Context, query GetAddressQuery) ( - *AddressInfo, error) + GetAddress(ctx context.Context, query GetAddressQuery) (*AddressInfo, error) // ListAddresses returns a slice of AddressInfo for all addresses in a // given account. It returns an empty slice if no addresses are found. - ListAddresses(ctx context.Context, query ListAddressesQuery) ( - []AddressInfo, error) - - // MarkAddressAsUsed marks a given address as used. This is used to - // ensure that the address is not reused. - MarkAddressAsUsed(ctx context.Context, - params MarkAddressAsUsedParams) error + ListAddresses(ctx context.Context, query ListAddressesQuery) ([]AddressInfo, + error) - // GetPrivateKey retrieves the private key for a given address. This - // method is ONLY valid for addresses that were imported with a private - // key. It will return an error for derived HD addresses and watch-only - // imports. - GetPrivateKey(ctx context.Context, params GetPrivateKeyParams) ( - *btcec.PrivateKey, error) + // GetAddressSecret retrieves the encrypted secret material for a given + // address. Returns the AddressSecret containing encrypted private key + // and scripts, or an error if the secret does not exist. + GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, + error) // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. @@ -175,8 +183,7 @@ type AddressStore interface { // GetAddressType returns the AddressTypeInfo associated with the given // address type identifier. An error is returned if the type is unknown. - GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, - error) + GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) } // TxStore defines the database actions for managing transaction records. From 491279db4bf37c666d17302d286f5b7e1b3ab482 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sun, 11 Jan 2026 20:11:53 -0300 Subject: [PATCH 378/691] wallet: add addresses SQL migrations --- .../postgres/000006_addresses.down.sql | 4 + .../postgres/000006_addresses.up.sql | 94 ++++++++++++++++++ .../sqlite/000006_addresses.down.sql | 4 + .../migrations/sqlite/000006_addresses.up.sql | 95 +++++++++++++++++++ wallet/internal/db/sqlc/postgres/models.go | 17 ++++ wallet/internal/db/sqlc/sqlite/models.go | 17 ++++ 6 files changed, 231 insertions(+) create mode 100644 wallet/internal/db/migrations/postgres/000006_addresses.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000006_addresses.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000006_addresses.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000006_addresses.up.sql diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.down.sql b/wallet/internal/db/migrations/postgres/000006_addresses.down.sql new file mode 100644 index 0000000000..92ce9f0dd2 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000006_addresses.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TABLE IF EXISTS address_secrets; +DROP TABLE IF EXISTS addresses; diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql new file mode 100644 index 0000000000..639d62150d --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -0,0 +1,94 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Addresses table stores all addresses under each account. Addresses can be +-- either HD-derived (following BIP32/BIP44 derivation paths) or imported from +-- external sources (e.g., watch-only addresses, hardware wallet addresses). +-- +-- The table supports both address types through nullable derivation fields: +-- - HD-derived addresses have address_branch and address_index values +-- - Imported addresses have NULL derivation fields and store pub_key +CREATE TABLE addresses ( + -- DB ID of the address, primary key. + id BIGSERIAL PRIMARY KEY, + + -- Reference to the account this address belongs to. + account_id BIGINT NOT NULL, + + -- Script pubkey that locks funds on-chain (stored in plaintext). + script_pub_key BYTEA NOT NULL, + + -- Reference to the address type (e.g., P2PKH, P2WPKH, P2TR). Determines + -- how the address is encoded and how funds can be spent. + type_id SMALLINT NOT NULL, + + -- Branch number in BIP44 derivation path (typically 0 for external, 1 for + -- internal/change). NULL for imported addresses. + address_branch BIGINT, + + -- Index number in BIP44 derivation path (sequential counter within each + -- branch). NULL for imported addresses. + address_index BIGINT, + + -- Public key for imported addresses (stored in plaintext). NULL for + -- HD-derived addresses since their public keys are derived from the + -- account key. + pub_key BYTEA, + + -- Timestamp when the address was created. Automatically set by the database. + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure + -- that the account cannot be deleted if addresses still exist. + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, + + -- Foreign key constraint to address types. Using ON DELETE RESTRICT to + -- ensure that the address type cannot be deleted if addresses still exist. + FOREIGN KEY (type_id) REFERENCES address_types (id) ON DELETE RESTRICT +); + +-- Unique partial index to prevent duplicate address derivations within the +-- same account. Only enforced when both branch and index are non-NULL +-- (HD-derived addresses). Imported addresses are excluded from this constraint. +CREATE UNIQUE INDEX uidx_addresses_branch_index +ON addresses (account_id, address_branch, address_index) +WHERE address_branch IS NOT NULL +AND address_index IS NOT NULL; + +-- Unique partial index to prevent duplicate script_pub_key within the same +-- account. Only enforced when script_pub_key is non-NULL (imported addresses). +-- Derived addresses are excluded from this constraint. +CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key +ON addresses (account_id, script_pub_key) +WHERE script_pub_key IS NOT NULL; + +-- Index on script_pub_key for efficient lookups by script pubkey. +-- Used by GetAddressByScriptPubKey which joins through accounts to key_scopes. +CREATE INDEX idx_addresses_script_pub_key +ON addresses (script_pub_key) +WHERE script_pub_key IS NOT NULL; + +-- Address Secrets table stores sensitive encrypted material needed to spend +-- from an address. This table has a one-to-one relationship with addresses. +-- Watch-only addresses may have no row in this table. +CREATE TABLE address_secrets ( + -- Reference to the address these secrets belong to. + address_id BIGINT NOT NULL, + + -- Encrypted private key for imported addresses. NULL for HD-derived + -- addresses since their private keys are derived from the account key. + encrypted_priv_key BYTEA, + + -- Encrypted script for script-based addresses (P2SH, P2WSH). Contains the + -- redeem script or witness script needed to spend the output. + encrypted_script BYTEA, + + -- Foreign key constraint to addresses. Using ON DELETE RESTRICT to ensure + -- that the address cannot be deleted if secrets still exist. + FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT +); + +-- Unique index to ensure one-to-one relationship between address and its +-- secrets. +CREATE UNIQUE INDEX uidx_address_secrets_address +ON address_secrets (address_id); diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql new file mode 100644 index 0000000000..92ce9f0dd2 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TABLE IF EXISTS address_secrets; +DROP TABLE IF EXISTS addresses; diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql new file mode 100644 index 0000000000..3d3b6e50a5 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -0,0 +1,95 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Addresses table stores all addresses under each account. Addresses can be +-- either HD-derived (following BIP32/BIP44 derivation paths) or imported from +-- external sources (e.g., watch-only addresses, hardware wallet addresses). +-- +-- The table supports both address types through nullable derivation fields: +-- - HD-derived addresses have address_branch and address_index values +-- - Imported addresses have NULL derivation fields and store pub_key +CREATE TABLE addresses ( + -- DB ID of the address, primary key. + id INTEGER PRIMARY KEY, + + -- Reference to the account this address belongs to. + account_id INTEGER NOT NULL, + + -- Script pubkey that locks funds on-chain (stored in plaintext). + script_pub_key BLOB NOT NULL, + + -- Reference to the address type (e.g., P2PKH, P2WPKH, P2TR). Determines + -- how the address is encoded and how funds can be spent. + type_id INTEGER NOT NULL, + + -- Branch number in BIP44 derivation path (typically 0 for external, 1 for + -- internal/change). NULL for imported addresses. + address_branch INTEGER, + + -- Index number in BIP44 derivation path (sequential counter within each + -- branch). NULL for imported addresses. + address_index INTEGER, + + -- Public key for imported addresses (stored in plaintext). NULL for + -- HD-derived addresses since their public keys are derived from the + -- account key. + pub_key BLOB, + + -- Timestamp when the address was created. Automatically set by the database. + created_at DATETIME NOT NULL DEFAULT current_timestamp, + + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure + -- that the account cannot be deleted if addresses still exist. + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, + + -- Foreign key constraint to address types. Using ON DELETE RESTRICT to + -- ensure that the address type cannot be deleted if addresses still exist. + FOREIGN KEY (type_id) REFERENCES address_types (id) ON DELETE RESTRICT +); + +-- Unique partial index to prevent duplicate address derivations within the +-- same account. Only enforced when both branch and index are non-NULL +-- (HD-derived addresses). Imported addresses are excluded from this constraint. +CREATE UNIQUE INDEX uidx_addresses_branch_index +ON addresses (account_id, address_branch, address_index) +WHERE + address_branch IS NOT NULL + AND address_index IS NOT NULL; + +-- Unique partial index to prevent duplicate script_pub_key within the same +-- account. Only enforced when script_pub_key is non-NULL (imported addresses). +-- Derived addresses are excluded from this constraint. +CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key +ON addresses (account_id, script_pub_key) +WHERE script_pub_key IS NOT NULL; + +-- Index on script_pub_key for efficient lookups by script pubkey. +-- Used by GetAddressByScriptPubKey which joins through accounts to key_scopes. +CREATE INDEX idx_addresses_script_pub_key +ON addresses (script_pub_key) +WHERE script_pub_key IS NOT NULL; + +-- Address Secrets table stores sensitive encrypted material needed to spend +-- from an address. This table has a one-to-one relationship with addresses. +-- Watch-only addresses may have no row in this table. +CREATE TABLE address_secrets ( + -- Reference to the address these secrets belong to. + address_id INTEGER NOT NULL, + + -- Encrypted private key for imported addresses. NULL for HD-derived + -- addresses since their private keys are derived from the account key. + encrypted_priv_key BLOB, + + -- Encrypted script for script-based addresses (P2SH, P2WSH). Contains the + -- redeem script or witness script needed to spend the output. + encrypted_script BLOB, + + -- Foreign key constraint to addresses. Using ON DELETE RESTRICT to ensure + -- that the address cannot be deleted if secrets still exist. + FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT +); + +-- Unique index to ensure one-to-one relationship between address and its +-- secrets. +CREATE UNIQUE INDEX uidx_address_secrets_address +ON address_secrets (address_id); diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 4c4bdc252c..31e2c52f9f 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -31,6 +31,23 @@ type AccountSecret struct { EncryptedPrivateKey []byte } +type Address struct { + ID int64 + AccountID int64 + ScriptPubKey []byte + TypeID int16 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + PubKey []byte + CreatedAt time.Time +} + +type AddressSecret struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + type AddressType struct { ID int16 Description string diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 9427d3abb3..215b2f1e7d 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -31,6 +31,23 @@ type AccountSecret struct { EncryptedPrivateKey []byte } +type Address struct { + ID int64 + AccountID int64 + ScriptPubKey []byte + TypeID int64 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + PubKey []byte + CreatedAt time.Time +} + +type AddressSecret struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + type AddressType struct { ID int64 Description string From 7b1b6c5b4387595fdc3527a8f7737265bf3d553c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 22 Jan 2026 00:02:07 -0300 Subject: [PATCH 379/691] wallet: add addresses SQL queries --- .../db/queries/postgres/addresses.sql | 94 ++++++ .../internal/db/queries/sqlite/addresses.sql | 94 ++++++ .../db/sqlc/postgres/addresses.sql.go | 288 ++++++++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 60 ++++ wallet/internal/db/sqlc/postgres/querier.go | 19 ++ .../internal/db/sqlc/sqlite/addresses.sql.go | 288 ++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 60 ++++ wallet/internal/db/sqlc/sqlite/querier.go | 19 ++ 8 files changed, 922 insertions(+) create mode 100644 wallet/internal/db/queries/postgres/addresses.sql create mode 100644 wallet/internal/db/queries/sqlite/addresses.sql create mode 100644 wallet/internal/db/sqlc/postgres/addresses.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/addresses.sql.go diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/db/queries/postgres/addresses.sql new file mode 100644 index 0000000000..710a2ed983 --- /dev/null +++ b/wallet/internal/db/queries/postgres/addresses.sql @@ -0,0 +1,94 @@ +-- name: InsertAddressSecret :exec +-- Inserts address secret information (private key, script) for imported addresses. +-- Not used for derived addresses (their keys are derived from account key). +INSERT INTO address_secrets ( + address_id, + encrypted_priv_key, + encrypted_script +) VALUES ( + $1, $2, $3 +); + +-- name: GetAddressByScriptPubKey :one +-- Retrieves an address by its script pubkey and account wallet. +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.script_pub_key = $1 AND ks.wallet_id = $2; + +-- name: GetAddressSecret :one +-- Retrieves secret information for an address. Uses LEFT JOIN to distinguish: +-- - Address exists with secret: returns full row +-- - Address exists without secret (watch-only/derived): returns row with NULL secret fields +-- - Address does not exist: returns no rows (sql.ErrNoRows) +SELECT + a.id AS address_id, + s.encrypted_priv_key, + s.encrypted_script +FROM addresses AS a +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.id = $1; + +-- name: CreateDerivedAddress :one +-- Creates a derived address with the given index and derived data. +-- The index is allocated separately via GetAndIncrementNextAddressIndex. +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, created_at; + +-- name: CreateImportedAddress :one +-- Creates an imported address (no derivation path, has script/pubkey). +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ( + $1, $2, $3, NULL, NULL, $4 +) +RETURNING id, created_at; + +-- name: ListAddressesByAccount :many +-- Lists all addresses for a given account identified by wallet_id, key scope +-- (purpose/coin_type), and account name. Returns all address columns for +-- filtering and processing by the application. +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE + ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 + AND acc.account_name = $4; diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/db/queries/sqlite/addresses.sql new file mode 100644 index 0000000000..81799b396a --- /dev/null +++ b/wallet/internal/db/queries/sqlite/addresses.sql @@ -0,0 +1,94 @@ +-- name: InsertAddressSecret :exec +-- Inserts address secret information (private key, script) for imported addresses. +-- Not used for derived addresses (their keys are derived from account key). +INSERT INTO address_secrets ( + address_id, + encrypted_priv_key, + encrypted_script +) VALUES ( + ?, ?, ? +); + +-- name: GetAddressByScriptPubKey :one +-- Retrieves an address by its script pubkey and account wallet. +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + s.encrypted_priv_key IS NOT NULL AS has_private_key, + s.encrypted_script IS NOT NULL AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.script_pub_key = ? AND ks.wallet_id = ?; + +-- name: GetAddressSecret :one +-- Retrieves secret information for an address. Uses LEFT JOIN to distinguish: +-- - Address exists with secret: returns full row +-- - Address exists without secret (watch-only/derived): returns row with NULL secret fields +-- - Address does not exist: returns no rows (sql.ErrNoRows) +SELECT + a.id AS address_id, + s.encrypted_priv_key, + s.encrypted_script +FROM addresses AS a +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.id = ?; + +-- name: CreateDerivedAddress :one +-- Creates a derived address with the given index and derived data. +-- The index is allocated separately via GetAndIncrementNextAddressIndex. +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES (?1, ?2, ?3, ?4, ?5, ?6) +RETURNING id, created_at; + +-- name: CreateImportedAddress :one +-- Creates an imported address (no derivation path, has script/pubkey). +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ( + ?1, ?2, ?3, NULL, NULL, ?4 +) +RETURNING id, created_at; + +-- name: ListAddressesByAccount :many +-- Lists all addresses for a given account identified by wallet_id, key scope +-- (purpose/coin_type), and account name. Returns all address columns for +-- filtering and processing by the application. +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + s.encrypted_priv_key IS NOT NULL AS has_private_key, + s.encrypted_script IS NOT NULL AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE + ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? + AND acc.account_name = ?; diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go new file mode 100644 index 0000000000..d282bfe308 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -0,0 +1,288 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: addresses.sql + +package sqlcpg + +import ( + "context" + "database/sql" + "time" +) + +const CreateDerivedAddress = `-- name: CreateDerivedAddress :one +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, created_at +` + +type CreateDerivedAddressParams struct { + AccountID int64 + ScriptPubKey []byte + TypeID int16 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + PubKey []byte +} + +type CreateDerivedAddressRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates a derived address with the given index and derived data. +// The index is allocated separately via GetAndIncrementNextAddressIndex. +func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { + row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, + arg.AccountID, + arg.ScriptPubKey, + arg.TypeID, + arg.AddressBranch, + arg.AddressIndex, + arg.PubKey, + ) + var i CreateDerivedAddressRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const CreateImportedAddress = `-- name: CreateImportedAddress :one +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ( + $1, $2, $3, NULL, NULL, $4 +) +RETURNING id, created_at +` + +type CreateImportedAddressParams struct { + AccountID int64 + ScriptPubKey []byte + TypeID int16 + PubKey []byte +} + +type CreateImportedAddressRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates an imported address (no derivation path, has script/pubkey). +func (q *Queries) CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) { + row := q.queryRow(ctx, q.createImportedAddressStmt, CreateImportedAddress, + arg.AccountID, + arg.ScriptPubKey, + arg.TypeID, + arg.PubKey, + ) + var i CreateImportedAddressRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const GetAddressByScriptPubKey = `-- name: GetAddressByScriptPubKey :one +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.script_pub_key = $1 AND ks.wallet_id = $2 +` + +type GetAddressByScriptPubKeyParams struct { + ScriptPubKey []byte + WalletID int64 +} + +type GetAddressByScriptPubKeyRow struct { + ID int64 + AccountID int64 + TypeID int16 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + ScriptPubKey []byte + PubKey []byte + CreatedAt time.Time + OriginID int16 + HasPrivateKey bool + HasScript bool +} + +// Retrieves an address by its script pubkey and account wallet. +func (q *Queries) GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) { + row := q.queryRow(ctx, q.getAddressByScriptPubKeyStmt, GetAddressByScriptPubKey, arg.ScriptPubKey, arg.WalletID) + var i GetAddressByScriptPubKeyRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.TypeID, + &i.AddressBranch, + &i.AddressIndex, + &i.ScriptPubKey, + &i.PubKey, + &i.CreatedAt, + &i.OriginID, + &i.HasPrivateKey, + &i.HasScript, + ) + return i, err +} + +const GetAddressSecret = `-- name: GetAddressSecret :one +SELECT + a.id AS address_id, + s.encrypted_priv_key, + s.encrypted_script +FROM addresses AS a +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.id = $1 +` + +type GetAddressSecretRow struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + +// Retrieves secret information for an address. Uses LEFT JOIN to distinguish: +// - Address exists with secret: returns full row +// - Address exists without secret (watch-only/derived): returns row with NULL secret fields +// - Address does not exist: returns no rows (sql.ErrNoRows) +func (q *Queries) GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) { + row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, id) + var i GetAddressSecretRow + err := row.Scan(&i.AddressID, &i.EncryptedPrivKey, &i.EncryptedScript) + return i, err +} + +const InsertAddressSecret = `-- name: InsertAddressSecret :exec +INSERT INTO address_secrets ( + address_id, + encrypted_priv_key, + encrypted_script +) VALUES ( + $1, $2, $3 +) +` + +type InsertAddressSecretParams struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + +// Inserts address secret information (private key, script) for imported addresses. +// Not used for derived addresses (their keys are derived from account key). +func (q *Queries) InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error { + _, err := q.exec(ctx, q.insertAddressSecretStmt, InsertAddressSecret, arg.AddressID, arg.EncryptedPrivKey, arg.EncryptedScript) + return err +} + +const ListAddressesByAccount = `-- name: ListAddressesByAccount :many +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + (s.encrypted_priv_key IS NOT NULL)::BOOLEAN AS has_private_key, + (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE + ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 + AND acc.account_name = $4 +` + +type ListAddressesByAccountParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string +} + +type ListAddressesByAccountRow struct { + ID int64 + AccountID int64 + TypeID int16 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + ScriptPubKey []byte + PubKey []byte + CreatedAt time.Time + OriginID int16 + HasPrivateKey bool + HasScript bool +} + +// Lists all addresses for a given account identified by wallet_id, key scope +// (purpose/coin_type), and account name. Returns all address columns for +// filtering and processing by the application. +func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) { + rows, err := q.query(ctx, q.listAddressesByAccountStmt, ListAddressesByAccount, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountName, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAddressesByAccountRow + for rows.Next() { + var i ListAddressesByAccountRow + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.TypeID, + &i.AddressBranch, + &i.AddressIndex, + &i.ScriptPubKey, + &i.PubKey, + &i.CreatedAt, + &i.OriginID, + &i.HasPrivateKey, + &i.HasScript, + ); 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 +} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 7f9d3d60c6..1534c7b7eb 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -33,9 +33,15 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createDerivedAccountWithNumberStmt, err = db.PrepareContext(ctx, CreateDerivedAccountWithNumber); err != nil { return nil, fmt.Errorf("error preparing query CreateDerivedAccountWithNumber: %w", err) } + if q.createDerivedAddressStmt, err = db.PrepareContext(ctx, CreateDerivedAddress); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAddress: %w", err) + } if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) } + if q.createImportedAddressStmt, err = db.PrepareContext(ctx, CreateImportedAddress); err != nil { + return nil, fmt.Errorf("error preparing query CreateImportedAddress: %w", err) + } if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) } @@ -66,6 +72,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getAddressByScriptPubKeyStmt, err = db.PrepareContext(ctx, GetAddressByScriptPubKey); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressByScriptPubKey: %w", err) + } + if q.getAddressSecretStmt, err = db.PrepareContext(ctx, GetAddressSecret); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressSecret: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } @@ -90,6 +102,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) } + if q.insertAddressSecretStmt, err = db.PrepareContext(ctx, InsertAddressSecret); err != nil { + return nil, fmt.Errorf("error preparing query InsertAddressSecret: %w", err) + } if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } @@ -117,6 +132,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } + if q.listAddressesByAccountStmt, err = db.PrepareContext(ctx, ListAddressesByAccount); err != nil { + return nil, fmt.Errorf("error preparing query ListAddressesByAccount: %w", err) + } if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) } @@ -158,11 +176,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createDerivedAccountWithNumberStmt: %w", cerr) } } + if q.createDerivedAddressStmt != nil { + if cerr := q.createDerivedAddressStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAddressStmt: %w", cerr) + } + } if q.createImportedAccountStmt != nil { if cerr := q.createImportedAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) } } + if q.createImportedAddressStmt != nil { + if cerr := q.createImportedAddressStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createImportedAddressStmt: %w", cerr) + } + } if q.createKeyScopeStmt != nil { if cerr := q.createKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) @@ -213,6 +241,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getAddressByScriptPubKeyStmt != nil { + if cerr := q.getAddressByScriptPubKeyStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressByScriptPubKeyStmt: %w", cerr) + } + } + if q.getAddressSecretStmt != nil { + if cerr := q.getAddressSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressSecretStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -253,6 +291,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) } } + if q.insertAddressSecretStmt != nil { + if cerr := q.insertAddressSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertAddressSecretStmt: %w", cerr) + } + } if q.insertBlockStmt != nil { if cerr := q.insertBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) @@ -298,6 +341,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) } } + if q.listAddressesByAccountStmt != nil { + if cerr := q.listAddressesByAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAddressesByAccountStmt: %w", cerr) + } + } if q.listKeyScopesByWalletStmt != nil { if cerr := q.listKeyScopesByWalletStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) @@ -375,7 +423,9 @@ type Queries struct { createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt + createDerivedAddressStmt *sql.Stmt createImportedAccountStmt *sql.Stmt + createImportedAddressStmt *sql.Stmt createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt @@ -386,6 +436,8 @@ type Queries struct { getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getAddressByScriptPubKeyStmt *sql.Stmt + getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getKeyScopeByIDStmt *sql.Stmt @@ -394,6 +446,7 @@ type Queries struct { getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt + insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt @@ -403,6 +456,7 @@ type Queries struct { listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt listAddressTypesStmt *sql.Stmt + listAddressesByAccountStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt lockAccountScopeStmt *sql.Stmt @@ -419,7 +473,9 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, + createDerivedAddressStmt: q.createDerivedAddressStmt, createImportedAccountStmt: q.createImportedAccountStmt, + createImportedAddressStmt: q.createImportedAddressStmt, createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, @@ -430,6 +486,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, + getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, @@ -438,6 +496,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, + insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, @@ -447,6 +506,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, listAddressTypesStmt: q.listAddressTypesStmt, + listAddressesByAccountStmt: q.listAddressesByAccountStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, lockAccountScopeStmt: q.lockAccountScopeStmt, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 114ed39acf..c48f70db9e 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -19,10 +19,15 @@ type Querier interface { // Test-only: Creates a derived account with a specific account number. // Used for testing account number overflow without creating billions of accounts. CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) + // Creates a derived address with the given index and derived data. + // The index is allocated separately via GetAndIncrementNextAddressIndex. + CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need // a sequential account number. CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) + // Creates an imported address (no derivation path, has script/pubkey). + CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) // Creates a new key scope for a wallet and returns its ID. CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) @@ -41,6 +46,13 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Retrieves an address by its script pubkey and account wallet. + GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) + // Retrieves secret information for an address. Uses LEFT JOIN to distinguish: + // - Address exists with secret: returns full row + // - Address exists without secret (watch-only/derived): returns row with NULL secret fields + // - Address does not exist: returns no rows (sql.ErrNoRows) + GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) @@ -53,6 +65,9 @@ type Querier interface { GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) + // Inserts address secret information (private key, script) for imported addresses. + // Not used for derived addresses (their keys are derived from account key). + InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error InsertBlock(ctx context.Context, arg InsertBlockParams) error // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for // watch-only scopes. @@ -73,6 +88,10 @@ type Querier interface { ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) + // Lists all addresses for a given account identified by wallet_id, key scope + // (purpose/coin_type), and account name. Returns all address columns for + // filtering and processing by the application. + ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/db/sqlc/sqlite/addresses.sql.go new file mode 100644 index 0000000000..ebae0bb83f --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/addresses.sql.go @@ -0,0 +1,288 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: addresses.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" + "time" +) + +const CreateDerivedAddress = `-- name: CreateDerivedAddress :one +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES (?1, ?2, ?3, ?4, ?5, ?6) +RETURNING id, created_at +` + +type CreateDerivedAddressParams struct { + AccountID int64 + ScriptPubKey []byte + TypeID int64 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + PubKey []byte +} + +type CreateDerivedAddressRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates a derived address with the given index and derived data. +// The index is allocated separately via GetAndIncrementNextAddressIndex. +func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { + row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, + arg.AccountID, + arg.ScriptPubKey, + arg.TypeID, + arg.AddressBranch, + arg.AddressIndex, + arg.PubKey, + ) + var i CreateDerivedAddressRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const CreateImportedAddress = `-- name: CreateImportedAddress :one +INSERT INTO addresses ( + account_id, + script_pub_key, + type_id, + address_branch, + address_index, + pub_key +) VALUES ( + ?1, ?2, ?3, NULL, NULL, ?4 +) +RETURNING id, created_at +` + +type CreateImportedAddressParams struct { + AccountID int64 + ScriptPubKey []byte + TypeID int64 + PubKey []byte +} + +type CreateImportedAddressRow struct { + ID int64 + CreatedAt time.Time +} + +// Creates an imported address (no derivation path, has script/pubkey). +func (q *Queries) CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) { + row := q.queryRow(ctx, q.createImportedAddressStmt, CreateImportedAddress, + arg.AccountID, + arg.ScriptPubKey, + arg.TypeID, + arg.PubKey, + ) + var i CreateImportedAddressRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const GetAddressByScriptPubKey = `-- name: GetAddressByScriptPubKey :one +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + s.encrypted_priv_key IS NOT NULL AS has_private_key, + s.encrypted_script IS NOT NULL AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.script_pub_key = ? AND ks.wallet_id = ? +` + +type GetAddressByScriptPubKeyParams struct { + ScriptPubKey []byte + WalletID int64 +} + +type GetAddressByScriptPubKeyRow struct { + ID int64 + AccountID int64 + TypeID int64 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + ScriptPubKey []byte + PubKey []byte + CreatedAt time.Time + OriginID int64 + HasPrivateKey bool + HasScript bool +} + +// Retrieves an address by its script pubkey and account wallet. +func (q *Queries) GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) { + row := q.queryRow(ctx, q.getAddressByScriptPubKeyStmt, GetAddressByScriptPubKey, arg.ScriptPubKey, arg.WalletID) + var i GetAddressByScriptPubKeyRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.TypeID, + &i.AddressBranch, + &i.AddressIndex, + &i.ScriptPubKey, + &i.PubKey, + &i.CreatedAt, + &i.OriginID, + &i.HasPrivateKey, + &i.HasScript, + ) + return i, err +} + +const GetAddressSecret = `-- name: GetAddressSecret :one +SELECT + a.id AS address_id, + s.encrypted_priv_key, + s.encrypted_script +FROM addresses AS a +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE a.id = ? +` + +type GetAddressSecretRow struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + +// Retrieves secret information for an address. Uses LEFT JOIN to distinguish: +// - Address exists with secret: returns full row +// - Address exists without secret (watch-only/derived): returns row with NULL secret fields +// - Address does not exist: returns no rows (sql.ErrNoRows) +func (q *Queries) GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) { + row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, id) + var i GetAddressSecretRow + err := row.Scan(&i.AddressID, &i.EncryptedPrivKey, &i.EncryptedScript) + return i, err +} + +const InsertAddressSecret = `-- name: InsertAddressSecret :exec +INSERT INTO address_secrets ( + address_id, + encrypted_priv_key, + encrypted_script +) VALUES ( + ?, ?, ? +) +` + +type InsertAddressSecretParams struct { + AddressID int64 + EncryptedPrivKey []byte + EncryptedScript []byte +} + +// Inserts address secret information (private key, script) for imported addresses. +// Not used for derived addresses (their keys are derived from account key). +func (q *Queries) InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error { + _, err := q.exec(ctx, q.insertAddressSecretStmt, InsertAddressSecret, arg.AddressID, arg.EncryptedPrivKey, arg.EncryptedScript) + return err +} + +const ListAddressesByAccount = `-- name: ListAddressesByAccount :many +SELECT + a.id, + a.account_id, + a.type_id, + a.address_branch, + a.address_index, + a.script_pub_key, + a.pub_key, + a.created_at, + acc.origin_id, + s.encrypted_priv_key IS NOT NULL AS has_private_key, + s.encrypted_script IS NOT NULL AS has_script +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN address_secrets AS s ON a.id = s.address_id +WHERE + ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? + AND acc.account_name = ? +` + +type ListAddressesByAccountParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string +} + +type ListAddressesByAccountRow struct { + ID int64 + AccountID int64 + TypeID int64 + AddressBranch sql.NullInt64 + AddressIndex sql.NullInt64 + ScriptPubKey []byte + PubKey []byte + CreatedAt time.Time + OriginID int64 + HasPrivateKey bool + HasScript bool +} + +// Lists all addresses for a given account identified by wallet_id, key scope +// (purpose/coin_type), and account name. Returns all address columns for +// filtering and processing by the application. +func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) { + rows, err := q.query(ctx, q.listAddressesByAccountStmt, ListAddressesByAccount, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountName, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAddressesByAccountRow + for rows.Next() { + var i ListAddressesByAccountRow + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.TypeID, + &i.AddressBranch, + &i.AddressIndex, + &i.ScriptPubKey, + &i.PubKey, + &i.CreatedAt, + &i.OriginID, + &i.HasPrivateKey, + &i.HasScript, + ); 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 +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 2b4a10c8cd..bc5f723a86 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -33,9 +33,15 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createDerivedAccountWithNumberStmt, err = db.PrepareContext(ctx, CreateDerivedAccountWithNumber); err != nil { return nil, fmt.Errorf("error preparing query CreateDerivedAccountWithNumber: %w", err) } + if q.createDerivedAddressStmt, err = db.PrepareContext(ctx, CreateDerivedAddress); err != nil { + return nil, fmt.Errorf("error preparing query CreateDerivedAddress: %w", err) + } if q.createImportedAccountStmt, err = db.PrepareContext(ctx, CreateImportedAccount); err != nil { return nil, fmt.Errorf("error preparing query CreateImportedAccount: %w", err) } + if q.createImportedAddressStmt, err = db.PrepareContext(ctx, CreateImportedAddress); err != nil { + return nil, fmt.Errorf("error preparing query CreateImportedAddress: %w", err) + } if q.createKeyScopeStmt, err = db.PrepareContext(ctx, CreateKeyScope); err != nil { return nil, fmt.Errorf("error preparing query CreateKeyScope: %w", err) } @@ -66,6 +72,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getAddressByScriptPubKeyStmt, err = db.PrepareContext(ctx, GetAddressByScriptPubKey); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressByScriptPubKey: %w", err) + } + if q.getAddressSecretStmt, err = db.PrepareContext(ctx, GetAddressSecret); err != nil { + return nil, fmt.Errorf("error preparing query GetAddressSecret: %w", err) + } if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } @@ -90,6 +102,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) } + if q.insertAddressSecretStmt, err = db.PrepareContext(ctx, InsertAddressSecret); err != nil { + return nil, fmt.Errorf("error preparing query InsertAddressSecret: %w", err) + } if q.insertBlockStmt, err = db.PrepareContext(ctx, InsertBlock); err != nil { return nil, fmt.Errorf("error preparing query InsertBlock: %w", err) } @@ -117,6 +132,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } + if q.listAddressesByAccountStmt, err = db.PrepareContext(ctx, ListAddressesByAccount); err != nil { + return nil, fmt.Errorf("error preparing query ListAddressesByAccount: %w", err) + } if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) } @@ -155,11 +173,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createDerivedAccountWithNumberStmt: %w", cerr) } } + if q.createDerivedAddressStmt != nil { + if cerr := q.createDerivedAddressStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createDerivedAddressStmt: %w", cerr) + } + } if q.createImportedAccountStmt != nil { if cerr := q.createImportedAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createImportedAccountStmt: %w", cerr) } } + if q.createImportedAddressStmt != nil { + if cerr := q.createImportedAddressStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createImportedAddressStmt: %w", cerr) + } + } if q.createKeyScopeStmt != nil { if cerr := q.createKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createKeyScopeStmt: %w", cerr) @@ -210,6 +238,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getAddressByScriptPubKeyStmt != nil { + if cerr := q.getAddressByScriptPubKeyStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressByScriptPubKeyStmt: %w", cerr) + } + } + if q.getAddressSecretStmt != nil { + if cerr := q.getAddressSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAddressSecretStmt: %w", cerr) + } + } if q.getAddressTypeByIDStmt != nil { if cerr := q.getAddressTypeByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) @@ -250,6 +288,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) } } + if q.insertAddressSecretStmt != nil { + if cerr := q.insertAddressSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertAddressSecretStmt: %w", cerr) + } + } if q.insertBlockStmt != nil { if cerr := q.insertBlockStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertBlockStmt: %w", cerr) @@ -295,6 +338,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) } } + if q.listAddressesByAccountStmt != nil { + if cerr := q.listAddressesByAccountStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAddressesByAccountStmt: %w", cerr) + } + } if q.listKeyScopesByWalletStmt != nil { if cerr := q.listKeyScopesByWalletStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) @@ -367,7 +415,9 @@ type Queries struct { createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt + createDerivedAddressStmt *sql.Stmt createImportedAccountStmt *sql.Stmt + createImportedAddressStmt *sql.Stmt createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt @@ -378,6 +428,8 @@ type Queries struct { getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getAddressByScriptPubKeyStmt *sql.Stmt + getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getKeyScopeByIDStmt *sql.Stmt @@ -386,6 +438,7 @@ type Queries struct { getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt + insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt @@ -395,6 +448,7 @@ type Queries struct { listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt listAddressTypesStmt *sql.Stmt + listAddressesByAccountStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt listWalletsStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt @@ -410,7 +464,9 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, + createDerivedAddressStmt: q.createDerivedAddressStmt, createImportedAccountStmt: q.createImportedAccountStmt, + createImportedAddressStmt: q.createImportedAddressStmt, createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, @@ -421,6 +477,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, + getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, @@ -429,6 +487,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, + insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, @@ -438,6 +497,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, listAddressTypesStmt: q.listAddressTypesStmt, + listAddressesByAccountStmt: q.listAddressesByAccountStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, listWalletsStmt: q.listWalletsStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 742741b180..cf8690a7a8 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -18,10 +18,15 @@ type Querier interface { // Test-only: Creates a derived account with a specific account number. // Used for testing account number overflow without creating billions of accounts. CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) + // Creates a derived address with the given index and derived data. + // The index is allocated separately via GetAndIncrementNextAddressIndex. + CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need // a sequential account number. CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) + // Creates an imported address (no derivation path, has script/pubkey). + CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) // Creates a new key scope for a wallet and returns its ID. CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) @@ -40,6 +45,13 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Retrieves an address by its script pubkey and account wallet. + GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) + // Retrieves secret information for an address. Uses LEFT JOIN to distinguish: + // - Address exists with secret: returns full row + // - Address exists without secret (watch-only/derived): returns row with NULL secret fields + // - Address does not exist: returns no rows (sql.ErrNoRows) + GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) @@ -52,6 +64,9 @@ type Querier interface { GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) + // Inserts address secret information (private key, script) for imported addresses. + // Not used for derived addresses (their keys are derived from account key). + InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error InsertBlock(ctx context.Context, arg InsertBlockParams) error // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for // watch-only scopes. @@ -72,6 +87,10 @@ type Querier interface { ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) + // Lists all addresses for a given account identified by wallet_id, key scope + // (purpose/coin_type), and account name. Returns all address columns for + // filtering and processing by the application. + ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) From 47b2ed04f47d74a00232f4ba4c8a8083981b4533 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 22 Jan 2026 13:16:03 -0300 Subject: [PATCH 380/691] wallet: return id in accounts SQL queries --- .../internal/db/queries/postgres/accounts.sql | 8 +++++++ .../internal/db/queries/sqlite/accounts.sql | 8 +++++++ .../internal/db/sqlc/postgres/accounts.sql.go | 24 +++++++++++++++++++ .../internal/db/sqlc/sqlite/accounts.sql.go | 24 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index 9538e355f8..1d3b35ed32 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -78,6 +78,7 @@ INSERT INTO account_secrets ( -- name: GetAccountByScopeAndName :one -- Returns a single account by scope id and account name. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -92,6 +93,7 @@ WHERE a.scope_id = $1 AND a.account_name = $2; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -106,6 +108,7 @@ WHERE a.scope_id = $1 AND a.account_number = $2; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -124,6 +127,7 @@ WHERE -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -161,6 +165,7 @@ WHERE a.id = $1; -- Lists all accounts in a scope, ordered by account number. Imported accounts -- (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -177,6 +182,7 @@ ORDER BY a.account_number NULLS LAST; -- Lists all accounts for a wallet and scope tuple, ordered by account number. -- Imported accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -196,6 +202,7 @@ ORDER BY a.account_number NULLS LAST; -- Lists all accounts for a wallet filtered by account name, ordered by account -- number. Imported accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -212,6 +219,7 @@ ORDER BY a.account_number NULLS LAST; -- Lists all accounts for a wallet, ordered by account number. Imported -- accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index f325be0520..e76c7c4cb8 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -49,6 +49,7 @@ INSERT INTO account_secrets ( -- name: GetAccountByScopeAndName :one -- Returns a single account by scope id and account name. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -63,6 +64,7 @@ WHERE a.scope_id = ? AND a.account_name = ?; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -77,6 +79,7 @@ WHERE a.scope_id = ? AND a.account_number = ?; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -95,6 +98,7 @@ WHERE -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -132,6 +136,7 @@ WHERE a.id = ?; -- Lists all accounts in a scope, ordered by account number. Imported accounts -- (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -148,6 +153,7 @@ ORDER BY a.account_number IS NULL, a.account_number; -- Lists all accounts for a wallet and scope tuple, ordered by account number. -- Imported accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -167,6 +173,7 @@ ORDER BY a.account_number IS NULL, a.account_number; -- Lists all accounts for a wallet filtered by account name, ordered by account -- number. Imported accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -183,6 +190,7 @@ ORDER BY a.account_number IS NULL, a.account_number; -- Lists all accounts for a wallet, ordered by account number. Imported -- accounts (with NULL account_number) appear last. SELECT + a.id, a.account_number, a.account_name, a.origin_id, diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index 0d2e667312..b208788cdb 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -174,6 +174,7 @@ func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedA const GetAccountByScopeAndName = `-- name: GetAccountByScopeAndName :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -192,6 +193,7 @@ type GetAccountByScopeAndNameParams struct { } type GetAccountByScopeAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -206,6 +208,7 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy row := q.queryRow(ctx, q.getAccountByScopeAndNameStmt, GetAccountByScopeAndName, arg.ScopeID, arg.AccountName) var i GetAccountByScopeAndNameRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -219,6 +222,7 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy const GetAccountByScopeAndNumber = `-- name: GetAccountByScopeAndNumber :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -237,6 +241,7 @@ type GetAccountByScopeAndNumberParams struct { } type GetAccountByScopeAndNumberRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -251,6 +256,7 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount row := q.queryRow(ctx, q.getAccountByScopeAndNumberStmt, GetAccountByScopeAndNumber, arg.ScopeID, arg.AccountNumber) var i GetAccountByScopeAndNumberRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -264,6 +270,7 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount const GetAccountByWalletScopeAndName = `-- name: GetAccountByWalletScopeAndName :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -288,6 +295,7 @@ type GetAccountByWalletScopeAndNameParams struct { } type GetAccountByWalletScopeAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -307,6 +315,7 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc ) var i GetAccountByWalletScopeAndNameRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -320,6 +329,7 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc const GetAccountByWalletScopeAndNumber = `-- name: GetAccountByWalletScopeAndNumber :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -344,6 +354,7 @@ type GetAccountByWalletScopeAndNumberParams struct { } type GetAccountByWalletScopeAndNumberRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -363,6 +374,7 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA ) var i GetAccountByWalletScopeAndNumberRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -428,6 +440,7 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount const ListAccountsByScope = `-- name: ListAccountsByScope :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -442,6 +455,7 @@ ORDER BY a.account_number NULLS LAST ` type ListAccountsByScopeRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -463,6 +477,7 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis for rows.Next() { var i ListAccountsByScopeRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -486,6 +501,7 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis const ListAccountsByWallet = `-- name: ListAccountsByWallet :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -500,6 +516,7 @@ ORDER BY a.account_number NULLS LAST ` type ListAccountsByWalletRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -521,6 +538,7 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L for rows.Next() { var i ListAccountsByWalletRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -544,6 +562,7 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L const ListAccountsByWalletAndName = `-- name: ListAccountsByWalletAndName :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -563,6 +582,7 @@ type ListAccountsByWalletAndNameParams struct { } type ListAccountsByWalletAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -584,6 +604,7 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou for rows.Next() { var i ListAccountsByWalletAndNameRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -607,6 +628,7 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou const ListAccountsByWalletScope = `-- name: ListAccountsByWalletScope :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -630,6 +652,7 @@ type ListAccountsByWalletScopeParams struct { } type ListAccountsByWalletScopeRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 @@ -651,6 +674,7 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount for rows.Next() { var i ListAccountsByWalletScopeRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 90a939d402..b0f2631db9 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -172,6 +172,7 @@ func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedA const GetAccountByScopeAndName = `-- name: GetAccountByScopeAndName :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -190,6 +191,7 @@ type GetAccountByScopeAndNameParams struct { } type GetAccountByScopeAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -204,6 +206,7 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy row := q.queryRow(ctx, q.getAccountByScopeAndNameStmt, GetAccountByScopeAndName, arg.ScopeID, arg.AccountName) var i GetAccountByScopeAndNameRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -217,6 +220,7 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy const GetAccountByScopeAndNumber = `-- name: GetAccountByScopeAndNumber :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -235,6 +239,7 @@ type GetAccountByScopeAndNumberParams struct { } type GetAccountByScopeAndNumberRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -249,6 +254,7 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount row := q.queryRow(ctx, q.getAccountByScopeAndNumberStmt, GetAccountByScopeAndNumber, arg.ScopeID, arg.AccountNumber) var i GetAccountByScopeAndNumberRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -262,6 +268,7 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount const GetAccountByWalletScopeAndName = `-- name: GetAccountByWalletScopeAndName :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -286,6 +293,7 @@ type GetAccountByWalletScopeAndNameParams struct { } type GetAccountByWalletScopeAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -305,6 +313,7 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc ) var i GetAccountByWalletScopeAndNameRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -318,6 +327,7 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc const GetAccountByWalletScopeAndNumber = `-- name: GetAccountByWalletScopeAndNumber :one SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -342,6 +352,7 @@ type GetAccountByWalletScopeAndNumberParams struct { } type GetAccountByWalletScopeAndNumberRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -361,6 +372,7 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA ) var i GetAccountByWalletScopeAndNumberRow err := row.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -426,6 +438,7 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount const ListAccountsByScope = `-- name: ListAccountsByScope :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -440,6 +453,7 @@ ORDER BY a.account_number IS NULL, a.account_number ` type ListAccountsByScopeRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -461,6 +475,7 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis for rows.Next() { var i ListAccountsByScopeRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -484,6 +499,7 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis const ListAccountsByWallet = `-- name: ListAccountsByWallet :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -498,6 +514,7 @@ ORDER BY a.account_number IS NULL, a.account_number ` type ListAccountsByWalletRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -519,6 +536,7 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L for rows.Next() { var i ListAccountsByWalletRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -542,6 +560,7 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L const ListAccountsByWalletAndName = `-- name: ListAccountsByWalletAndName :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -561,6 +580,7 @@ type ListAccountsByWalletAndNameParams struct { } type ListAccountsByWalletAndNameRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -582,6 +602,7 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou for rows.Next() { var i ListAccountsByWalletAndNameRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, @@ -605,6 +626,7 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou const ListAccountsByWalletScope = `-- name: ListAccountsByWalletScope :many SELECT + a.id, a.account_number, a.account_name, a.origin_id, @@ -628,6 +650,7 @@ type ListAccountsByWalletScopeParams struct { } type ListAccountsByWalletScopeRow struct { + ID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 @@ -649,6 +672,7 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount for rows.Next() { var i ListAccountsByWalletScopeRow if err := rows.Scan( + &i.ID, &i.AccountNumber, &i.AccountName, &i.OriginID, From 6c40e84f6265520d249fb6b8ed9d2d7fe8eba902 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 5 Feb 2026 16:12:53 -0300 Subject: [PATCH 381/691] wallet: add accounts next address index --- .../postgres/000005_accounts.up.sql | 6 ++ .../migrations/sqlite/000005_accounts.up.sql | 6 ++ .../internal/db/queries/postgres/accounts.sql | 30 ++++++++ .../internal/db/queries/sqlite/accounts.sql | 30 ++++++++ .../internal/db/sqlc/postgres/accounts.sql.go | 68 +++++++++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 40 +++++++++++ wallet/internal/db/sqlc/postgres/models.go | 2 + wallet/internal/db/sqlc/postgres/querier.go | 12 ++++ .../internal/db/sqlc/sqlite/accounts.sql.go | 68 +++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 40 +++++++++++ wallet/internal/db/sqlc/sqlite/models.go | 2 + wallet/internal/db/sqlc/sqlite/querier.go | 12 ++++ 12 files changed, 316 insertions(+) diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index c51ea1f95e..cfe297ce26 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -64,6 +64,12 @@ CREATE TABLE accounts ( -- Timestamp when the account was created. Automatically set by the database. created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + -- Next index to use for external addresses (branch 0) + next_external_index BIGINT NOT NULL DEFAULT 0, + + -- Next index to use for internal/change addresses (branch 1) + next_internal_index BIGINT NOT NULL DEFAULT 0, + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql index 0644de5ae4..399104ce8f 100644 --- a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -64,6 +64,12 @@ CREATE TABLE accounts ( -- Timestamp when the account was created. Automatically set by the database. created_at DATETIME NOT NULL DEFAULT current_timestamp, + -- Next index to use for external addresses (branch 0) + next_external_index INTEGER NOT NULL DEFAULT 0, + + -- Next index to use for internal/change addresses (branch 1) + next_internal_index INTEGER NOT NULL DEFAULT 0, + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index 1d3b35ed32..9da524fa30 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -274,3 +274,33 @@ INSERT INTO accounts ( ) VALUES ($1, $2, $3, $4, $5) RETURNING id, account_number, created_at; + +-- name: GetAndIncrementNextExternalIndex :one +-- Atomically gets the next external address index and increments the counter. +-- Returns the current index value (before incrementing) for the address derivation. +UPDATE accounts +SET next_external_index = next_external_index + 1 +WHERE id = $1 +RETURNING (next_external_index - 1)::BIGINT AS address_index; + +-- name: GetAndIncrementNextInternalIndex :one +-- Atomically gets the next internal/change address index and increments the counter. +-- Returns the current index value (before incrementing) for the address derivation. +UPDATE accounts +SET next_internal_index = next_internal_index + 1 +WHERE id = $1 +RETURNING (next_internal_index - 1)::BIGINT AS address_index; + +-- name: UpdateAccountNextExternalIndex :exec +-- Updates the next_external_index counter for an account. Used in tests +-- to set up specific index scenarios. +UPDATE accounts +SET next_external_index = $2 +WHERE id = $1; + +-- name: UpdateAccountNextInternalIndex :exec +-- Updates the next_internal_index counter for an account. Used in tests +-- to set up specific index scenarios. +UPDATE accounts +SET next_internal_index = $2 +WHERE id = $1; diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index e76c7c4cb8..f6bfeb5f10 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -245,3 +245,33 @@ INSERT INTO accounts ( ) VALUES (?, ?, ?, ?, ?) RETURNING id, account_number, created_at; + +-- name: GetAndIncrementNextExternalIndex :one +-- Atomically gets the next external address index and increments the counter. +-- Returns the current index value (before incrementing) for the address derivation. +UPDATE accounts +SET next_external_index = next_external_index + 1 +WHERE id = ? +RETURNING next_external_index - 1 AS address_index; + +-- name: GetAndIncrementNextInternalIndex :one +-- Atomically gets the next internal/change address index and increments the counter. +-- Returns the current index value (before incrementing) for the address derivation. +UPDATE accounts +SET next_internal_index = next_internal_index + 1 +WHERE id = ? +RETURNING next_internal_index - 1 AS address_index; + +-- name: UpdateAccountNextExternalIndex :exec +-- Updates the next_external_index counter for an account. Used in tests +-- to set up specific index scenarios. +UPDATE accounts +SET next_external_index = ?2 +WHERE id = ?1; + +-- name: UpdateAccountNextInternalIndex :exec +-- Updates the next_internal_index counter for an account. Used in tests +-- to set up specific index scenarios. +UPDATE accounts +SET next_internal_index = ?2 +WHERE id = ?1; diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index b208788cdb..e6998c7c81 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -438,6 +438,38 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount return i, err } +const GetAndIncrementNextExternalIndex = `-- name: GetAndIncrementNextExternalIndex :one +UPDATE accounts +SET next_external_index = next_external_index + 1 +WHERE id = $1 +RETURNING (next_external_index - 1)::BIGINT AS address_index +` + +// Atomically gets the next external address index and increments the counter. +// Returns the current index value (before incrementing) for the address derivation. +func (q *Queries) GetAndIncrementNextExternalIndex(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getAndIncrementNextExternalIndexStmt, GetAndIncrementNextExternalIndex, id) + var address_index int64 + err := row.Scan(&address_index) + return address_index, err +} + +const GetAndIncrementNextInternalIndex = `-- name: GetAndIncrementNextInternalIndex :one +UPDATE accounts +SET next_internal_index = next_internal_index + 1 +WHERE id = $1 +RETURNING (next_internal_index - 1)::BIGINT AS address_index +` + +// Atomically gets the next internal/change address index and increments the counter. +// Returns the current index value (before incrementing) for the address derivation. +func (q *Queries) GetAndIncrementNextInternalIndex(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getAndIncrementNextInternalIndexStmt, GetAndIncrementNextInternalIndex, id) + var address_index int64 + err := row.Scan(&address_index) + return address_index, err +} + const ListAccountsByScope = `-- name: ListAccountsByScope :many SELECT a.id, @@ -803,3 +835,39 @@ func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, a } return result.RowsAffected() } + +const UpdateAccountNextExternalIndex = `-- name: UpdateAccountNextExternalIndex :exec +UPDATE accounts +SET next_external_index = $2 +WHERE id = $1 +` + +type UpdateAccountNextExternalIndexParams struct { + ID int64 + NextExternalIndex int64 +} + +// Updates the next_external_index counter for an account. Used in tests +// to set up specific index scenarios. +func (q *Queries) UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error { + _, err := q.exec(ctx, q.updateAccountNextExternalIndexStmt, UpdateAccountNextExternalIndex, arg.ID, arg.NextExternalIndex) + return err +} + +const UpdateAccountNextInternalIndex = `-- name: UpdateAccountNextInternalIndex :exec +UPDATE accounts +SET next_internal_index = $2 +WHERE id = $1 +` + +type UpdateAccountNextInternalIndexParams struct { + ID int64 + NextInternalIndex int64 +} + +// Updates the next_internal_index counter for an account. Used in tests +// to set up specific index scenarios. +func (q *Queries) UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error { + _, err := q.exec(ctx, q.updateAccountNextInternalIndexStmt, UpdateAccountNextInternalIndex, arg.ID, arg.NextInternalIndex) + return err +} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 1534c7b7eb..07cbfc7f13 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -81,6 +81,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } + if q.getAndIncrementNextExternalIndexStmt, err = db.PrepareContext(ctx, GetAndIncrementNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query GetAndIncrementNextExternalIndex: %w", err) + } + if q.getAndIncrementNextInternalIndexStmt, err = db.PrepareContext(ctx, GetAndIncrementNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query GetAndIncrementNextInternalIndex: %w", err) + } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } @@ -150,6 +156,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } + if q.updateAccountNextExternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNextExternalIndex: %w", err) + } + if q.updateAccountNextInternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNextInternalIndex: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -256,6 +268,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) } } + if q.getAndIncrementNextExternalIndexStmt != nil { + if cerr := q.getAndIncrementNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAndIncrementNextExternalIndexStmt: %w", cerr) + } + } + if q.getAndIncrementNextInternalIndexStmt != nil { + if cerr := q.getAndIncrementNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAndIncrementNextInternalIndexStmt: %w", cerr) + } + } if q.getBlockByHeightStmt != nil { if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) @@ -371,6 +393,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } + if q.updateAccountNextExternalIndexStmt != nil { + if cerr := q.updateAccountNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNextExternalIndexStmt: %w", cerr) + } + } + if q.updateAccountNextInternalIndexStmt != nil { + if cerr := q.updateAccountNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNextInternalIndexStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -439,6 +471,8 @@ type Queries struct { getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt + getAndIncrementNextExternalIndexStmt *sql.Stmt + getAndIncrementNextInternalIndexStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getKeyScopeByIDStmt *sql.Stmt getKeyScopeByWalletAndScopeStmt *sql.Stmt @@ -462,6 +496,8 @@ type Queries struct { lockAccountScopeStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateAccountNextExternalIndexStmt *sql.Stmt + updateAccountNextInternalIndexStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -489,6 +525,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getAndIncrementNextExternalIndexStmt: q.getAndIncrementNextExternalIndexStmt, + getAndIncrementNextInternalIndexStmt: q.getAndIncrementNextInternalIndexStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, @@ -512,6 +550,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { lockAccountScopeStmt: q.lockAccountScopeStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateAccountNextExternalIndexStmt: q.updateAccountNextExternalIndexStmt, + updateAccountNextInternalIndexStmt: q.updateAccountNextInternalIndexStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 31e2c52f9f..3c11ebf44b 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -19,6 +19,8 @@ type Account struct { MasterFingerprint sql.NullInt64 EncryptedPublicKey []byte CreatedAt time.Time + NextExternalIndex int64 + NextInternalIndex int64 } type AccountOrigin struct { diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index c48f70db9e..54f20c5b51 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -55,6 +55,12 @@ type Querier interface { GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) + // Atomically gets the next external address index and increments the counter. + // Returns the current index value (before incrementing) for the address derivation. + GetAndIncrementNextExternalIndex(ctx context.Context, id int64) (int64, error) + // Atomically gets the next internal/change address index and increments the counter. + // Returns the current index value (before incrementing) for the address derivation. + GetAndIncrementNextInternalIndex(ctx context.Context, id int64) (int64, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) // Retrieves a key scope by its ID. GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) @@ -123,6 +129,12 @@ type Querier interface { UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) + // Updates the next_external_index counter for an account. Used in tests + // to set up specific index scenarios. + UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error + // Updates the next_internal_index counter for an account. Used in tests + // to set up specific index scenarios. + UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index b0f2631db9..3eacdc0306 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -436,6 +436,38 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount return i, err } +const GetAndIncrementNextExternalIndex = `-- name: GetAndIncrementNextExternalIndex :one +UPDATE accounts +SET next_external_index = next_external_index + 1 +WHERE id = ? +RETURNING next_external_index - 1 AS address_index +` + +// Atomically gets the next external address index and increments the counter. +// Returns the current index value (before incrementing) for the address derivation. +func (q *Queries) GetAndIncrementNextExternalIndex(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getAndIncrementNextExternalIndexStmt, GetAndIncrementNextExternalIndex, id) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const GetAndIncrementNextInternalIndex = `-- name: GetAndIncrementNextInternalIndex :one +UPDATE accounts +SET next_internal_index = next_internal_index + 1 +WHERE id = ? +RETURNING next_internal_index - 1 AS address_index +` + +// Atomically gets the next internal/change address index and increments the counter. +// Returns the current index value (before incrementing) for the address derivation. +func (q *Queries) GetAndIncrementNextInternalIndex(ctx context.Context, id int64) (int64, error) { + row := q.queryRow(ctx, q.getAndIncrementNextInternalIndexStmt, GetAndIncrementNextInternalIndex, id) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + const ListAccountsByScope = `-- name: ListAccountsByScope :many SELECT a.id, @@ -769,3 +801,39 @@ func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, a } return result.RowsAffected() } + +const UpdateAccountNextExternalIndex = `-- name: UpdateAccountNextExternalIndex :exec +UPDATE accounts +SET next_external_index = ?2 +WHERE id = ?1 +` + +type UpdateAccountNextExternalIndexParams struct { + ID int64 + NextExternalIndex int64 +} + +// Updates the next_external_index counter for an account. Used in tests +// to set up specific index scenarios. +func (q *Queries) UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error { + _, err := q.exec(ctx, q.updateAccountNextExternalIndexStmt, UpdateAccountNextExternalIndex, arg.ID, arg.NextExternalIndex) + return err +} + +const UpdateAccountNextInternalIndex = `-- name: UpdateAccountNextInternalIndex :exec +UPDATE accounts +SET next_internal_index = ?2 +WHERE id = ?1 +` + +type UpdateAccountNextInternalIndexParams struct { + ID int64 + NextInternalIndex int64 +} + +// Updates the next_internal_index counter for an account. Used in tests +// to set up specific index scenarios. +func (q *Queries) UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error { + _, err := q.exec(ctx, q.updateAccountNextInternalIndexStmt, UpdateAccountNextInternalIndex, arg.ID, arg.NextInternalIndex) + return err +} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index bc5f723a86..25ad7c5f16 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -81,6 +81,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAddressTypeByIDStmt, err = db.PrepareContext(ctx, GetAddressTypeByID); err != nil { return nil, fmt.Errorf("error preparing query GetAddressTypeByID: %w", err) } + if q.getAndIncrementNextExternalIndexStmt, err = db.PrepareContext(ctx, GetAndIncrementNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query GetAndIncrementNextExternalIndex: %w", err) + } + if q.getAndIncrementNextInternalIndexStmt, err = db.PrepareContext(ctx, GetAndIncrementNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query GetAndIncrementNextInternalIndex: %w", err) + } if q.getBlockByHeightStmt, err = db.PrepareContext(ctx, GetBlockByHeight); err != nil { return nil, fmt.Errorf("error preparing query GetBlockByHeight: %w", err) } @@ -147,6 +153,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } + if q.updateAccountNextExternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextExternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNextExternalIndex: %w", err) + } + if q.updateAccountNextInternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextInternalIndex); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAccountNextInternalIndex: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -253,6 +265,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAddressTypeByIDStmt: %w", cerr) } } + if q.getAndIncrementNextExternalIndexStmt != nil { + if cerr := q.getAndIncrementNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAndIncrementNextExternalIndexStmt: %w", cerr) + } + } + if q.getAndIncrementNextInternalIndexStmt != nil { + if cerr := q.getAndIncrementNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAndIncrementNextInternalIndexStmt: %w", cerr) + } + } if q.getBlockByHeightStmt != nil { if cerr := q.getBlockByHeightStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getBlockByHeightStmt: %w", cerr) @@ -363,6 +385,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } + if q.updateAccountNextExternalIndexStmt != nil { + if cerr := q.updateAccountNextExternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNextExternalIndexStmt: %w", cerr) + } + } + if q.updateAccountNextInternalIndexStmt != nil { + if cerr := q.updateAccountNextInternalIndexStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAccountNextInternalIndexStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -431,6 +463,8 @@ type Queries struct { getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt + getAndIncrementNextExternalIndexStmt *sql.Stmt + getAndIncrementNextInternalIndexStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt getKeyScopeByIDStmt *sql.Stmt getKeyScopeByWalletAndScopeStmt *sql.Stmt @@ -453,6 +487,8 @@ type Queries struct { listWalletsStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateAccountNextExternalIndexStmt *sql.Stmt + updateAccountNextInternalIndexStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -480,6 +516,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, + getAndIncrementNextExternalIndexStmt: q.getAndIncrementNextExternalIndexStmt, + getAndIncrementNextInternalIndexStmt: q.getAndIncrementNextInternalIndexStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, @@ -502,6 +540,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listWalletsStmt: q.listWalletsStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateAccountNextExternalIndexStmt: q.updateAccountNextExternalIndexStmt, + updateAccountNextInternalIndexStmt: q.updateAccountNextInternalIndexStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 215b2f1e7d..8681080041 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -19,6 +19,8 @@ type Account struct { MasterFingerprint sql.NullInt64 EncryptedPublicKey []byte CreatedAt time.Time + NextExternalIndex int64 + NextInternalIndex int64 } type AccountOrigin struct { diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index cf8690a7a8..3eba8e332e 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -54,6 +54,12 @@ type Querier interface { GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) + // Atomically gets the next external address index and increments the counter. + // Returns the current index value (before incrementing) for the address derivation. + GetAndIncrementNextExternalIndex(ctx context.Context, id int64) (int64, error) + // Atomically gets the next internal/change address index and increments the counter. + // Returns the current index value (before incrementing) for the address derivation. + GetAndIncrementNextInternalIndex(ctx context.Context, id int64) (int64, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) // Retrieves a key scope by its ID. GetKeyScopeByID(ctx context.Context, id int64) (KeyScope, error) @@ -98,6 +104,12 @@ type Querier interface { UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) + // Updates the next_external_index counter for an account. Used in tests + // to set up specific index scenarios. + UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error + // Updates the next_internal_index counter for an account. Used in tests + // to set up specific index scenarios. + UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } From 3c8f35f245cc7296ff13e629b7a1d11b1bb82759 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 24 Jan 2026 15:23:17 -0300 Subject: [PATCH 382/691] wallet: add new conversions to safecasting --- wallet/internal/db/safecasting.go | 11 ++++++ wallet/internal/db/safecasting_test.go | 51 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 9ffbea2f2b..c45c8a0588 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -43,6 +43,17 @@ func int64ToInt32(v int64) (int32, error) { return int32(v), nil } +// int64ToInt16 safely casts an int64 to an int16, returning an error +// if the value is out of range. +func int64ToInt16(v int64) (int16, error) { + if v < math.MinInt16 || v > math.MaxInt16 { + return 0, fmt.Errorf("could not cast %d to int16: %w", v, + ErrCastingOverflow) + } + + return int16(v), nil +} + // int64ToUint8 safely casts an int64 to an uint8, returning an error // if the value is out of range. func int64ToUint8(v int64) (uint8, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index ec4b4d748a..83e568af3a 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -200,6 +200,57 @@ func TestInt16ToUint8(t *testing.T) { } } +// TestInt64ToInt16 checks that an int64 value is converted to int16 only +// when it fits within the signed 16 bit range. It should fail loudly for +// any value outside those limits. +func TestInt64ToInt16(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val int64 + want int16 + wantErr bool + }{ + { + name: "min int16", + val: int64(math.MinInt16), + want: math.MinInt16, + }, + { + name: "max int16", + val: int64(math.MaxInt16), + want: math.MaxInt16, + }, + {name: "zero", val: 0, want: 0}, + { + name: "below min", + val: int64(math.MinInt16) - 1, + wantErr: true, + }, + { + name: "above max", + val: int64(math.MaxInt16) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := int64ToInt16(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + // TestUint32ToInt32 checks that an uint32 value is safely converted to int32 // only when it fits within the signed 32 bit range. It should fail loudly // for any value that exceeds those limits. From f9c6d129eadc31395cf1de475cd69eab02db7eb7 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sat, 24 Jan 2026 19:20:31 -0300 Subject: [PATCH 383/691] wallet: format safecasting --- wallet/internal/db/safecasting.go | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index c45c8a0588..8f40e43c69 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -21,10 +21,8 @@ var ( // if the value is out of range. func int64ToUint32(v int64) (uint32, error) { if v < 0 || v > math.MaxUint32 { - return 0, fmt.Errorf( - "could not cast %d to uint32: %w", - v, ErrCastingOverflow, - ) + return 0, fmt.Errorf("could not cast %d to uint32: %w", v, + ErrCastingOverflow) } return uint32(v), nil @@ -34,10 +32,8 @@ func int64ToUint32(v int64) (uint32, error) { // if the value is out of range. func int64ToInt32(v int64) (int32, error) { if v < math.MinInt32 || v > math.MaxInt32 { - return 0, fmt.Errorf( - "could not cast %d to int32: %w", - v, ErrCastingOverflow, - ) + return 0, fmt.Errorf("could not cast %d to int32: %w", v, + ErrCastingOverflow) } return int32(v), nil @@ -80,10 +76,8 @@ func int16ToUint8(v int16) (uint8, error) { // if the value is out of range. func uint32ToInt32(v uint32) (int32, error) { if v > math.MaxInt32 { - return 0, fmt.Errorf( - "could not cast %d to int32: %w", - v, ErrCastingOverflow, - ) + return 0, fmt.Errorf("could not cast %d to int32: %w", v, + ErrCastingOverflow) } return int32(v), nil @@ -104,17 +98,13 @@ func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { // an error if the value is out of range or invalid. func nullInt32ToUint32(n sql.NullInt32) (uint32, error) { if !n.Valid { - return 0, fmt.Errorf( - "could not cast invalid NullInt32 to uint32: %w", - ErrInvalidNullInt, - ) + return 0, fmt.Errorf("could not cast invalid NullInt32 to uint32: %w", + ErrInvalidNullInt) } if n.Int32 < 0 { - return 0, fmt.Errorf( - "could not cast %d to uint32: %w", - n.Int32, ErrCastingOverflow, - ) + return 0, fmt.Errorf("could not cast %d to uint32: %w", n.Int32, + ErrCastingOverflow) } return uint32(n.Int32), nil From f39894bae77a99c33d40379be251957e6a51f0fa Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 22 Jan 2026 00:05:16 -0300 Subject: [PATCH 384/691] wallet: add AddressStore implementation --- wallet/internal/db/addresses_common.go | 633 +++++++++++++++++++++++++ wallet/internal/db/addresses_pg.go | 304 ++++++++++++ wallet/internal/db/addresses_sqlite.go | 308 ++++++++++++ wallet/internal/db/interface.go | 17 + 4 files changed, 1262 insertions(+) create mode 100644 wallet/internal/db/addresses_common.go create mode 100644 wallet/internal/db/addresses_pg.go create mode 100644 wallet/internal/db/addresses_sqlite.go diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go new file mode 100644 index 0000000000..9cb9884f1f --- /dev/null +++ b/wallet/internal/db/addresses_common.go @@ -0,0 +1,633 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" + "time" +) + +// DefaultImportedAccountName is the default account name for imported +// addresses. +const DefaultImportedAccountName = "imported" + +var ( + // errInvalidOriginID is returned when an origin ID from the database is + // outside the valid range [DerivedAccount, ImportedAccount]. In practice, + // this should never happen, but it's possible if the database is modified + // incorrectly or the query is incorrect. + errInvalidOriginID = errors.New("invalid origin ID: must be 0 or 1") + + // errInvalidDerivationPath is returned when the database contains an + // invalid derivation path, such as a missing index or branch for a + // derived address. This should never happen, but it's possible if the + // database is modified incorrectly or the query is incorrect. + errInvalidDerivationPath = errors.New("invalid derivation path") +) + +// accountLookupKey contains the fields needed to look up an account. +type accountLookupKey struct { + walletID int64 + purpose int64 + coinType int64 + accountName string +} + +// accountKeyFromParams extracts account lookup fields from params. +func accountKeyFromParams(params NewDerivedAddressParams) accountLookupKey { + return accountLookupKey{ + walletID: int64(params.WalletID), + purpose: int64(params.Scope.Purpose), + coinType: int64(params.Scope.Coin), + accountName: params.AccountName, + } +} + +// accountKeyFromImportedParams extracts account lookup fields from imported +// params using DefaultImportedAccountName. +func accountKeyFromImportedParams( + params NewImportedAddressParams) accountLookupKey { + + return accountLookupKey{ + walletID: int64(params.WalletID), + purpose: int64(params.Scope.Purpose), + coinType: int64(params.Scope.Coin), + accountName: DefaultImportedAccountName, + } +} + +// getAddressSecret is a generic helper that retrieves address secret +// information using the provided getter function and converts it to an +// AddressSecret with error handling. +func getAddressSecret[Row any](ctx context.Context, + getter func(context.Context, int64) (Row, error), addressID uint32, + toSecret func(Row) (*AddressSecret, error)) (*AddressSecret, error) { + + row, err := getter(ctx, int64(addressID)) + if err == nil { + return toSecret(row) + } + + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("address secret for address %d: %w", + addressID, ErrAddressNotFound) + } + + return nil, fmt.Errorf("get address secret: %w", err) +} + +// validate validates the required fields for creating an imported address. +// Returns sentinel errors on failure. +func (p NewImportedAddressParams) validate() error { + if len(p.ScriptPubKey) == 0 { + return ErrMissingScriptPubKey + } + + return nil +} + +// isWatchOnly returns true if the params include neither a private key nor +// a redeem or witness script. +func (p NewImportedAddressParams) isWatchOnly() bool { + noPrivKey := len(p.EncryptedPrivateKey) == 0 + noScript := len(p.EncryptedScript) == 0 + + if noPrivKey && noScript { + return true + } + + return false +} + +// idToOrigin safely converts an integer to AccountOrigin. It returns an error +// if the value is outside [DerivedAccount, ImportedAccount]. +func idToOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { + if v < 0 || v > T(ImportedAccount) { + return 0, fmt.Errorf("address origin: %d: %w", v, errInvalidOriginID) + } + + return AccountOrigin(uint8(v)), nil +} + +// addressInfoRow captures common fields from all address row types across +// PostgreSQL and SQLite backends. Uses generic type parameters to handle +// different ID types (int16 for PostgreSQL, int64 for SQLite). +type addressInfoRow[TypeID, OriginIDType any] struct { + // ID is the database unique identifier for the address. + ID int64 + + // AccountID is the database unique identifier for the account. + AccountID int64 + + // TypeID is the database identifier for the address type. + TypeID TypeID + + // OriginID is the database identifier for address origin (derived=0, + // imported=1). + OriginID OriginIDType + + // HasPrivateKey indicates whether the address has an encrypted private key. + HasPrivateKey bool + + // HasScript indicates whether the address has an encrypted script. + HasScript bool + + // CreatedAt is when the address was created in the wallet database. + CreatedAt time.Time + + // AddressBranch is the BIP44 branch number (0=external, 1=internal/change), + // or NULL for imported addresses. + AddressBranch sql.NullInt64 + + // AddressIndex is the BIP44 index within the branch, or NULL for imported + // addresses. + AddressIndex sql.NullInt64 + + // ScriptPubKey is the script pubkey. Zero value for derived addresses. + ScriptPubKey []byte + + // PubKey is the public key. Zero value for derived addresses. + PubKey []byte + + // IDToAddrType converts TypeID to AddressType with validation. + IDToAddrType func(TypeID) (AddressType, error) + + // IDToOrigin converts OriginIDType to AccountOrigin with validation. + IDToOrigin func(OriginIDType) (AccountOrigin, error) +} + +// convertAddressIDs converts database IDs to their respective uint32 values +// with error handling. +func convertAddressIDs(id, accountID int64) (uint32, uint32, error) { + addrID, err := int64ToUint32(id) + if err != nil { + return 0, 0, fmt.Errorf("address ID: %w", err) + } + + acctID, err := int64ToUint32(accountID) + if err != nil { + return 0, 0, fmt.Errorf("account ID: %w", err) + } + + return addrID, acctID, nil +} + +// newImportedAddressTx handles the shared transaction flow for creating an +// imported address across database backends. +func newImportedAddressTx[QTX any, Row any, CreateArgs any, InsertArgs any]( + ctx context.Context, create func(context.Context, CreateArgs) (Row, error), + createArgs CreateArgs, + insertFn func(QTX) func(context.Context, InsertArgs) error, qtx QTX, + insertArgs func(int64, NewImportedAddressParams) InsertArgs, + params NewImportedAddressParams, accountID int64, + rowID func(Row) int64, rowCreatedAt func(Row) time.Time) (*AddressInfo, + error) { + + addrRow, err := create(ctx, createArgs) + if err != nil { + return nil, fmt.Errorf("create imported address: %w", err) + } + + addrID := rowID(addrRow) + if !params.isWatchOnly() { + err = insertFn(qtx)(ctx, insertArgs(addrID, params)) + if err != nil { + return nil, fmt.Errorf("insert address secret: %w", err) + } + } + + id, acctID, err := convertAddressIDs(addrID, accountID) + if err != nil { + return nil, err + } + + return &AddressInfo{ + ID: id, + AccountID: acctID, + AddrType: params.AddressType, + CreatedAt: rowCreatedAt(addrRow), + Origin: ImportedAccount, + ScriptPubKey: params.ScriptPubKey, + PubKey: params.PubKey, + IsWatchOnly: params.isWatchOnly(), + }, nil +} + +// convertAddressMetadata converts address type and origin IDs with error +// handling. +func convertAddressMetadata[TypeID, OriginIDType any]( + row addressInfoRow[TypeID, OriginIDType]) (AddressType, AccountOrigin, + error) { + + addrType, err := row.IDToAddrType(row.TypeID) + if err != nil { + return 0, 0, fmt.Errorf("address type: %w", err) + } + + origin, err := row.IDToOrigin(row.OriginID) + if err != nil { + return 0, 0, fmt.Errorf("address origin: %w", err) + } + + return addrType, origin, nil +} + +// convertAddressPath converts BIP44 branch and index values with error +// handling. +func convertAddressPath(branch, index sql.NullInt64) (uint32, uint32, error) { + addrBranch, err := int64ToUint32(branch.Int64) + if err != nil { + return 0, 0, fmt.Errorf("address branch: %w", err) + } + + addrIndex, err := int64ToUint32(index.Int64) + if err != nil { + return 0, 0, fmt.Errorf("address index: %w", err) + } + + return addrBranch, addrIndex, nil +} + +// addressRowToInfo converts raw database field values into an AddressInfo +// struct. It handles type conversion and validation for each field. +func addressRowToInfo[TypeID, OriginIDType any]( + row addressInfoRow[TypeID, OriginIDType]) (*AddressInfo, error) { + + id, accountID, err := convertAddressIDs(row.ID, row.AccountID) + if err != nil { + return nil, err + } + + addrType, origin, err := convertAddressMetadata(row) + if err != nil { + return nil, err + } + + if origin == DerivedAccount { + if !row.AddressIndex.Valid || + !row.AddressBranch.Valid { + + return nil, errInvalidDerivationPath + } + } + + addrBranch, addrIndex, err := convertAddressPath( + row.AddressBranch, row.AddressIndex, + ) + if err != nil { + return nil, err + } + + isWatchOnly := origin == ImportedAccount && !row.HasPrivateKey && + !row.HasScript + + return &AddressInfo{ + ID: id, + AccountID: accountID, + AddrType: addrType, + CreatedAt: row.CreatedAt, + Origin: origin, + Branch: addrBranch, + Index: addrIndex, + ScriptPubKey: row.ScriptPubKey, + PubKey: row.PubKey, + IsWatchOnly: isWatchOnly, + }, nil +} + +// getAddress is a generic helper that retrieves a single address using the +// provided getter function. It handles sql.ErrNoRows mapping and delegates +// conversion to the toInfo function. +func getAddress[T any, Args any](ctx context.Context, + getter func(context.Context, Args) (T, error), args Args, + toInfo func(T) (*AddressInfo, error)) (*AddressInfo, error) { + + row, err := getter(ctx, args) + if err == nil { + return toInfo(row) + } + + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get address: %w", err) + } + + return nil, ErrAddressNotFound +} + +// listAddresses is a generic helper that retrieves addresses using the provided +// lister function and converts the results to AddressInfo structs. +func listAddresses[T any, Args any](ctx context.Context, + lister func(context.Context, Args) ([]T, error), args Args, + toInfo func(T) (*AddressInfo, error)) ([]AddressInfo, error) { + + rows, err := lister(ctx, args) + if err != nil { + return nil, fmt.Errorf("list addresses: %w", err) + } + + return addressInfosFromRows(rows, toInfo) +} + +// addressInfosFromRows converts a slice of row types to AddressInfo structs +// using the provided converter function. +func addressInfosFromRows[T any](rows []T, + toInfo func(T) (*AddressInfo, error)) ([]AddressInfo, error) { + + infos := make([]AddressInfo, len(rows)) + for i, row := range rows { + info, err := toInfo(row) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} + +// derivedAddressAdapters groups the functions needed to create a +// derived address across different database backends. +type derivedAddressAdapters[QTX any, AccountRow any, AccountParams any, + AddrRow any] struct { + + // getAccount retrieves an account by the provided parameters. + getAccount func(context.Context, AccountParams) (AccountRow, error) + + // accountParams converts params to account lookup parameters. + accountParams func(NewDerivedAddressParams) AccountParams + + // getAccountID extracts the account ID from an account row. + getAccountID func(AccountRow) int64 + + // getExtIndex returns a function to get the external index. + getExtIndex func(QTX) func(context.Context, int64) (int64, error) + + // getIntIndex returns a function to get the internal index. + getIntIndex func(QTX) func(context.Context, int64) (int64, error) + + // createAddr returns a function to create an address row. + createAddr func(QTX) func(context.Context, int64, AddressType, uint32, + uint32, []byte) (AddrRow, error) + + // rowID extracts the ID from an address row. + rowID func(AddrRow) int64 + + // rowCreatedAt extracts the creation time from an address row. + rowCreatedAt func(AddrRow) time.Time +} + +// importedAddressAdapters groups the functions needed to create an +// imported address across different database backends. +type importedAddressAdapters[QTX any, AccountRow any, + AccountParams any, CreateArgs any, AddrRow any, + SecretParams any] struct { + + // getAccount retrieves an account by the provided parameters. + getAccount func(context.Context, AccountParams) (AccountRow, error) + + // accountParams converts params to account lookup parameters. + accountParams func(NewImportedAddressParams) AccountParams + + // getAccountID extracts the account ID from an account row. + getAccountID func(AccountRow) int64 + + // createAddr returns a function to create an address row. + createAddr func(QTX) func(context.Context, CreateArgs) (AddrRow, error) + + // createParams converts accountID and params to address creation + // parameters. + createParams func(int64, NewImportedAddressParams) CreateArgs + + // insertSecret returns a function to insert address secret. + insertSecret func(QTX) func(context.Context, SecretParams) error + + // secretParams converts address ID and params to secret parameters. + secretParams func(int64, NewImportedAddressParams) SecretParams + + // rowID extracts the ID from an address row. + rowID func(AddrRow) int64 + + // rowCreatedAt extracts the creation time from an address row. + rowCreatedAt func(AddrRow) time.Time +} + +// getAddressFunc defines a function signature for retrieving a single address. +type getAddressFunc func(context.Context, GetAddressQuery) (*AddressInfo, error) + +// getAddressByQuery validates the query and executes the script-based lookup. +func getAddressByQuery(ctx context.Context, query GetAddressQuery, + getter getAddressFunc) (*AddressInfo, error) { + + if len(query.ScriptPubKey) == 0 { + return nil, ErrInvalidAddressQuery + } + + return getter(ctx, query) +} + +// createDerivedAddress is a generic helper that encapsulates the shared +// derived address creation logic. It calls derivedAddressInput to prepare +// inputs and then createFn to create the address. +func createDerivedAddress[T any](ctx context.Context, + params NewDerivedAddressParams, accountID int64, + getExtIndex func(context.Context, int64) (int64, error), + getIntIndex func(context.Context, int64) (int64, error), + createFn func(context.Context, int64, AddressType, uint32, uint32, + []byte) (T, error), + rowID func(T) int64, rowCreatedAt func(T) time.Time, + deriveFn AddressDerivationFunc) (*AddressInfo, error) { + + addrType, branch, index, scriptPubKey, err := + derivedAddressInput( + ctx, params, accountID, getExtIndex, getIntIndex, deriveFn, + ) + if err != nil { + return nil, err + } + + row, err := createFn(ctx, accountID, addrType, branch, index, scriptPubKey) + if err != nil { + return nil, fmt.Errorf("create address: %w", err) + } + + rowIDVal := rowID(row) + + id, convertedAcctID, err := convertAddressIDs(rowIDVal, accountID) + if err != nil { + return nil, err + } + + return &AddressInfo{ + ID: id, + AccountID: convertedAcctID, + AddrType: addrType, + CreatedAt: rowCreatedAt(row), + Origin: DerivedAccount, + Branch: branch, + Index: index, + IsWatchOnly: false, + }, nil +} + +// derivedAddressInput encapsulates the logic to prepare inputs for address +// derivation, including schema lookup, branch/type selection, index +// allocation with overflow check, accountID conversion, and derivation. +func derivedAddressInput(ctx context.Context, + params NewDerivedAddressParams, accountID int64, + getExtIndex func(context.Context, int64) (int64, error), + getIntIndex func(context.Context, int64) (int64, error), + deriveFn AddressDerivationFunc) (AddressType, uint32, uint32, + []byte, error) { + + addrSchema, err := getAddrSchemaForScope(params.Scope) + if err != nil { + return 0, 0, 0, nil, err + } + + var ( + branch uint32 + addrType AddressType + getIdx func(context.Context, int64) (int64, error) + ) + + if params.Change { + branch = 1 + addrType = addrSchema.InternalAddrType + getIdx = getIntIndex + } else { + addrType = addrSchema.ExternalAddrType + getIdx = getExtIndex + } + + indexValue, err := getIdx(ctx, accountID) + if err != nil { + return 0, 0, 0, nil, fmt.Errorf( + "get next address index: %w", err) + } + + if indexValue > math.MaxUint32 { + return 0, 0, 0, nil, ErrMaxAddressIndexReached + } + + index, err := int64ToUint32(indexValue) + if err != nil { + return 0, 0, 0, nil, fmt.Errorf( + "address index: %w", err) + } + + acctID, err := int64ToUint32(accountID) + if err != nil { + return 0, 0, 0, nil, fmt.Errorf( + "account ID: %w", err) + } + + derivedData, err := deriveFn(ctx, acctID, branch, index) + if err != nil { + return 0, 0, 0, nil, fmt.Errorf( + "derive address: %w", err) + } + + return addrType, branch, index, derivedData.ScriptPubKey, nil +} + +// newDerivedAddressWithTx combines transaction execution, account lookup, +// and derived address creation in one helper. +func newDerivedAddressWithTx[QTX any, AccountRow any, + AccountParams any, AddrRow any](ctx context.Context, + params NewDerivedAddressParams, + executeTx func(context.Context, func(QTX) error) error, + adapters derivedAddressAdapters[QTX, AccountRow, AccountParams, AddrRow], + deriveFn AddressDerivationFunc) (*AddressInfo, error) { + + var result *AddressInfo + + err := executeTx(ctx, func(qtx QTX) error { + row, err := adapters.getAccount(ctx, adapters.accountParams(params)) + if err == nil { + info, errAddr := createDerivedAddress( + ctx, params, adapters.getAccountID(row), + adapters.getExtIndex(qtx), + adapters.getIntIndex(qtx), + adapters.createAddr(qtx), + adapters.rowID, + adapters.rowCreatedAt, deriveFn) + if errAddr != nil { + return errAddr + } + + result = info + + return nil + } + + if errors.Is(err, sql.ErrNoRows) { + key := accountKeyFromParams(params) + + return fmt.Errorf("account %q in scope %d/%d: %w", + key.accountName, key.purpose, key.coinType, ErrAccountNotFound) + } + + return fmt.Errorf("get account: %w", err) + }) + if err != nil { + return nil, err + } + + return result, nil +} + +// newImportedAddressWithTx combines transaction execution, account lookup, +// and imported address creation in one helper. +func newImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, + CreateArgs any, AddrRow any, SecretParams any]( + ctx context.Context, params NewImportedAddressParams, + executeTx func(context.Context, func(QTX) error) error, + adapters importedAddressAdapters[QTX, AccountRow, AccountParams, CreateArgs, + AddrRow, SecretParams]) (*AddressInfo, error) { + + validationErr := params.validate() + if validationErr != nil { + return nil, validationErr + } + + var result *AddressInfo + + err := executeTx(ctx, func(qtx QTX) error { + row, err := adapters.getAccount(ctx, adapters.accountParams(params)) + if err == nil { + acctID := adapters.getAccountID(row) + + info, errAddr := newImportedAddressTx( + ctx, adapters.createAddr(qtx), + adapters.createParams(acctID, params), + adapters.insertSecret, qtx, + adapters.secretParams, params, acctID, + adapters.rowID, + adapters.rowCreatedAt) + if errAddr != nil { + return errAddr + } + + result = info + + return nil + } + + if errors.Is(err, sql.ErrNoRows) { + key := accountKeyFromImportedParams(params) + + return fmt.Errorf("account %q in scope %d/%d: %w", + key.accountName, key.purpose, key.coinType, ErrAccountNotFound) + } + + return fmt.Errorf("get account: %w", err) + }) + if err != nil { + return nil, err + } + + return result, nil +} diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go new file mode 100644 index 0000000000..76c3d42334 --- /dev/null +++ b/wallet/internal/db/addresses_pg.go @@ -0,0 +1,304 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// GetAddress retrieves information about a specific address, identified by +// its script pubkey. +func (w *PostgresWalletDB) GetAddress(ctx context.Context, + query GetAddressQuery) (*AddressInfo, error) { + + getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, + error) { + + return getAddress( + ctx, w.queries.GetAddressByScriptPubKey, + sqlcpg.GetAddressByScriptPubKeyParams{ + ScriptPubKey: q.ScriptPubKey, + WalletID: int64(q.WalletID), + }, pgAddressRowToInfo, + ) + } + + return getAddressByQuery(ctx, query, getByScript) +} + +// ListAddresses returns a slice of AddressInfo for all addresses in a given +// account. +func (w *PostgresWalletDB) ListAddresses(ctx context.Context, + query ListAddressesQuery) ([]AddressInfo, error) { + + return listAddresses( + ctx, w.queries.ListAddressesByAccount, + sqlcpg.ListAddressesByAccountParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountName: query.AccountName, + }, pgAddressRowToInfo, + ) +} + +// GetAddressSecret retrieves the encrypted secret information for an address. +func (w *PostgresWalletDB) GetAddressSecret(ctx context.Context, + addressID uint32) (*AddressSecret, error) { + + return getAddressSecret( + ctx, w.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, + ) +} + +// NewDerivedAddress creates a new address for a given account and key +// scope. +func (w *PostgresWalletDB) NewDerivedAddress(ctx context.Context, + params NewDerivedAddressParams, + deriveFn AddressDerivationFunc) (*AddressInfo, error) { + + adapters := derivedAddressAdapters[ + *sqlcpg.Queries, + sqlcpg.GetAccountByWalletScopeAndNameRow, + accountLookupKey, + sqlcpg.CreateDerivedAddressRow]{ + getAccount: pgGetAccountFromKey(w.queries), + accountParams: accountKeyFromParams, + getAccountID: newDerivedAddressGetAccountIDPg, + getExtIndex: newDerivedAddressGetExtIndexPg, + getIntIndex: newDerivedAddressGetIntIndexPg, + createAddr: newDerivedAddressCreateAddrPg, + rowID: newDerivedAddressRowIDPg, + rowCreatedAt: newDerivedAddressRowCreatedAtPg, + } + + return newDerivedAddressWithTx(ctx, params, w.ExecuteTx, adapters, deriveFn) +} + +// NewImportedAddress imports a new address, script, or private key. +func (w *PostgresWalletDB) NewImportedAddress(ctx context.Context, + params NewImportedAddressParams) (*AddressInfo, error) { + + adapters := importedAddressAdapters[ + *sqlcpg.Queries, + sqlcpg.GetAccountByWalletScopeAndNameRow, + accountLookupKey, + sqlcpg.CreateImportedAddressParams, + sqlcpg.CreateImportedAddressRow, + sqlcpg.InsertAddressSecretParams]{ + getAccount: pgGetAccountFromKey(w.queries), + accountParams: accountKeyFromImportedParams, + getAccountID: newImportedAddressGetAccountIDPg, + createAddr: pgCreateImportedAddress, + createParams: createImportedAddressParamsPg, + insertSecret: pgInsertAddressSecret, + secretParams: insertAddressSecretParamsPg, + rowID: importedAddressRowIDPg, + rowCreatedAt: importedAddressRowCreatedAtPg, + } + + return newImportedAddressWithTx(ctx, params, w.ExecuteTx, adapters) +} + +// pgGetAccountFromKey returns a helper to look up accounts by key. +func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, + accountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { + + return func(ctx context.Context, + key accountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, + error) { + + return qtx.GetAccountByWalletScopeAndName( + ctx, sqlcpg.GetAccountByWalletScopeAndNameParams{ + WalletID: key.walletID, + Purpose: key.purpose, + CoinType: key.coinType, + AccountName: key.accountName, + }, + ) + } +} + +// newDerivedAddressGetAccountIDPg extracts the account ID from a row. +func newDerivedAddressGetAccountIDPg( + row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { + + return row.ID +} + +// newDerivedAddressGetExtIndexPg returns the external index query. +func newDerivedAddressGetExtIndexPg(qtx *sqlcpg.Queries) func(context.Context, + int64) (int64, error) { + + return qtx.GetAndIncrementNextExternalIndex +} + +// newDerivedAddressGetIntIndexPg returns the internal index query. +func newDerivedAddressGetIntIndexPg(qtx *sqlcpg.Queries) func(context.Context, + int64) (int64, error) { + + return qtx.GetAndIncrementNextInternalIndex +} + +// newDerivedAddressCreateAddrPg returns the derived address insert helper. +func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, + int64, AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, + error) { + + return func(ctx context.Context, accountID int64, addrType AddressType, + branch uint32, index uint32, + scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { + + return qtx.CreateDerivedAddress( + ctx, sqlcpg.CreateDerivedAddressParams{ + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int16(addrType), + AddressBranch: sql.NullInt64{ + Int64: int64(branch), + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: nil, + }, + ) + } +} + +// newDerivedAddressRowIDPg returns the created address ID. +func newDerivedAddressRowIDPg(row sqlcpg.CreateDerivedAddressRow) int64 { + return row.ID +} + +// newDerivedAddressRowCreatedAtPg returns the CreatedAt timestamp. +func newDerivedAddressRowCreatedAtPg( + row sqlcpg.CreateDerivedAddressRow) time.Time { + + return row.CreatedAt +} + +// newImportedAddressGetAccountIDPg extracts the account ID from a row. +func newImportedAddressGetAccountIDPg( + row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { + + return row.ID +} + +// pgCreateImportedAddress returns the imported address insert helper. +func pgCreateImportedAddress(qtx *sqlcpg.Queries) func(context.Context, + sqlcpg.CreateImportedAddressParams) (sqlcpg.CreateImportedAddressRow, + error) { + + return qtx.CreateImportedAddress +} + +// pgInsertAddressSecret returns the secret insert helper. +func pgInsertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, + sqlcpg.InsertAddressSecretParams) error { + + return qtx.InsertAddressSecret +} + +// createImportedAddressParamsPg maps imported params to sqlc params. +func createImportedAddressParamsPg(accountID int64, + params NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { + + return sqlcpg.CreateImportedAddressParams{ + AccountID: accountID, + ScriptPubKey: params.ScriptPubKey, + TypeID: int16(params.AddressType), + PubKey: params.PubKey, + } +} + +// insertAddressSecretParamsPg maps imported params to secret params. +func insertAddressSecretParamsPg(addressID int64, + params NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { + + return sqlcpg.InsertAddressSecretParams{ + AddressID: addressID, + EncryptedPrivKey: params.EncryptedPrivateKey, + EncryptedScript: params.EncryptedScript, + } +} + +// importedAddressRowIDPg returns the created address ID. +func importedAddressRowIDPg(row sqlcpg.CreateImportedAddressRow) int64 { + return row.ID +} + +// importedAddressRowCreatedAtPg returns the CreatedAt timestamp. +func importedAddressRowCreatedAtPg( + row sqlcpg.CreateImportedAddressRow) time.Time { + + return row.CreatedAt +} + +// pgAddressSecretRowToSecret converts a sqlc GetAddressSecretRow row to the +// db.AddressSecret type used by the wallet, handling null value conversions. +// Returns ErrSecretNotFound if the address exists but has no secret. +func pgAddressSecretRowToSecret(row sqlcpg.GetAddressSecretRow) (*AddressSecret, + error) { + + hasKey := len(row.EncryptedPrivKey) > 0 + hasScript := len(row.EncryptedScript) > 0 + + if !hasKey && !hasScript { + return nil, fmt.Errorf("address %d: %w", row.AddressID, + ErrSecretNotFound) + } + + addrID, err := int64ToUint32(row.AddressID) + if err != nil { + return nil, fmt.Errorf("address ID: %w", err) + } + + return &AddressSecret{ + AddressID: addrID, + EncryptedPrivKey: row.EncryptedPrivKey, + EncryptedScript: row.EncryptedScript, + }, nil +} + +// pgAddressInfoRow is a type constraint that unifies all PostgreSQL address +// row types that share the same field structure. This enables a single +// generic conversion function to handle all address query result types. +type pgAddressInfoRow interface { + sqlcpg.GetAddressByScriptPubKeyRow | + sqlcpg.ListAddressesByAccountRow +} + +// pgAddressRowToInfo converts a PostgreSQL address row to an AddressInfo +// struct. +func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { + // Direct conversion works only because all constraint types have + // identical fields. If sqlc types diverge, compilation will fail. + base := sqlcpg.GetAddressByScriptPubKeyRow(row) + + info, err := addressRowToInfo(addressInfoRow[int16, int16]{ + ID: base.ID, + AccountID: base.AccountID, + TypeID: base.TypeID, + OriginID: base.OriginID, + HasPrivateKey: base.HasPrivateKey, + HasScript: base.HasScript, + CreatedAt: base.CreatedAt, + AddressBranch: base.AddressBranch, + AddressIndex: base.AddressIndex, + ScriptPubKey: base.ScriptPubKey, + PubKey: base.PubKey, + IDToAddrType: idToAddressType[int16], + IDToOrigin: idToOrigin[int16], + }) + if err != nil { + return nil, err + } + + return info, nil +} diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go new file mode 100644 index 0000000000..f89ea9143c --- /dev/null +++ b/wallet/internal/db/addresses_sqlite.go @@ -0,0 +1,308 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// GetAddress retrieves information about a specific address, identified by +// its script pubkey. +func (w *SQLiteWalletDB) GetAddress(ctx context.Context, + query GetAddressQuery) (*AddressInfo, error) { + + getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, + error) { + + return getAddress( + ctx, w.queries.GetAddressByScriptPubKey, + sqlcsqlite.GetAddressByScriptPubKeyParams{ + WalletID: int64(q.WalletID), + ScriptPubKey: q.ScriptPubKey, + }, sqliteAddressRowToInfo, + ) + } + + return getAddressByQuery(ctx, query, getByScript) +} + +// ListAddresses returns a slice of AddressInfo for all addresses in a given +// account. +func (w *SQLiteWalletDB) ListAddresses(ctx context.Context, + query ListAddressesQuery) ([]AddressInfo, error) { + + return listAddresses( + ctx, w.queries.ListAddressesByAccount, + sqlcsqlite.ListAddressesByAccountParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountName: query.AccountName, + }, sqliteAddressRowToInfo, + ) +} + +// GetAddressSecret retrieves the encrypted secret information for an address. +func (w *SQLiteWalletDB) GetAddressSecret(ctx context.Context, + addressID uint32) (*AddressSecret, error) { + + return getAddressSecret( + ctx, w.queries.GetAddressSecret, addressID, + sqliteAddressSecretRowToSecret, + ) +} + +// NewDerivedAddress creates a new address for a given account and key +// scope. +func (w *SQLiteWalletDB) NewDerivedAddress(ctx context.Context, + params NewDerivedAddressParams, + deriveFn AddressDerivationFunc) (*AddressInfo, error) { + + adapters := derivedAddressAdapters[ + *sqlcsqlite.Queries, + sqlcsqlite.GetAccountByWalletScopeAndNameRow, + accountLookupKey, + sqlcsqlite.CreateDerivedAddressRow]{ + getAccount: sqliteGetAccountFromKey(w.queries), + accountParams: accountKeyFromParams, + getAccountID: newDerivedAddressGetAccountIDSQLite, + getExtIndex: newDerivedAddressGetExtIndexSQLite, + getIntIndex: newDerivedAddressGetIntIndexSQLite, + createAddr: newDerivedAddressCreateAddrSQLite, + rowID: newDerivedAddressRowIDSQLite, + rowCreatedAt: newDerivedAddressRowCreatedAtSQLite, + } + + return newDerivedAddressWithTx(ctx, params, w.ExecuteTx, adapters, deriveFn) +} + +// NewImportedAddress imports a new address, script, or private key. +func (w *SQLiteWalletDB) NewImportedAddress(ctx context.Context, + params NewImportedAddressParams) (*AddressInfo, error) { + + adapters := importedAddressAdapters[ + *sqlcsqlite.Queries, + sqlcsqlite.GetAccountByWalletScopeAndNameRow, + accountLookupKey, + sqlcsqlite.CreateImportedAddressParams, + sqlcsqlite.CreateImportedAddressRow, + sqlcsqlite.InsertAddressSecretParams]{ + getAccount: sqliteGetAccountFromKey(w.queries), + accountParams: accountKeyFromImportedParams, + getAccountID: newImportedAddressGetAccountIDSQLite, + createAddr: sqliteCreateImportedAddress, + createParams: createImportedAddressParamsSQLite, + insertSecret: sqliteInsertAddressSecret, + secretParams: insertAddressSecretParamsSQLite, + rowID: importedAddressRowIDSQLite, + rowCreatedAt: importedAddressRowCreatedAtSQLite, + } + + return newImportedAddressWithTx(ctx, params, w.ExecuteTx, adapters) +} + +// sqliteGetAccountFromKey returns a helper to look up accounts by key. +func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, + accountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { + + return func(ctx context.Context, + key accountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, + error) { + + return qtx.GetAccountByWalletScopeAndName( + ctx, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ + WalletID: key.walletID, + Purpose: key.purpose, + CoinType: key.coinType, + AccountName: key.accountName, + }, + ) + } +} + +// newDerivedAddressGetAccountIDSQLite extracts the account ID from a row. +func newDerivedAddressGetAccountIDSQLite( + row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { + + return row.ID +} + +// newDerivedAddressGetExtIndexSQLite returns the external index query. +func newDerivedAddressGetExtIndexSQLite( + qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { + + return qtx.GetAndIncrementNextExternalIndex +} + +// newDerivedAddressGetIntIndexSQLite returns the internal index query. +func newDerivedAddressGetIntIndexSQLite( + qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { + + return qtx.GetAndIncrementNextInternalIndex +} + +// newDerivedAddressCreateAddrSQLite returns the derived address insert helper. +func newDerivedAddressCreateAddrSQLite( + qtx *sqlcsqlite.Queries) func(context.Context, int64, AddressType, uint32, + uint32, []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { + + return func(ctx context.Context, accountID int64, addrType AddressType, + branch uint32, index uint32, + scriptPubKey []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { + + return qtx.CreateDerivedAddress( + ctx, sqlcsqlite.CreateDerivedAddressParams{ + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int64(addrType), + AddressBranch: sql.NullInt64{ + Int64: int64(branch), + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: nil, + }, + ) + } +} + +// newDerivedAddressRowIDSQLite returns the created address ID. +func newDerivedAddressRowIDSQLite( + row sqlcsqlite.CreateDerivedAddressRow) int64 { + + return row.ID +} + +// newDerivedAddressRowCreatedAtSQLite returns the CreatedAt timestamp. +func newDerivedAddressRowCreatedAtSQLite( + row sqlcsqlite.CreateDerivedAddressRow) time.Time { + + return row.CreatedAt +} + +// newImportedAddressGetAccountIDSQLite extracts the account ID from a row. +func newImportedAddressGetAccountIDSQLite( + row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { + + return row.ID +} + +// sqliteCreateImportedAddress returns the imported address insert helper. +func sqliteCreateImportedAddress(qtx *sqlcsqlite.Queries) func(context.Context, + sqlcsqlite.CreateImportedAddressParams) ( + sqlcsqlite.CreateImportedAddressRow, error) { + + return qtx.CreateImportedAddress +} + +// sqliteInsertAddressSecret returns the secret insert helper. +func sqliteInsertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, + sqlcsqlite.InsertAddressSecretParams) error { + + return qtx.InsertAddressSecret +} + +// createImportedAddressParamsSQLite maps imported params to sqlc params. +func createImportedAddressParamsSQLite(accountID int64, + params NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { + + return sqlcsqlite.CreateImportedAddressParams{ + AccountID: accountID, + ScriptPubKey: params.ScriptPubKey, + TypeID: int64(params.AddressType), + PubKey: params.PubKey, + } +} + +// importedAddressRowIDSQLite returns the created address ID. +func importedAddressRowIDSQLite(row sqlcsqlite.CreateImportedAddressRow) int64 { + return row.ID +} + +// importedAddressRowCreatedAtSQLite returns the CreatedAt timestamp. +func importedAddressRowCreatedAtSQLite( + row sqlcsqlite.CreateImportedAddressRow) time.Time { + + return row.CreatedAt +} + +// insertAddressSecretParamsSQLite maps imported params to secret params. +func insertAddressSecretParamsSQLite(addressID int64, + params NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { + + return sqlcsqlite.InsertAddressSecretParams{ + AddressID: addressID, + EncryptedPrivKey: params.EncryptedPrivateKey, + EncryptedScript: params.EncryptedScript, + } +} + +// sqliteAddressSecretRowToSecret converts a sqlc GetAddressSecretRow row to the +// db.AddressSecret type used by the wallet, handling null value conversions. +// Returns ErrSecretNotFound if the secret is missing. +func sqliteAddressSecretRowToSecret( + row sqlcsqlite.GetAddressSecretRow) (*AddressSecret, error) { + + hasKey := len(row.EncryptedPrivKey) > 0 + hasScript := len(row.EncryptedScript) > 0 + + if !hasKey && !hasScript { + return nil, fmt.Errorf("address %d: %w", row.AddressID, + ErrSecretNotFound) + } + + addrID, err := int64ToUint32(row.AddressID) + if err != nil { + return nil, fmt.Errorf("address ID: %w", err) + } + + return &AddressSecret{ + AddressID: addrID, + EncryptedPrivKey: row.EncryptedPrivKey, + EncryptedScript: row.EncryptedScript, + }, nil +} + +// sqliteAddressInfoRow is a type constraint union that represents all SQLite +// address row types that share the same field structure. This enables a +// single generic conversion function to handle all address query result types. +type sqliteAddressInfoRow interface { + sqlcsqlite.GetAddressByScriptPubKeyRow | + sqlcsqlite.ListAddressesByAccountRow +} + +// sqliteAddressRowToInfo converts a SQLite address row to an AddressInfo +// struct. +func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, + error) { + // Direct conversion works only because all constraint types have + // identical fields. If sqlc types diverge, compilation will fail. + base := sqlcsqlite.GetAddressByScriptPubKeyRow(row) + + info, err := addressRowToInfo(addressInfoRow[int64, int64]{ + ID: base.ID, + AccountID: base.AccountID, + TypeID: base.TypeID, + OriginID: base.OriginID, + HasPrivateKey: base.HasPrivateKey, + HasScript: base.HasScript, + CreatedAt: base.CreatedAt, + AddressBranch: base.AddressBranch, + AddressIndex: base.AddressIndex, + ScriptPubKey: base.ScriptPubKey, + PubKey: base.PubKey, + IDToAddrType: idToAddressType[int64], + IDToOrigin: idToOrigin[int64], + }) + if err != nil { + return nil, err + } + + return info, nil +} diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 3589383a48..858bdac669 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -27,6 +27,10 @@ var ( // database. ErrAccountNotFound = errors.New("account not found") + // ErrAddressNotFound is returned when an address is not found in the + // database. + ErrAddressNotFound = errors.New("address not found") + // ErrKeyScopeNotFound is returned when a key scope is not found in the // database. ErrKeyScopeNotFound = errors.New("key scope not found") @@ -41,6 +45,14 @@ var ( "exactly one of Name or AccountNumber must be provided", ) + // ErrInvalidAddressQuery is returned when GetAddressQuery has invalid + // field combinations. + ErrInvalidAddressQuery = errors.New("ScriptPubKey must be provided") + + // ErrMissingScriptPubKey is returned when creating an imported + // address without the required script public key. + ErrMissingScriptPubKey = errors.New("script pubkey required") + // ErrMissingAccountPublicKey is returned when an imported account is // missing the encrypted public key. ErrMissingAccountPublicKey = errors.New( @@ -55,6 +67,11 @@ var ( // within a key scope because the account number counter has reached its // maximum representable value. ErrMaxAccountNumberReached = errors.New("max account number reached") + + // ErrMaxAddressIndexReached indicates that no more addresses can be + // created within a branch because the address index counter has reached + // its maximum representable value. + ErrMaxAddressIndexReached = errors.New("max address index reached") ) // WalletStore defines the methods for wallet-level operations. From e715b7226cebc42233b37f4178f9afd46dec3082 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 12 Jan 2026 17:02:31 -0300 Subject: [PATCH 385/691] wallet: add address counting to account --- wallet/internal/db/accounts_common.go | 117 ++++++--- wallet/internal/db/accounts_pg.go | 24 +- wallet/internal/db/accounts_sqlite.go | 24 +- .../internal/db/queries/postgres/accounts.sql | 73 ++++-- .../internal/db/queries/sqlite/accounts.sql | 73 ++++-- .../internal/db/sqlc/postgres/accounts.sql.go | 229 +++++++++++++----- .../internal/db/sqlc/sqlite/accounts.sql.go | 229 +++++++++++++----- 7 files changed, 560 insertions(+), 209 deletions(-) diff --git a/wallet/internal/db/accounts_common.go b/wallet/internal/db/accounts_common.go index 8c2681f3ad..24324e5786 100644 --- a/wallet/internal/db/accounts_common.go +++ b/wallet/internal/db/accounts_common.go @@ -54,6 +54,9 @@ type accountPropsRow[AddrTypeId, AccOriginId any] struct { AccountNumber sql.NullInt64 AccountName string OriginID AccOriginId + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 EncryptedPublicKey []byte MasterFingerprint sql.NullInt64 IsWatchOnly bool @@ -66,13 +69,52 @@ type accountPropsRow[AddrTypeId, AccOriginId any] struct { IDToOriginType func(AccOriginId) (AccountOrigin, error) } +// getKeyCounts converts external, internal, and imported key counts from +// int64 to uint32 and handles errors. +func getKeyCounts(external, internal, imported int64) (uint32, uint32, + uint32, error) { + + externalKeyCount, err := int64ToUint32(external) + if err != nil { + return 0, 0, 0, fmt.Errorf("external key count: %w", err) + } + + internalKeyCount, err := int64ToUint32(internal) + if err != nil { + return 0, 0, 0, fmt.Errorf("internal key count: %w", err) + } + + importedKeyCount, err := int64ToUint32(imported) + if err != nil { + return 0, 0, 0, fmt.Errorf("imported key count: %w", err) + } + + return externalKeyCount, internalKeyCount, importedKeyCount, nil +} + +// getAddrTypes extracts the internal and external address types from the row +// and handles errors. +func getAddrTypes[AddrTypeId, AccOriginId any]( + row accountPropsRow[AddrTypeId, AccOriginId]) (AddressType, AddressType, + error) { + + internalType, err := row.IDToAddrType(row.InternalTypeID) + if err != nil { + return 0, 0, fmt.Errorf("internal type: %w", err) + } + + externalType, err := row.IDToAddrType(row.ExternalTypeID) + if err != nil { + return 0, 0, fmt.Errorf("external type: %w", err) + } + + return internalType, externalType, nil +} + // accountPropsRowToProps converts a database row containing full account // properties into an AccountProperties struct. The idToAddrType function is // used to convert the internal and external address type IDs to AddressType // values. -// -// TODO(stingelin): Add address counting support after address management is -// implemented. func accountPropsRowToProps[AddrTypeId, AccOriginId any]( row accountPropsRow[AddrTypeId, AccOriginId]) (*AccountProperties, error) { @@ -101,14 +143,9 @@ func accountPropsRowToProps[AddrTypeId, AccOriginId any]( return nil, fmt.Errorf("coin type: %w", err) } - internalType, err := row.IDToAddrType(row.InternalTypeID) + internalType, externalType, err := getAddrTypes(row) if err != nil { - return nil, fmt.Errorf("internal type: %w", err) - } - - externalType, err := row.IDToAddrType(row.ExternalTypeID) - if err != nil { - return nil, fmt.Errorf("external type: %w", err) + return nil, err } var fingerprint uint32 @@ -119,13 +156,20 @@ func accountPropsRowToProps[AddrTypeId, AccOriginId any]( } } + externalKeyCount, internalKeyCount, importedKeyCount, err := getKeyCounts( + row.ExternalKeyCount, row.InternalKeyCount, row.ImportedKeyCount, + ) + if err != nil { + return nil, err + } + return &AccountProperties{ AccountNumber: accountNum, AccountName: row.AccountName, Origin: origin, - ExternalKeyCount: 0, - InternalKeyCount: 0, - ImportedKeyCount: 0, + ExternalKeyCount: externalKeyCount, + InternalKeyCount: internalKeyCount, + ImportedKeyCount: importedKeyCount, EncryptedPublicKey: row.EncryptedPublicKey, MasterKeyFingerprint: fingerprint, KeyScope: KeyScope{ @@ -186,23 +230,22 @@ func getAddrSchemaForScope(scope KeyScope) (ScopeAddrSchema, error) { } // buildAccountInfo creates an AccountInfo with the provided values and zeroed -// balances and key counts while we do not yet support address counting. +// balances while we do not yet support balance tracking. // -// TODO(stingelin): Add address counting support after address management is -// implemented. // TODO(stingelin): Add balance tracking support after transaction management is // implemented. func buildAccountInfo(accountNum uint32, accountName string, - origin AccountOrigin, isWatchOnly bool, createdAt time.Time, + origin AccountOrigin, externalKeyCount, internalKeyCount, + importedKeyCount uint32, isWatchOnly bool, createdAt time.Time, scope KeyScope) *AccountInfo { return &AccountInfo{ AccountNumber: accountNum, AccountName: accountName, Origin: origin, - ExternalKeyCount: 0, - InternalKeyCount: 0, - ImportedKeyCount: 0, + ExternalKeyCount: externalKeyCount, + InternalKeyCount: internalKeyCount, + ImportedKeyCount: importedKeyCount, ConfirmedBalance: 0, UnconfirmedBalance: 0, IsWatchOnly: isWatchOnly, @@ -224,14 +267,17 @@ func idToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { // accountInfoRow represents the raw database fields needed to construct // AccountInfo. type accountInfoRow[AccOriginId any] struct { - AccountNumber sql.NullInt64 - AccountName string - OriginID AccOriginId - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 - IDToOriginType func(AccOriginId) (AccountOrigin, error) + AccountNumber sql.NullInt64 + AccountName string + OriginID AccOriginId + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + IDToOriginType func(AccOriginId) (AccountOrigin, error) } // accountRowToInfo converts raw database field values into an AccountInfo @@ -264,12 +310,17 @@ func accountRowToInfo[AccOriginId any]( return nil, fmt.Errorf("coin type: %w", err) } + externalKeyCount, internalKeyCount, importedKeyCount, err := getKeyCounts( + row.ExternalKeyCount, row.InternalKeyCount, row.ImportedKeyCount, + ) + if err != nil { + return nil, err + } + return buildAccountInfo( - accountNum, row.AccountName, origin, row.IsWatchOnly, - row.CreatedAt, KeyScope{ - Purpose: purposeNum, - Coin: coinTypeNum, - }, + accountNum, row.AccountName, origin, externalKeyCount, internalKeyCount, + importedKeyCount, row.IsWatchOnly, row.CreatedAt, + KeyScope{Purpose: purposeNum, Coin: coinTypeNum}, ), nil } diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index b8d6e17ff8..3aad53c15b 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -100,7 +100,7 @@ func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, } info = buildAccountInfo( - accNumber, params.Name, DerivedAccount, false, + accNumber, params.Name, DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -197,6 +197,9 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, + ExternalKeyCount: row.ExternalKeyCount, + InternalKeyCount: row.InternalKeyCount, + ImportedKeyCount: row.ImportedKeyCount, EncryptedPublicKey: row.EncryptedPublicKey, MasterFingerprint: row.MasterFingerprint, IsWatchOnly: row.IsWatchOnly, @@ -264,14 +267,17 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { base := sqlcpg.GetAccountByScopeAndNameRow(row) return accountRowToInfo(accountInfoRow[int16]{ - AccountNumber: base.AccountNumber, - AccountName: base.AccountName, - OriginID: base.OriginID, - IsWatchOnly: base.IsWatchOnly, - CreatedAt: base.CreatedAt, - Purpose: base.Purpose, - CoinType: base.CoinType, - IDToOriginType: idToAccountOrigin[int16], + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + OriginID: base.OriginID, + ExternalKeyCount: base.ExternalKeyCount, + InternalKeyCount: base.InternalKeyCount, + ImportedKeyCount: base.ImportedKeyCount, + IsWatchOnly: base.IsWatchOnly, + CreatedAt: base.CreatedAt, + Purpose: base.Purpose, + CoinType: base.CoinType, + IDToOriginType: idToAccountOrigin[int16], }) } diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index 94f665c1a0..a746a3c6f3 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -90,7 +90,7 @@ func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, } info = buildAccountInfo( - accNumber, params.Name, DerivedAccount, false, + accNumber, params.Name, DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -223,6 +223,9 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, + ExternalKeyCount: row.ExternalKeyCount, + InternalKeyCount: row.InternalKeyCount, + ImportedKeyCount: row.ImportedKeyCount, EncryptedPublicKey: row.EncryptedPublicKey, MasterFingerprint: row.MasterFingerprint, IsWatchOnly: row.IsWatchOnly, @@ -260,14 +263,17 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, base := sqlcsqlite.GetAccountByScopeAndNameRow(row) return accountRowToInfo(accountInfoRow[int64]{ - AccountNumber: base.AccountNumber, - AccountName: base.AccountName, - OriginID: base.OriginID, - IsWatchOnly: base.IsWatchOnly, - CreatedAt: base.CreatedAt, - Purpose: base.Purpose, - CoinType: base.CoinType, - IDToOriginType: idToAccountOrigin[int64], + AccountNumber: base.AccountNumber, + AccountName: base.AccountName, + OriginID: base.OriginID, + ExternalKeyCount: base.ExternalKeyCount, + InternalKeyCount: base.InternalKeyCount, + ImportedKeyCount: base.ImportedKeyCount, + IsWatchOnly: base.IsWatchOnly, + CreatedAt: base.CreatedAt, + Purpose: base.Purpose, + CoinType: base.CoinType, + IDToOriginType: idToAccountOrigin[int64], }) } diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index 9da524fa30..afa4e885e9 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -85,10 +85,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.scope_id = $1 AND a.account_name = $2; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.scope_id = $1 AND a.account_name = $2 +GROUP BY a.id, ks.id; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. @@ -100,10 +105,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.scope_id = $1 AND a.account_number = $2; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.scope_id = $1 AND a.account_number = $2 +GROUP BY a.id, ks.id; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. @@ -115,14 +125,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND a.account_name = $4; + AND a.account_name = $4 +GROUP BY a.id, ks.id; -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. @@ -134,14 +149,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND a.account_number = $4; + AND a.account_number = $4 +GROUP BY a.id, ks.id; -- name: GetAccountPropsById :one -- Returns full account properties by account id. @@ -156,10 +176,15 @@ SELECT ks.purpose, ks.coin_type, ks.internal_type_id, - ks.external_type_id + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.id = $1; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.id = $1 +GROUP BY a.id, ks.id; -- name: ListAccountsByScope :many -- Lists all accounts in a scope, ordered by account number. Imported accounts @@ -172,10 +197,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWalletScope :many @@ -189,13 +219,18 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWalletAndName :many @@ -209,10 +244,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND a.account_name = $2 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWallet :many @@ -226,10 +266,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: UpdateAccountNameByWalletScopeAndNumber :execrows diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index f6bfeb5f10..cea8dc577a 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -56,10 +56,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.scope_id = ? AND a.account_name = ?; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.scope_id = ? AND a.account_name = ? +GROUP BY a.id, ks.id; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. @@ -71,10 +76,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.scope_id = ? AND a.account_number = ?; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.scope_id = ? AND a.account_number = ? +GROUP BY a.id, ks.id; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. @@ -86,14 +96,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND a.account_name = ?; + AND a.account_name = ? +GROUP BY a.id, ks.id; -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. @@ -105,14 +120,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND a.account_number = ?; + AND a.account_number = ? +GROUP BY a.id, ks.id; -- name: GetAccountPropsById :one -- Returns full account properties by account id. @@ -127,10 +147,15 @@ SELECT ks.purpose, ks.coin_type, ks.internal_type_id, - ks.external_type_id + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -WHERE a.id = ?; +LEFT JOIN addresses AS addr ON a.id = addr.account_id +WHERE a.id = ? +GROUP BY a.id, ks.id; -- name: ListAccountsByScope :many -- Lists all accounts in a scope, ordered by account number. Imported accounts @@ -143,10 +168,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWalletScope :many @@ -160,13 +190,18 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWalletAndName :many @@ -180,10 +215,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND a.account_name = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWallet :many @@ -197,10 +237,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: UpdateAccountNameByWalletScopeAndNumber :execrows diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index e6998c7c81..b3bd193e1b 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -181,10 +181,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 AND a.account_name = $2 +GROUP BY a.id, ks.id ` type GetAccountByScopeAndNameParams struct { @@ -194,13 +199,16 @@ type GetAccountByScopeAndNameParams struct { type GetAccountByScopeAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by scope id and account name. @@ -216,6 +224,9 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -229,10 +240,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 AND a.account_number = $2 +GROUP BY a.id, ks.id ` type GetAccountByScopeAndNumberParams struct { @@ -242,13 +258,16 @@ type GetAccountByScopeAndNumberParams struct { type GetAccountByScopeAndNumberRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by scope id and account number. @@ -264,6 +283,9 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -277,14 +299,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND a.account_name = $4 +GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNameParams struct { @@ -296,13 +323,16 @@ type GetAccountByWalletScopeAndNameParams struct { type GetAccountByWalletScopeAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by wallet id, scope tuple, and account name. @@ -323,6 +353,9 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -336,14 +369,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND a.account_number = $4 +GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNumberParams struct { @@ -355,13 +393,16 @@ type GetAccountByWalletScopeAndNumberParams struct { type GetAccountByWalletScopeAndNumberRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by wallet id, scope tuple, and account number. @@ -382,6 +423,9 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -398,10 +442,15 @@ SELECT ks.purpose, ks.coin_type, ks.internal_type_id, - ks.external_type_id + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.id = $1 +GROUP BY a.id, ks.id ` type GetAccountPropsByIdRow struct { @@ -416,6 +465,9 @@ type GetAccountPropsByIdRow struct { CoinType int64 InternalTypeID int16 ExternalTypeID int16 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns full account properties by account id. @@ -434,6 +486,9 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount &i.CoinType, &i.InternalTypeID, &i.ExternalTypeID, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -479,22 +534,30 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` type ListAccountsByScopeRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts in a scope, ordered by account number. Imported accounts @@ -517,6 +580,9 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -540,22 +606,30 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` type ListAccountsByWalletRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet, ordered by account number. Imported @@ -578,6 +652,9 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -601,10 +678,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND a.account_name = $2 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -615,13 +697,16 @@ type ListAccountsByWalletAndNameParams struct { type ListAccountsByWalletAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet filtered by account name, ordered by account @@ -644,6 +729,9 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -667,13 +755,18 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 +GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -685,13 +778,16 @@ type ListAccountsByWalletScopeParams struct { type ListAccountsByWalletScopeRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int16 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int16 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet and scope tuple, ordered by account number. @@ -714,6 +810,9 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 3eacdc0306..51c363249e 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -179,10 +179,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? AND a.account_name = ? +GROUP BY a.id, ks.id ` type GetAccountByScopeAndNameParams struct { @@ -192,13 +197,16 @@ type GetAccountByScopeAndNameParams struct { type GetAccountByScopeAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by scope id and account name. @@ -214,6 +222,9 @@ func (q *Queries) GetAccountByScopeAndName(ctx context.Context, arg GetAccountBy &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -227,10 +238,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? AND a.account_number = ? +GROUP BY a.id, ks.id ` type GetAccountByScopeAndNumberParams struct { @@ -240,13 +256,16 @@ type GetAccountByScopeAndNumberParams struct { type GetAccountByScopeAndNumberRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by scope id and account number. @@ -262,6 +281,9 @@ func (q *Queries) GetAccountByScopeAndNumber(ctx context.Context, arg GetAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -275,14 +297,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND a.account_name = ? +GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNameParams struct { @@ -294,13 +321,16 @@ type GetAccountByWalletScopeAndNameParams struct { type GetAccountByWalletScopeAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by wallet id, scope tuple, and account name. @@ -321,6 +351,9 @@ func (q *Queries) GetAccountByWalletScopeAndName(ctx context.Context, arg GetAcc &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -334,14 +367,19 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND a.account_number = ? +GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNumberParams struct { @@ -353,13 +391,16 @@ type GetAccountByWalletScopeAndNumberParams struct { type GetAccountByWalletScopeAndNumberRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns a single account by wallet id, scope tuple, and account number. @@ -380,6 +421,9 @@ func (q *Queries) GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetA &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -396,10 +440,15 @@ SELECT ks.purpose, ks.coin_type, ks.internal_type_id, - ks.external_type_id + ks.external_type_id, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.id = ? +GROUP BY a.id, ks.id ` type GetAccountPropsByIdRow struct { @@ -414,6 +463,9 @@ type GetAccountPropsByIdRow struct { CoinType int64 InternalTypeID int64 ExternalTypeID int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Returns full account properties by account id. @@ -432,6 +484,9 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount &i.CoinType, &i.InternalTypeID, &i.ExternalTypeID, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ) return i, err } @@ -477,22 +532,30 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` type ListAccountsByScopeRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts in a scope, ordered by account number. Imported accounts @@ -515,6 +578,9 @@ func (q *Queries) ListAccountsByScope(ctx context.Context, scopeID int64) ([]Lis &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -538,22 +604,30 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` type ListAccountsByWalletRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet, ordered by account number. Imported @@ -576,6 +650,9 @@ func (q *Queries) ListAccountsByWallet(ctx context.Context, walletID int64) ([]L &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -599,10 +676,15 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND a.account_name = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -613,13 +695,16 @@ type ListAccountsByWalletAndNameParams struct { type ListAccountsByWalletAndNameRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet filtered by account name, ordered by account @@ -642,6 +727,9 @@ func (q *Queries) ListAccountsByWalletAndName(ctx context.Context, arg ListAccou &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } @@ -665,13 +753,18 @@ SELECT a.is_watch_only, a.created_at, ks.purpose, - ks.coin_type + ks.coin_type, + a.next_external_index AS external_key_count, + a.next_internal_index AS internal_key_count, + count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? +GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -683,13 +776,16 @@ type ListAccountsByWalletScopeParams struct { type ListAccountsByWalletScopeRow struct { ID int64 - AccountNumber sql.NullInt64 - AccountName string - OriginID int64 - IsWatchOnly bool - CreatedAt time.Time - Purpose int64 - CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + OriginID int64 + IsWatchOnly bool + CreatedAt time.Time + Purpose int64 + CoinType int64 + ExternalKeyCount int64 + InternalKeyCount int64 + ImportedKeyCount int64 } // Lists all accounts for a wallet and scope tuple, ordered by account number. @@ -712,6 +808,9 @@ func (q *Queries) ListAccountsByWalletScope(ctx context.Context, arg ListAccount &i.CreatedAt, &i.Purpose, &i.CoinType, + &i.ExternalKeyCount, + &i.InternalKeyCount, + &i.ImportedKeyCount, ); err != nil { return nil, err } From 7a6cd57c9e0d7365a10391acc5339162ca4c9d87 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 22 Jan 2026 01:28:55 -0300 Subject: [PATCH 386/691] wallet: add AddressStore tests --- .../internal/db/itest/address_store_test.go | 1120 +++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 88 ++ .../internal/db/itest/fixtures_sqlite_test.go | 88 ++ 3 files changed, 1296 insertions(+) create mode 100644 wallet/internal/db/itest/address_store_test.go diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go new file mode 100644 index 0000000000..e555c1d349 --- /dev/null +++ b/wallet/internal/db/itest/address_store_test.go @@ -0,0 +1,1120 @@ +//go:build itest + +package itest + +import ( + "context" + "encoding/binary" + "math" + "sort" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// mockDeriveFunc is a test helper that returns a mock AddressDerivationFunc +// for testing NewDerivedAddress. It generates deterministic script_pub_key +// values based on accountID, branch, and index to ensure uniqueness. +func mockDeriveFunc() db.AddressDerivationFunc { + return func(ctx context.Context, accountID uint32, branch uint32, + index uint32) (*db.DerivedAddressData, error) { + + scriptPubKey := make([]byte, 20) + binary.BigEndian.PutUint32(scriptPubKey[0:4], accountID) + binary.BigEndian.PutUint32(scriptPubKey[4:8], branch) + binary.BigEndian.PutUint32(scriptPubKey[8:12], index) + return &db.DerivedAddressData{ + ScriptPubKey: scriptPubKey, + }, nil + } +} + +func newDerivedAddress(t *testing.T, store db.AddressStore, walletID uint32, + scope db.KeyScope, accountName string, change bool) *db.AddressInfo { + + t.Helper() + + info, err := store.NewDerivedAddress( + t.Context(), db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Change: change, + }, mockDeriveFunc(), + ) + require.NoError(t, err) + + return info +} + +func createDerivedAddresses(t *testing.T, store db.AddressStore, + walletID uint32, scope db.KeyScope, accountName string, change bool, + count int) []db.AddressInfo { + + t.Helper() + + addresses := make([]db.AddressInfo, 0, count) + for i := 0; i < count; i++ { + info := newDerivedAddress( + t, store, walletID, scope, accountName, change, + ) + addresses = append(addresses, *info) + } + + return addresses +} + +func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, + scope db.KeyScope, accountName string) *db.AccountInfo { + + t.Helper() + + account, err := store.GetAccount( + t.Context(), getAccountQueryByName(walletID, scope, accountName), + ) + require.NoError(t, err) + + return account +} + +// TestNewImportedAddress verifies that NewImportedAddress correctly imports +// addresses of different types, both watch-only and spendable. +func TestNewImportedAddress(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-imported-addresses") + + createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0086, "imported") + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + pubKey := privKey.PubKey() + + p2pkhAddr, err := btcutil.NewAddressPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chaincfg.MainNetParams, + ) + require.NoError(t, err) + + p2wpkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(pubKey.SerializeCompressed()), &chaincfg.MainNetParams, + ) + require.NoError(t, err) + + p2trAddr, err := btcutil.NewAddressTaproot( + pubKey.SerializeCompressed()[1:], &chaincfg.MainNetParams, + ) + require.NoError(t, err) + + testCases := []struct { + name string + addr btcutil.Address + scope db.KeyScope + expectedAddrType db.AddressType + providePrivateKey bool + }{ + { + name: "P2PKH watch-only", + addr: p2pkhAddr, + scope: db.KeyScopeBIP0044, + expectedAddrType: db.PubKeyHash, + providePrivateKey: false, + }, + { + name: "P2PKH spendable", + addr: p2pkhAddr, + scope: db.KeyScopeBIP0044, + expectedAddrType: db.PubKeyHash, + providePrivateKey: true, + }, + { + name: "P2WPKH watch-only", + addr: p2wpkhAddr, + scope: db.KeyScopeBIP0084, + expectedAddrType: db.WitnessPubKey, + providePrivateKey: false, + }, + { + name: "P2WPKH spendable", + addr: p2wpkhAddr, + scope: db.KeyScopeBIP0084, + expectedAddrType: db.WitnessPubKey, + providePrivateKey: true, + }, + { + name: "P2TR watch-only", + addr: p2trAddr, + scope: db.KeyScopeBIP0086, + expectedAddrType: db.TaprootPubKey, + providePrivateKey: false, + }, + { + name: "P2TR spendable", + addr: p2trAddr, + scope: db.KeyScopeBIP0086, + expectedAddrType: db.TaprootPubKey, + providePrivateKey: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: tc.scope, + AddressType: tc.expectedAddrType, + PubKey: RandomBytes(33), + ScriptPubKey: RandomBytes(32), + } + + if tc.providePrivateKey { + params.EncryptedPrivateKey = RandomBytes(32) + } + + // Import the address. + info, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + // Verify AddressInfo fields. + require.NotZero(t, info.ID) + require.NotZero(t, info.AccountID) + require.Equal(t, db.ImportedAccount, info.Origin) + require.NotZero(t, info.CreatedAt) + require.Equal(t, uint32(0), info.Branch) + require.Equal(t, uint32(0), info.Index) + require.NotNil(t, info.PubKey) + require.NotNil(t, info.ScriptPubKey) + require.Equal(t, tc.expectedAddrType, info.AddrType) + require.Equal(t, !tc.providePrivateKey, info.IsWatchOnly) + + // Verify account imported_key_count incremented. + account, err := store.GetAccount( + t.Context(), getAccountQueryByName( + walletID, tc.scope, "imported", + ), + ) + require.NoError(t, err) + require.Greater(t, account.ImportedKeyCount, uint32(0)) + + // Verify address_secrets row for imported addresses. + addressID := getAddressID( + t, queries, params.ScriptPubKey, walletID, + ) + + secret, err := getAddressSecret(t, queries, addressID) + require.NoError(t, err) + if tc.providePrivateKey { + require.Equal( + t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, + ) + require.Empty(t, secret.EncryptedScript) + } else { + require.Empty(t, secret.EncryptedPrivKey) + require.Empty(t, secret.EncryptedScript) + } + }) + } +} + +// TestNewImportedAddressWithEncryptedScript verifies that NewImportedAddress +// correctly imports script-based addresses (P2SH, P2WSH) with EncryptedScript, +// and that the EncryptedScript is stored and retrievable via GetAddressSecret. +func TestNewImportedAddressWithEncryptedScript(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-encrypted-script") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0049Plus, "imported") + + redeemScript := RandomBytes(32) + witnessScript := RandomBytes(48) + + testCases := []struct { + name string + scope db.KeyScope + addressType db.AddressType + encryptedScript []byte + hasPrivateKey bool + hasScript bool + expectedAddrType db.AddressType + }{ + { + name: "P2SH with EncryptedScript only (watch-only)", + scope: db.KeyScopeBIP0044, + addressType: db.ScriptHash, + encryptedScript: redeemScript, + hasPrivateKey: false, + hasScript: true, + expectedAddrType: db.ScriptHash, + }, + { + name: "P2SH with both EncryptedPrivateKey and EncryptedScript", + scope: db.KeyScopeBIP0044, + addressType: db.ScriptHash, + encryptedScript: redeemScript, + hasPrivateKey: true, + hasScript: true, + expectedAddrType: db.ScriptHash, + }, + { + name: "P2WSH with EncryptedScript only (watch-only)", + scope: db.KeyScopeBIP0049Plus, + addressType: db.WitnessScript, + encryptedScript: witnessScript, + hasPrivateKey: false, + hasScript: true, + expectedAddrType: db.WitnessScript, + }, + { + name: "P2WSH with both EncryptedPrivateKey and EncryptedScript", + scope: db.KeyScopeBIP0049Plus, + addressType: db.WitnessScript, + encryptedScript: witnessScript, + hasPrivateKey: true, + hasScript: true, + expectedAddrType: db.WitnessScript, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + scriptPubKey := RandomBytes(32) + + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: tc.scope, + AddressType: tc.addressType, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + EncryptedScript: tc.encryptedScript, + } + + if tc.hasPrivateKey { + params.EncryptedPrivateKey = RandomBytes(32) + } + + info, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + require.NotZero(t, info.ID) + require.NotZero(t, info.AccountID) + require.Equal(t, db.ImportedAccount, info.Origin) + require.NotZero(t, info.CreatedAt) + require.Equal(t, uint32(0), info.Branch) + require.Equal(t, uint32(0), info.Index) + require.NotNil(t, info.PubKey) + require.NotNil(t, info.ScriptPubKey) + require.Equal(t, tc.expectedAddrType, info.AddrType) + + addressID := getAddressID( + t, queries, params.ScriptPubKey, walletID, + ) + + secret, err := getAddressSecret(t, queries, addressID) + require.NoError(t, err) + + require.Equal(t, tc.encryptedScript, secret.EncryptedScript) + + if tc.hasPrivateKey { + require.Equal( + t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, + ) + } + }) + } +} + +// TestNewImportedAddressDuplicate verifies that importing an address with +// a duplicate ScriptPubKey fails with a constraint error. +func TestNewImportedAddressDuplicate(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-duplicate-import") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + + // Set up encryption parameters (same for both imports). + scriptPubKey := RandomBytes(32) + + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + EncryptedPrivateKey: RandomBytes(32), + } + + // Import address first time (should succeed). + _, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + // Attempt to import with same ScriptPubKey (should fail). + _, err = store.NewImportedAddress(t.Context(), params) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") + require.ErrorContains(t, err, "script_pub_key") +} + +// TestGetAddressSecret verifies that GetAddressSecret correctly retrieves +// address secrets for watch-only imported addresses and returns an error for +// spendable addresses or non-existent address IDs. +func TestGetAddressSecret(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-secrets") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + + testCases := []struct { + name string + providePrivateKey bool + shouldHaveSecret bool + }{ + { + name: "spendable import", + providePrivateKey: true, + shouldHaveSecret: true, + }, + { + name: "watch-only import", + providePrivateKey: false, + shouldHaveSecret: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AddressType: db.PubKeyHash, + PubKey: RandomBytes(33), + ScriptPubKey: RandomBytes(32), + } + + if tc.providePrivateKey { + params.EncryptedPrivateKey = RandomBytes(32) + } + + info, errNewAddr := store.NewImportedAddress(t.Context(), params) + require.NoError(t, errNewAddr) + + if tc.shouldHaveSecret { + secret, err := store.GetAddressSecret(t.Context(), info.ID) + require.NoError(t, err) + require.NotNil(t, secret) + require.Equal(t, info.ID, secret.AddressID) + require.Equal( + t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, + ) + require.Empty(t, secret.EncryptedScript) + } else { + _, err := store.GetAddressSecret(t.Context(), info.ID) + require.ErrorIs(t, err, db.ErrSecretNotFound) + } + }) + } + + // Test non-existent address ID. + t.Run("non-existent address", func(t *testing.T) { + _, err := store.GetAddressSecret(t.Context(), 999999) + require.ErrorIs(t, err, db.ErrAddressNotFound) + }) +} + +// TestGetAddress verifies that GetAddress correctly retrieves addresses by +// ID and by encrypted script pubkey, and returns appropriate errors for +// invalid or non-existent queries. +func TestGetAddress(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-address") + + tests := []struct { + name string + setupFunc func(t *testing.T) db.GetAddressQuery + wantErr error + validate func(t *testing.T, addr *db.AddressInfo) + }{ + { + name: "get by encrypted script pubkey", + setupFunc: func(t *testing.T) db.GetAddressQuery { + createImportedAccount( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + + script := RandomBytes(32) + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: script, + } + _, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + return db.GetAddressQuery{ + WalletID: walletID, + ScriptPubKey: script, + } + }, + validate: func(t *testing.T, addr *db.AddressInfo) { + require.NotNil(t, addr.ScriptPubKey) + require.Equal(t, db.ImportedAccount, addr.Origin) + }, + }, + { + name: "address not found by script", + setupFunc: func(t *testing.T) db.GetAddressQuery { + return db.GetAddressQuery{ + WalletID: walletID, + ScriptPubKey: RandomBytes(32), + } + }, + wantErr: db.ErrAddressNotFound, + }, + { + name: "invalid query - empty script pubkey", + setupFunc: func(t *testing.T) db.GetAddressQuery { + return db.GetAddressQuery{ + WalletID: walletID, + ScriptPubKey: nil, + } + }, + wantErr: db.ErrInvalidAddressQuery, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + query := tc.setupFunc(t) + addr, err := store.GetAddress(t.Context(), query) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, addr) + return + } + + require.NoError(t, err) + require.NotNil(t, addr) + if tc.validate != nil { + tc.validate(t, addr) + } + }) + } +} + +// TestListAddresses verifies that ListAddresses correctly returns all +// addresses for an account in a specified scope, filters by scope +// appropriately, and handles empty results without error. +func TestListAddresses(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-addresses") + + tests := []struct { + name string + setupFunc func(t *testing.T) db.ListAddressesQuery + wantCount int + wantErr error + validate func(t *testing.T, addrs []db.AddressInfo) + }{ + { + name: "list multiple addresses for account", + setupFunc: func(t *testing.T) db.ListAddressesQuery { + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0044, "test-account", + ) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0044, "test-account", + false, 5, + ) + + return db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AccountName: "test-account", + } + }, + wantCount: 5, + validate: func(t *testing.T, addrs []db.AddressInfo) { + require.Len(t, addrs, 5) + for i, addr := range addrs { + require.Equal(t, uint32(i), addr.Index) + require.Equal(t, uint32(0), addr.Branch) + require.Equal(t, db.DerivedAccount, addr.Origin) + } + }, + }, + { + name: "list addresses - empty result", + setupFunc: func(t *testing.T) db.ListAddressesQuery { + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0084, "empty-account", + ) + + return db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "empty-account", + } + }, + wantCount: 0, + }, + { + name: "list addresses filters by scope correctly", + setupFunc: func(t *testing.T) db.ListAddressesQuery { + // Create accounts in different scopes. + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0044, "bip44-multi", + ) + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0049Plus, "bip49-multi", + ) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0044, "bip44-multi", + false, 3, + ) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0049Plus, "bip49-multi", + false, 2, + ) + + // Query only BIP0044 scope. + return db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AccountName: "bip44-multi", + } + }, + wantCount: 3, + validate: func(t *testing.T, addrs []db.AddressInfo) { + require.Len(t, addrs, 3) + for _, addr := range addrs { + require.Equal(t, db.DerivedAccount, addr.Origin) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + query := tc.setupFunc(t) + addrs, err := store.ListAddresses(t.Context(), query) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Len(t, addrs, tc.wantCount) + + if tc.validate != nil { + tc.validate(t, addrs) + } + }) + } +} + +// TestNewDerivedAddress verifies that NewDerivedAddress correctly creates +// derived addresses with proper AddressInfo fields for both external and +// change addresses. +func TestNewDerivedAddress(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-derived") + + // Create account in BIP44 scope. + accountName := "derived-test" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0044, accountName) + + testCases := []struct { + name string + change bool + expectedBranch uint32 + }{ + { + name: "external address", + change: false, + expectedBranch: 0, + }, + { + name: "change address", + change: true, + expectedBranch: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + info := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0044, accountName, tc.change, + ) + + // Verify AddressInfo fields. + require.NotZero(t, info.ID) + require.NotZero(t, info.AccountID) + require.Equal(t, db.DerivedAccount, info.Origin) + require.NotZero(t, info.CreatedAt) + require.Equal(t, tc.expectedBranch, info.Branch) + require.GreaterOrEqual(t, info.Index, uint32(0)) + require.Nil(t, info.ScriptPubKey) + require.Nil(t, info.PubKey) + require.False(t, info.IsWatchOnly) + }) + } +} + +// TestNewImportedAddress_NonExistentImportedAccount verifies that calling +// NewImportedAddress when the implicit "imported" account doesn't exist +// returns db.ErrAccountNotFound. This validates that the implicit account +// lookup fails appropriately when the "imported" account has not been created +// in the specified scope. +func TestNewImportedAddress_NonExistentImportedAccount(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "test-wallet") + + // Attempt to import address when "imported" account doesn't exist. + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + EncryptedPrivateKey: RandomBytes(32), + ScriptPubKey: RandomBytes(32), + } + _, err := store.NewImportedAddress(t.Context(), params) + + // Expect account not found error because an implicit "imported" account + // doesn't exist. + require.ErrorIs(t, err, db.ErrAccountNotFound) +} + +// TestGetAddressSecret_DerivedAddress verifies that calling GetAddressSecret +// on a derived address returns db.ErrSecretNotFound (not ErrAddressNotFound). +// This validates the LEFT JOIN: derived addresses exist in the addresses +// table but have no corresponding row in address_secrets. The query returns a +// row with NULL encrypted_priv_key, and the converter returns ErrSecretNotFound. +func TestGetAddressSecret_DerivedAddress(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "test-wallet") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "test-account") + + params := db.NewDerivedAddressParams{ + WalletID: walletID, + AccountName: "test-account", + Scope: db.KeyScopeBIP0084, + Change: false, + } + addrInfo, err := store.NewDerivedAddress(t.Context(), params, mockDeriveFunc()) + require.NoError(t, err) + + // Attempt to get secret for derived address. + // Derived addresses have no row in address_secrets table. + _, err = store.GetAddressSecret(t.Context(), addrInfo.ID) + + // Expect ErrSecretNotFound (not ErrAddressNotFound) because the + // LEFT JOIN returns a row with NULL encrypted_priv_key. + require.ErrorIs(t, err, db.ErrSecretNotFound) +} + +// TestNewDerivedAddressSequentialIndexes verifies that derived addresses +// receive sequential indexes 0, 1, 2, 3, 4 within the same account and +// branch. +func TestNewDerivedAddressSequentialIndexes(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-sequential-indexes") + + // Create derived account for the test. + accountName := "sequential-test" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + // Create 5 addresses in external branch and verify sequential indexes. + for i := 0; i < 5; i++ { + info := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, false, + ) + require.NotNil(t, info) + require.Equal(t, uint32(i), info.Index) + } +} + +// TestListAddressesOrdering verifies that ListAddresses returns addresses +// sorted by index in ascending order, with addresses grouped by branch. +func TestListAddressesOrdering(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-ordering") + + createDerivedAccount( + t, store, walletID, db.KeyScopeBIP0084, "ordering-account", + ) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0084, "ordering-account", false, 3, + ) + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0084, "ordering-account", true, 3, + ) + + addresses, err := store.ListAddresses( + t.Context(), + db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "ordering-account", + }, + ) + + require.NoError(t, err) + require.Len(t, addresses, 6) + + // Separate addresses by branch for verification. + var externalAddrs []db.AddressInfo + var changeAddrs []db.AddressInfo + + for _, addr := range addresses { + if addr.Branch == 0 { + externalAddrs = append(externalAddrs, addr) + } else { + changeAddrs = append(changeAddrs, addr) + } + } + + // Verify external addresses sorted by index. + for i := 1; i < len(externalAddrs); i++ { + require.True( + t, externalAddrs[i-1].Index <= externalAddrs[i].Index, + "external addresses not in order", + ) + } + + // Verify change addresses sorted by index. + for i := 1; i < len(changeAddrs); i++ { + require.True( + t, changeAddrs[i-1].Index <= changeAddrs[i].Index, + "change addresses not in order", + ) + } +} + +// TestNewDerivedAddressErrors verifies that NewDerivedAddress returns +// appropriate errors for invalid parameters, including non-existent +// accounts, unknown key scopes, and empty account names. +func TestNewDerivedAddressErrors(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-new-derived-address-errors") + + tests := []struct { + name string + params db.NewDerivedAddressParams + wantErr error + }{ + { + name: "non-existent account", + params: db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "non-existent", + Change: false, + }, + wantErr: db.ErrAccountNotFound, + }, + { + name: "empty account name", + params: db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AccountName: "", + Change: false, + }, + wantErr: db.ErrAccountNotFound, + }, + { + name: "unknown key scope returns account not found", + params: db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScope{ + Purpose: 999, + Coin: 999, + }, + AccountName: "any-name", + Change: false, + }, + wantErr: db.ErrAccountNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + info, err := store.NewDerivedAddress(t.Context(), tc.params, mockDeriveFunc()) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, info) + }) + } +} + +// TestNewDerivedAddressConcurrent verifies that concurrent address +// creation produces unique sequential indexes without conflicts. +func TestNewDerivedAddressConcurrent(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "concurrent-wallet") + + accountName := "concurrent-account" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + const workers = 20 + results := make([]db.AddressInfo, workers) + var wg sync.WaitGroup + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + deriveFn := mockDeriveFunc() + + for i := range workers { + wg.Add(1) + go func(i int) { + defer wg.Done() + info, err := store.NewDerivedAddress( + ctx, db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: accountName, + Change: false, + }, deriveFn, + ) + require.NoError(t, err) + results[i] = *info + }(i) + } + + wg.Wait() + + // Verify all indexes are unique and sequential. + indexes := make([]uint32, workers) + for i, addr := range results { + indexes[i] = addr.Index + } + + sort.Slice(indexes, func(i, j int) bool { + return indexes[i] < indexes[j] + }) + + for i := range workers { + require.Equal(t, uint32(i), indexes[i]) + } +} + +// TestNewDerivedAddressBranchIsolation verifies that external (branch 0) +// and change (branch 1) addresses maintain independent sequential index +// counters within the same account. +func TestNewDerivedAddressBranchIsolation(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-branch-isolation") + + // Create derived account for the test. + accountName := "branch-isolation-test" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + // Create addresses alternating between branches: + // external-0, change-0, external-1, change-1, external-2, change-2. + var externalAddrs []db.AddressInfo + var changeAddrs []db.AddressInfo + + for i := 0; i < 3; i++ { + // Create external address (branch 0). + extInfo := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, false, + ) + externalAddrs = append(externalAddrs, *extInfo) + + // Create change address (branch 1). + chgInfo := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, true, + ) + changeAddrs = append(changeAddrs, *chgInfo) + } + + // Verify external addresses have indexes 0, 1, 2. + for i, addr := range externalAddrs { + require.Equal(t, uint32(i), addr.Index) + require.Equal(t, uint32(0), addr.Branch) + } + + // Verify change addresses have indexes 0, 1, 2. + for i, addr := range changeAddrs { + require.Equal(t, uint32(i), addr.Index) + require.Equal(t, uint32(1), addr.Branch) + } +} + +// TestNewDerivedAddressAccountKeyCounts verifies that account key counts are +// derived from the next index counters for both external and internal branches. +func TestNewDerivedAddressAccountKeyCounts(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-account-key-counts") + + accountName := "counted-account" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0084, accountName, false, 3, + ) + createDerivedAddresses( + t, store, walletID, db.KeyScopeBIP0084, accountName, true, 2, + ) + + account := getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, accountName, + ) + require.Equal(t, uint32(3), account.ExternalKeyCount) + require.Equal(t, uint32(2), account.InternalKeyCount) + require.Zero(t, account.ImportedKeyCount) +} + +// TestNewDerivedAddressBranchCounters verifies that external and internal +// counters advance independently when new addresses are created. +func TestNewDerivedAddressBranchCounters(t *testing.T) { + t.Parallel() + + store, _ := NewTestStore(t) + walletID := newWallet(t, store, "wallet-branch-counters") + + accountName := "branch-counter-account" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, true, + ) + + account := getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, accountName, + ) + require.Equal(t, uint32(0), account.ExternalKeyCount) + require.Equal(t, uint32(1), account.InternalKeyCount) + + newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, accountName, false, + ) + + account = getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, accountName, + ) + require.Equal(t, uint32(1), account.ExternalKeyCount) + require.Equal(t, uint32(1), account.InternalKeyCount) +} + +// TestNewDerivedAddressMaxIndex verifies that addresses can be created +// up to the maximum index (math.MaxUint32), but the next address creation +// fails due to overflow. +func TestNewDerivedAddressMaxIndex(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-max-index") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") + + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + accountID := GetAccountID(t, queries, scopeID, "max-acct") + + // Insert address at MaxUint32 - 1 + CreateAddressWithIndex(t, queries, accountID, 0, math.MaxUint32-1) + + // Set the counter to MaxUint32 so the next allocation gives us MaxUint32 + UpdateAccountNextExternalIndex(t, queries, accountID, math.MaxUint32) + + // This should succeed with address index = MaxUint32. + info := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "max-acct", false, + ) + require.Equal(t, uint32(math.MaxUint32), info.Index) + + // This should fail; the next allocation would overflow + // uint32. + _, err := store.NewDerivedAddress( + t.Context(), db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "max-acct", + Change: false, + }, mockDeriveFunc(), + ) + require.Error(t, err) +} + +// TestNewDerivedAddressMaxIndexInternal verifies that internal addresses can be +// created up to the maximum index (math.MaxUint32), but the next address +// creation fails due to overflow. +func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { + t.Parallel() + + store, queries := NewTestStore(t) + walletID := newWallet(t, store, "wallet-max-index-internal") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") + + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + accountID := GetAccountID(t, queries, scopeID, "max-acct") + + // Insert address at MaxUint32 - 1 in the internal branch. + CreateAddressWithIndex(t, queries, accountID, 1, math.MaxUint32-1) + + // Set the internal counter to MaxUint32 so the next allocation gives us + // MaxUint32. + UpdateAccountNextInternalIndex(t, queries, accountID, math.MaxUint32) + + // This should succeed with address index = MaxUint32. + info := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "max-acct", true, + ) + require.Equal(t, uint32(math.MaxUint32), info.Index) + + // This should fail; the next allocation would overflow uint32. + _, err := store.NewDerivedAddress( + t.Context(), db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: "max-acct", + Change: true, + }, mockDeriveFunc(), + ) + require.ErrorIs(t, err, db.ErrMaxAddressIndexReached) +} diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 6fc07f9446..ed00da6f62 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -47,6 +47,55 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, require.NoError(t, err) } +// CreateAddressWithIndex creates a derived address with a specific address +// index. Used to test address index overflow without creating billions of +// addresses. +func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, + accountID int64, branch uint32, index uint32) { + t.Helper() + + _, err := queries.CreateDerivedAddress( + t.Context(), sqlcpg.CreateDerivedAddressParams{ + AccountID: accountID, + ScriptPubKey: RandomBytes(20), + TypeID: int16(db.WitnessPubKey), + AddressBranch: sql.NullInt64{Int64: int64(branch), Valid: true}, + AddressIndex: sql.NullInt64{Int64: int64(index), Valid: true}, + PubKey: nil, + }, + ) + require.NoError(t, err) +} + +// UpdateAccountNextExternalIndex updates the account's external index counter. +func UpdateAccountNextExternalIndex(t *testing.T, queries *sqlcpg.Queries, + accountID int64, nextIndex uint32) { + t.Helper() + + err := queries.UpdateAccountNextExternalIndex( + t.Context(), sqlcpg.UpdateAccountNextExternalIndexParams{ + ID: accountID, + NextExternalIndex: int64(nextIndex), + }, + ) + require.NoError(t, err) +} + +// UpdateAccountNextInternalIndex updates the account's internal index counter. +func UpdateAccountNextInternalIndex(t *testing.T, queries *sqlcpg.Queries, + accountID int64, nextIndex uint32) { + + t.Helper() + + err := queries.UpdateAccountNextInternalIndex( + t.Context(), sqlcpg.UpdateAccountNextInternalIndexParams{ + ID: accountID, + NextInternalIndex: int64(nextIndex), + }, + ) + require.NoError(t, err) +} + // GetKeyScopeID retrieves the scope ID for a given wallet and key scope. func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, walletID uint32, scope db.KeyScope) int64 { @@ -63,3 +112,42 @@ func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, return row.ID } + +// GetAccountID retrieves the account ID for a given scope and account name. +func GetAccountID(t *testing.T, queries *sqlcpg.Queries, + scopeID int64, accountName string) int64 { + t.Helper() + + row, err := queries.GetAccountByScopeAndName( + t.Context(), + sqlcpg.GetAccountByScopeAndNameParams{ + ScopeID: scopeID, + AccountName: accountName, + }, + ) + require.NoError(t, err) + + return row.ID +} + +func getAddressID(t *testing.T, queries *sqlcpg.Queries, scriptPubKey []byte, + walletID uint32) int64 { + t.Helper() + + addr, err := queries.GetAddressByScriptPubKey( + t.Context(), sqlcpg.GetAddressByScriptPubKeyParams{ + ScriptPubKey: scriptPubKey, + WalletID: int64(walletID), + }, + ) + require.NoError(t, err) + + return addr.ID +} + +func getAddressSecret(t *testing.T, queries *sqlcpg.Queries, + addressID int64) (sqlcpg.GetAddressSecretRow, error) { + t.Helper() + + return queries.GetAddressSecret(t.Context(), addressID) +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 3d5dfcf676..e0c68134fd 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -47,6 +47,55 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcsqlite.Queries, require.NoError(t, err) } +// CreateAddressWithIndex creates a derived address with a specific address +// index. Used to test address index overflow without creating billions of +// addresses. +func CreateAddressWithIndex(t *testing.T, queries *sqlcsqlite.Queries, + accountID int64, branch uint32, index uint32) { + t.Helper() + + _, err := queries.CreateDerivedAddress( + t.Context(), sqlcsqlite.CreateDerivedAddressParams{ + AccountID: accountID, + ScriptPubKey: RandomBytes(20), + TypeID: int64(db.WitnessPubKey), + AddressBranch: sql.NullInt64{Int64: int64(branch), Valid: true}, + AddressIndex: sql.NullInt64{Int64: int64(index), Valid: true}, + PubKey: nil, + }, + ) + require.NoError(t, err) +} + +// UpdateAccountNextExternalIndex updates the account's external index counter. +func UpdateAccountNextExternalIndex(t *testing.T, queries *sqlcsqlite.Queries, + accountID int64, nextIndex uint32) { + t.Helper() + + err := queries.UpdateAccountNextExternalIndex( + t.Context(), sqlcsqlite.UpdateAccountNextExternalIndexParams{ + ID: accountID, + NextExternalIndex: int64(nextIndex), + }, + ) + require.NoError(t, err) +} + +// UpdateAccountNextInternalIndex updates the account's internal index counter. +func UpdateAccountNextInternalIndex(t *testing.T, queries *sqlcsqlite.Queries, + accountID int64, nextIndex uint32) { + + t.Helper() + + err := queries.UpdateAccountNextInternalIndex( + t.Context(), sqlcsqlite.UpdateAccountNextInternalIndexParams{ + ID: accountID, + NextInternalIndex: int64(nextIndex), + }, + ) + require.NoError(t, err) +} + // GetKeyScopeID retrieves the scope ID for a given wallet and key scope. func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, walletID uint32, scope db.KeyScope) int64 { @@ -63,3 +112,42 @@ func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, return row.ID } + +// GetAccountID retrieves the account ID for a given scope and account name. +func GetAccountID(t *testing.T, queries *sqlcsqlite.Queries, + scopeID int64, accountName string) int64 { + t.Helper() + + row, err := queries.GetAccountByScopeAndName( + t.Context(), + sqlcsqlite.GetAccountByScopeAndNameParams{ + ScopeID: scopeID, + AccountName: accountName, + }, + ) + require.NoError(t, err) + + return row.ID +} + +func getAddressID(t *testing.T, queries *sqlcsqlite.Queries, + scriptPubKey []byte, walletID uint32) int64 { + t.Helper() + + addr, err := queries.GetAddressByScriptPubKey( + t.Context(), sqlcsqlite.GetAddressByScriptPubKeyParams{ + ScriptPubKey: scriptPubKey, + WalletID: int64(walletID), + }, + ) + require.NoError(t, err) + + return addr.ID +} + +func getAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, + addressID int64) (sqlcsqlite.GetAddressSecretRow, error) { + t.Helper() + + return queries.GetAddressSecret(t.Context(), addressID) +} From 099942a465c555da5d8e6f065ff64cdc76f9732c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 02:08:51 -0300 Subject: [PATCH 387/691] wallet: limit parallel itests for pg db --- wallet/internal/db/itest/pg_test.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index e93c96a87d..f5f5bd0cff 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -23,6 +23,9 @@ import ( ) var ( + // Limit concurrent database creation to avoid exhausting connections. + pgDBSemaphore = make(chan struct{}, 4) + // Shared container instance, reused across tests for performance. // This is safe to use concurrently because we only share the container // and not the database inside it. Each test gets its own database. @@ -134,6 +137,13 @@ func NewPostgresDB(t *testing.T) *sql.DB { t.Helper() ctx := t.Context() + // Acquire a semaphore slot to limit concurrent database creation and + // parallel test execution that depends on it. + pgDBSemaphore <- struct{}{} + defer func() { + <-pgDBSemaphore + }() + container, err := GetPostgresContainer(ctx) require.NoError(t, err, "failed to get postgres container") @@ -145,12 +155,6 @@ func NewPostgresDB(t *testing.T) *sql.DB { require.NoError(t, err, "failed to open admin connection") require.NotNil(t, adminDB, "admin connection is nil") - // Close the connection to avoid leaking an idle connection during tests. - // The container is reused across all tests, so we explicitly clean this up. - t.Cleanup(func() { - _ = adminDB.Close() - }) - // Create a database name based on the test name. dbName := sanitizedPgDBName(t) @@ -159,6 +163,10 @@ func NewPostgresDB(t *testing.T) *sql.DB { _, err = adminDB.ExecContext(ctx, createDBStmt) require.NoError(t, err, "failed to create test database") + // Close the connection to avoid leaking an idle connection during tests. + // The container is reused across all tests, so we explicitly clean this up. + _ = adminDB.Close() + // Build the connection string for the test database. testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) From 6715ef3d5befaeceb69a36b3e8b82040a4d6b6c5 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Feb 2026 14:29:41 -0300 Subject: [PATCH 388/691] wallet: fix ScriptPubKey comments --- wallet/internal/db/addresses_common.go | 17 +++++++++-------- wallet/internal/db/data_types.go | 7 ++----- wallet/internal/db/itest/address_store_test.go | 2 +- .../migrations/postgres/000006_addresses.up.sql | 13 ++++--------- .../migrations/sqlite/000006_addresses.up.sql | 13 ++++--------- 5 files changed, 20 insertions(+), 32 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index 9cb9884f1f..d767faf4a0 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -461,14 +461,15 @@ func createDerivedAddress[T any](ctx context.Context, } return &AddressInfo{ - ID: id, - AccountID: convertedAcctID, - AddrType: addrType, - CreatedAt: rowCreatedAt(row), - Origin: DerivedAccount, - Branch: branch, - Index: index, - IsWatchOnly: false, + ID: id, + AccountID: convertedAcctID, + AddrType: addrType, + CreatedAt: rowCreatedAt(row), + Origin: DerivedAccount, + Branch: branch, + Index: index, + ScriptPubKey: scriptPubKey, + IsWatchOnly: false, }, nil } diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 875dfa0610..25b21d3c08 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -588,8 +588,7 @@ type AddressInfo struct { // addresses. Index uint32 - // ScriptPubKey is the script pubkey (plaintext). Zero value for - // derived addresses. + // ScriptPubKey is the script pubkey (plaintext). ScriptPubKey []byte // PubKey is the public key (plaintext). Zero value for derived @@ -680,9 +679,7 @@ type GetAddressQuery struct { // databases (signed 64-bit integers). WalletID uint32 - // ScriptPubKey is the script pubkey. If provided, the query will be - // performed using this value. Only applicable to imported addresses, - // as derived addresses have NULL script pubkeys. + // ScriptPubKey is the script pubkey to be fetched. ScriptPubKey []byte } diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index e555c1d349..901da9b29a 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -680,7 +680,7 @@ func TestNewDerivedAddress(t *testing.T) { require.NotZero(t, info.CreatedAt) require.Equal(t, tc.expectedBranch, info.Branch) require.GreaterOrEqual(t, info.Index, uint32(0)) - require.Nil(t, info.ScriptPubKey) + require.NotNil(t, info.ScriptPubKey) require.Nil(t, info.PubKey) require.False(t, info.IsWatchOnly) }) diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 639d62150d..2d83ca3242 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -55,18 +55,13 @@ ON addresses (account_id, address_branch, address_index) WHERE address_branch IS NOT NULL AND address_index IS NOT NULL; --- Unique partial index to prevent duplicate script_pub_key within the same --- account. Only enforced when script_pub_key is non-NULL (imported addresses). --- Derived addresses are excluded from this constraint. +-- Unique index to prevent duplicate script_pub_key within the same account. CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key -ON addresses (account_id, script_pub_key) -WHERE script_pub_key IS NOT NULL; +ON addresses (account_id, script_pub_key); -- Index on script_pub_key for efficient lookups by script pubkey. --- Used by GetAddressByScriptPubKey which joins through accounts to key_scopes. -CREATE INDEX idx_addresses_script_pub_key -ON addresses (script_pub_key) -WHERE script_pub_key IS NOT NULL; +-- Used by GetAddressByScriptPubKey. +CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); -- Address Secrets table stores sensitive encrypted material needed to spend -- from an address. This table has a one-to-one relationship with addresses. diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index 3d3b6e50a5..3c541b756f 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -56,18 +56,13 @@ WHERE address_branch IS NOT NULL AND address_index IS NOT NULL; --- Unique partial index to prevent duplicate script_pub_key within the same --- account. Only enforced when script_pub_key is non-NULL (imported addresses). --- Derived addresses are excluded from this constraint. +-- Unique index to prevent duplicate script_pub_key within the same account. CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key -ON addresses (account_id, script_pub_key) -WHERE script_pub_key IS NOT NULL; +ON addresses (account_id, script_pub_key); -- Index on script_pub_key for efficient lookups by script pubkey. --- Used by GetAddressByScriptPubKey which joins through accounts to key_scopes. -CREATE INDEX idx_addresses_script_pub_key -ON addresses (script_pub_key) -WHERE script_pub_key IS NOT NULL; +-- Used by GetAddressByScriptPubKey. +CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); -- Address Secrets table stores sensitive encrypted material needed to spend -- from an address. This table has a one-to-one relationship with addresses. From 0c738dd2bd74f81a470f399b67dc71755a43ccca Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Feb 2026 14:55:13 -0300 Subject: [PATCH 389/691] wallet: add db checks on address index and branch --- .../db/migrations/postgres/000005_accounts.up.sql | 6 ++++++ .../db/migrations/postgres/000006_addresses.up.sql | 10 ++++++++++ .../db/migrations/sqlite/000005_accounts.up.sql | 6 ++++++ .../db/migrations/sqlite/000006_addresses.up.sql | 10 ++++++++++ 4 files changed, 32 insertions(+) diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index cfe297ce26..1687b3f997 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -70,6 +70,12 @@ CREATE TABLE accounts ( -- Next index to use for internal/change addresses (branch 1) next_internal_index BIGINT NOT NULL DEFAULT 0, + -- External derivation index must be non-negative. + CHECK (next_external_index >= 0), + + -- Internal derivation index must be non-negative. + CHECK (next_internal_index >= 0), + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 2d83ca3242..4abffac20b 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -38,6 +38,16 @@ CREATE TABLE addresses ( -- Timestamp when the address was created. Automatically set by the database. created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + -- Branch and index are set together for HD-derived addresses and both + -- NULL for imported addresses. + CHECK ((address_branch IS NULL) = (address_index IS NULL)), + + -- Branch must be a BIP44 branch number when set. + CHECK (address_branch IS NULL OR address_branch IN (0, 1)), + + -- Address index must be non-negative when set. + CHECK (address_index IS NULL OR address_index >= 0), + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure -- that the account cannot be deleted if addresses still exist. FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql index 399104ce8f..03ccdce2d3 100644 --- a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -70,6 +70,12 @@ CREATE TABLE accounts ( -- Next index to use for internal/change addresses (branch 1) next_internal_index INTEGER NOT NULL DEFAULT 0, + -- External derivation index must be non-negative. + CHECK (next_external_index >= 0), + + -- Internal derivation index must be non-negative. + CHECK (next_internal_index >= 0), + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index 3c541b756f..23021a80c8 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -38,6 +38,16 @@ CREATE TABLE addresses ( -- Timestamp when the address was created. Automatically set by the database. created_at DATETIME NOT NULL DEFAULT current_timestamp, + -- Branch and index are set together for HD-derived addresses and both + -- NULL for imported addresses. + CHECK ((address_branch IS NULL) = (address_index IS NULL)), + + -- Branch must be a BIP44 branch number when set. + CHECK (address_branch IS NULL OR address_branch IN (0, 1)), + + -- Address index must be non-negative when set. + CHECK (address_index IS NULL OR address_index >= 0), + -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure -- that the account cannot be deleted if addresses still exist. FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, From 12dd934904cd5c46731952446a39777066bc8dba Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Feb 2026 17:49:40 -0300 Subject: [PATCH 390/691] wallet: use pk instead of unique index for extension tables like secrets --- .../migrations/postgres/000002_wallets.up.sql | 21 ++++++------------- .../postgres/000004_key_scopes.up.sql | 11 +++------- .../postgres/000005_accounts.up.sql | 9 +++----- .../postgres/000006_addresses.up.sql | 10 +++------ .../migrations/sqlite/000002_wallets.up.sql | 21 ++++++------------- .../sqlite/000004_key_scopes.up.sql | 11 +++------- .../migrations/sqlite/000005_accounts.up.sql | 9 +++----- .../migrations/sqlite/000006_addresses.up.sql | 10 +++------ 8 files changed, 30 insertions(+), 72 deletions(-) diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql index c6af352892..65676ee7c3 100644 --- a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/postgres/000002_wallets.up.sql @@ -38,9 +38,9 @@ CREATE UNIQUE INDEX uidx_wallets_name ON wallets (wallet_name); -- Watch-only wallets may have no corresponding row in this table or have all -- private key fields with no data. CREATE TABLE wallet_secrets ( - -- Reference to the wallet these secrets belong to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - wallet_id BIGINT NOT NULL, + -- Reference to the wallet these secrets belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + wallet_id BIGINT PRIMARY KEY, -- Params to derive the private master key. NULL for watch-only wallets. master_priv_params BYTEA, @@ -61,17 +61,13 @@ CREATE TABLE wallet_secrets ( FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT ); --- Enforces one-to-one relationship: each wallet has at most one secrets record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_wallet_secrets_wallet ON wallet_secrets (wallet_id); - -- Wallet Sync States table to store the synchronization state of each wallet. -- This is kept separate from the wallets table to avoid write amplification on -- frequently updated sync data. Each wallet has exactly one sync state record. CREATE TABLE wallet_sync_states ( - -- Reference to the wallet this sync state belongs to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - wallet_id BIGINT NOT NULL, + -- Reference to the wallet this sync state belongs to. Also serves as the + -- primary key, enforcing one-to-one relationship. + wallet_id BIGINT PRIMARY KEY, -- Current sync status of the wallet (references blocks table). NULL for wallets -- that haven't synced any blocks yet. @@ -102,8 +98,3 @@ CREATE TABLE wallet_sync_states ( FOREIGN KEY (birthday_height) REFERENCES blocks (block_height) ON DELETE RESTRICT ); - --- Enforces one-to-one relationship: each wallet has exactly one sync state record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet -ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql index 1162ebd008..5386d22855 100644 --- a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql @@ -46,9 +46,9 @@ ON key_scopes (wallet_id, purpose, coin_type); -- Watch-only scopes may have no corresponding row in this table or have NULL -- encrypted_coin_priv_key. CREATE TABLE key_scope_secrets ( - -- Reference to the key scope these keys belong to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - scope_id BIGINT NOT NULL, + -- Reference to the key scope these keys belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + scope_id BIGINT PRIMARY KEY, -- Encrypted key used to derive private keys for this scope. -- NULL for watch-only key scopes. @@ -58,8 +58,3 @@ CREATE TABLE key_scope_secrets ( -- that the key scope cannot be deleted if secrets still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT ); - --- Enforces one-to-one relationship: each key scope has at most one secrets record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_key_scope_secrets_scope -ON key_scope_secrets (scope_id); diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index 1687b3f997..c10eda819b 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -102,8 +102,9 @@ ON accounts (scope_id, account_name); -- Account Secrets table to hold encrypted account-level secrets. CREATE TABLE account_secrets ( - -- Reference to the account these keys belong to. - account_id BIGINT NOT NULL, + -- Reference to the account these keys belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + account_id BIGINT PRIMARY KEY, -- Encrypted private key for the account. Watch-only accounts may have -- no row in this table. @@ -113,7 +114,3 @@ CREATE TABLE account_secrets ( -- that the account cannot be deleted if secrets still exist. FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT ); - --- Unique index to ensure one-to-one relationship between account and its secrets. -CREATE UNIQUE INDEX uidx_account_secrets_account -ON account_secrets (account_id); diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 4abffac20b..f942265e2e 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -77,8 +77,9 @@ CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); -- from an address. This table has a one-to-one relationship with addresses. -- Watch-only addresses may have no row in this table. CREATE TABLE address_secrets ( - -- Reference to the address these secrets belong to. - address_id BIGINT NOT NULL, + -- Reference to the address these secrets belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + address_id BIGINT PRIMARY KEY, -- Encrypted private key for imported addresses. NULL for HD-derived -- addresses since their private keys are derived from the account key. @@ -92,8 +93,3 @@ CREATE TABLE address_secrets ( -- that the address cannot be deleted if secrets still exist. FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT ); - --- Unique index to ensure one-to-one relationship between address and its --- secrets. -CREATE UNIQUE INDEX uidx_address_secrets_address -ON address_secrets (address_id); diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql index 253849cfb7..db2db83fc3 100644 --- a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql +++ b/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql @@ -38,9 +38,9 @@ CREATE UNIQUE INDEX uidx_wallets_name ON wallets (wallet_name); -- Watch-only wallets may have no corresponding row in this table or have all -- private key fields with no data. CREATE TABLE wallet_secrets ( - -- Reference to the wallet these secrets belong to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - wallet_id INTEGER NOT NULL, + -- Reference to the wallet these secrets belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + wallet_id INTEGER PRIMARY KEY, -- Params to derive the private master key. NULL for watch-only wallets. master_priv_params BLOB, @@ -61,17 +61,13 @@ CREATE TABLE wallet_secrets ( FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT ); --- Enforces one-to-one relationship: each wallet has at most one secrets record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_wallet_secrets_wallet ON wallet_secrets (wallet_id); - -- Wallet Sync States table to store the synchronization state of each wallet. -- This is kept separate from the wallets table to avoid write amplification on -- frequently updated sync data. Each wallet has exactly one sync state record. CREATE TABLE wallet_sync_states ( - -- Reference to the wallet this sync state belongs to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - wallet_id INTEGER NOT NULL, + -- Reference to the wallet this sync state belongs to. Also serves as the + -- primary key, enforcing one-to-one relationship. + wallet_id INTEGER PRIMARY KEY, -- Current sync status of the wallet (references blocks table). NULL for wallets -- that haven't synced any blocks yet. @@ -102,8 +98,3 @@ CREATE TABLE wallet_sync_states ( FOREIGN KEY (birthday_height) REFERENCES blocks (block_height) ON DELETE RESTRICT ); - --- Enforces one-to-one relationship: each wallet has exactly one sync state record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_wallet_sync_states_wallet -ON wallet_sync_states (wallet_id); diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql index 702758d4ab..ade3e3ef78 100644 --- a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql +++ b/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql @@ -46,9 +46,9 @@ ON key_scopes (wallet_id, purpose, coin_type); -- Watch-only scopes may have no corresponding row in this table or have NULL -- encrypted_coin_priv_key. CREATE TABLE key_scope_secrets ( - -- Reference to the key scope these keys belong to. Acts as the primary key - -- via the unique index below, enforcing one-to-one relationship. - scope_id INTEGER NOT NULL, + -- Reference to the key scope these keys belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + scope_id INTEGER PRIMARY KEY, -- Encrypted key used to derive private keys for this scope. -- NULL for watch-only key scopes. @@ -58,8 +58,3 @@ CREATE TABLE key_scope_secrets ( -- that the key scope cannot be deleted if secrets still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT ); - --- Enforces one-to-one relationship: each key scope has at most one secrets record. --- Also serves as the effective primary key for this table. -CREATE UNIQUE INDEX uidx_key_scope_secrets_scope -ON key_scope_secrets (scope_id); diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql index 03ccdce2d3..34c15e320a 100644 --- a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -102,8 +102,9 @@ ON accounts (scope_id, account_name); -- Account Secrets table to hold encrypted account-level secrets. CREATE TABLE account_secrets ( - -- Reference to the account these keys belong to. - account_id INTEGER NOT NULL, + -- Reference to the account these keys belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + account_id INTEGER PRIMARY KEY, -- Encrypted private key for the account. Watch-only accounts may have -- no row in this table. @@ -113,7 +114,3 @@ CREATE TABLE account_secrets ( -- that the account cannot be deleted if secrets still exist. FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT ); - --- Unique index to ensure one-to-one relationship between account and its secrets. -CREATE UNIQUE INDEX uidx_account_secrets_account -ON account_secrets (account_id); diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index 23021a80c8..e7ccfd6f52 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -78,8 +78,9 @@ CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); -- from an address. This table has a one-to-one relationship with addresses. -- Watch-only addresses may have no row in this table. CREATE TABLE address_secrets ( - -- Reference to the address these secrets belong to. - address_id INTEGER NOT NULL, + -- Reference to the address these secrets belong to. Also serves as the + -- primary key, enforcing one-to-one relationship. + address_id INTEGER PRIMARY KEY, -- Encrypted private key for imported addresses. NULL for HD-derived -- addresses since their private keys are derived from the account key. @@ -93,8 +94,3 @@ CREATE TABLE address_secrets ( -- that the address cannot be deleted if secrets still exist. FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT ); - --- Unique index to ensure one-to-one relationship between address and its --- secrets. -CREATE UNIQUE INDEX uidx_address_secrets_address -ON address_secrets (address_id); From d98f504e98c2438756e141b9bcceeef84aba2d7a Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Feb 2026 18:13:11 -0300 Subject: [PATCH 391/691] wallet: remove dead code --- wallet/internal/db/safecasting.go | 11 ------ wallet/internal/db/safecasting_test.go | 51 -------------------------- 2 files changed, 62 deletions(-) diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 8f40e43c69..499c29fdad 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -39,17 +39,6 @@ func int64ToInt32(v int64) (int32, error) { return int32(v), nil } -// int64ToInt16 safely casts an int64 to an int16, returning an error -// if the value is out of range. -func int64ToInt16(v int64) (int16, error) { - if v < math.MinInt16 || v > math.MaxInt16 { - return 0, fmt.Errorf("could not cast %d to int16: %w", v, - ErrCastingOverflow) - } - - return int16(v), nil -} - // int64ToUint8 safely casts an int64 to an uint8, returning an error // if the value is out of range. func int64ToUint8(v int64) (uint8, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index 83e568af3a..ec4b4d748a 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -200,57 +200,6 @@ func TestInt16ToUint8(t *testing.T) { } } -// TestInt64ToInt16 checks that an int64 value is converted to int16 only -// when it fits within the signed 16 bit range. It should fail loudly for -// any value outside those limits. -func TestInt64ToInt16(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - val int64 - want int16 - wantErr bool - }{ - { - name: "min int16", - val: int64(math.MinInt16), - want: math.MinInt16, - }, - { - name: "max int16", - val: int64(math.MaxInt16), - want: math.MaxInt16, - }, - {name: "zero", val: 0, want: 0}, - { - name: "below min", - val: int64(math.MinInt16) - 1, - wantErr: true, - }, - { - name: "above max", - val: int64(math.MaxInt16) + 1, - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got, err := int64ToInt16(tc.val) - if tc.wantErr { - require.ErrorIs(t, err, ErrCastingOverflow) - return - } - - require.NoError(t, err) - require.Equal(t, tc.want, got) - }) - } -} - // TestUint32ToInt32 checks that an uint32 value is safely converted to int32 // only when it fits within the signed 32 bit range. It should fail loudly // for any value that exceeds those limits. From 506233869e33c037988be6b8aa6cead419b5ab8e Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 12:04:19 -0300 Subject: [PATCH 392/691] wallet: generalize AddressSecretRow conversion --- wallet/internal/db/addresses_common.go | 36 ++++++++++++++++++++++++++ wallet/internal/db/addresses_pg.go | 29 +++++---------------- wallet/internal/db/addresses_sqlite.go | 25 ++++-------------- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index d767faf4a0..2c9bbf8870 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -158,6 +158,42 @@ type addressInfoRow[TypeID, OriginIDType any] struct { IDToOrigin func(OriginIDType) (AccountOrigin, error) } +// addressSecretRow captures fields shared by address secret row types across +// backends. +type addressSecretRow struct { + // AddressID is the database unique identifier for the address. + AddressID int64 + + // EncryptedPrivKey is the encrypted private key for imported addresses. + EncryptedPrivKey []byte + + // EncryptedScript is the encrypted script for script-based addresses. + EncryptedScript []byte +} + +// addressSecretRowToSecret converts raw secret row fields into an AddressSecret +// with validation and ID conversion. +func addressSecretRowToSecret(row addressSecretRow) (*AddressSecret, error) { + hasKey := len(row.EncryptedPrivKey) > 0 + hasScript := len(row.EncryptedScript) > 0 + + if !hasKey && !hasScript { + return nil, fmt.Errorf("address %d: %w", row.AddressID, + ErrSecretNotFound) + } + + addrID, err := int64ToUint32(row.AddressID) + if err != nil { + return nil, fmt.Errorf("address ID: %w", err) + } + + return &AddressSecret{ + AddressID: addrID, + EncryptedPrivKey: row.EncryptedPrivKey, + EncryptedScript: row.EncryptedScript, + }, nil +} + // convertAddressIDs converts database IDs to their respective uint32 values // with error handling. func convertAddressIDs(id, accountID int64) (uint32, uint32, error) { diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 76c3d42334..cb0f14b722 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -3,7 +3,6 @@ package db import ( "context" "database/sql" - "fmt" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -240,30 +239,16 @@ func importedAddressRowCreatedAtPg( return row.CreatedAt } -// pgAddressSecretRowToSecret converts a sqlc GetAddressSecretRow row to the -// db.AddressSecret type used by the wallet, handling null value conversions. -// Returns ErrSecretNotFound if the address exists but has no secret. -func pgAddressSecretRowToSecret(row sqlcpg.GetAddressSecretRow) (*AddressSecret, - error) { - - hasKey := len(row.EncryptedPrivKey) > 0 - hasScript := len(row.EncryptedScript) > 0 - - if !hasKey && !hasScript { - return nil, fmt.Errorf("address %d: %w", row.AddressID, - ErrSecretNotFound) - } +// pgAddressSecretRowToSecret converts a PostgreSQL address secret row to an +// AddressSecret struct. +func pgAddressSecretRowToSecret( + row sqlcpg.GetAddressSecretRow) (*AddressSecret, error) { - addrID, err := int64ToUint32(row.AddressID) - if err != nil { - return nil, fmt.Errorf("address ID: %w", err) - } - - return &AddressSecret{ - AddressID: addrID, + return addressSecretRowToSecret(addressSecretRow{ + AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, - }, nil + }) } // pgAddressInfoRow is a type constraint that unifies all PostgreSQL address diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index f89ea9143c..7a36d12434 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -3,7 +3,6 @@ package db import ( "context" "database/sql" - "fmt" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -243,30 +242,16 @@ func insertAddressSecretParamsSQLite(addressID int64, } } -// sqliteAddressSecretRowToSecret converts a sqlc GetAddressSecretRow row to the -// db.AddressSecret type used by the wallet, handling null value conversions. -// Returns ErrSecretNotFound if the secret is missing. +// sqliteAddressSecretRowToSecret converts a SQLite address secret row to an +// AddressSecret struct. func sqliteAddressSecretRowToSecret( row sqlcsqlite.GetAddressSecretRow) (*AddressSecret, error) { - hasKey := len(row.EncryptedPrivKey) > 0 - hasScript := len(row.EncryptedScript) > 0 - - if !hasKey && !hasScript { - return nil, fmt.Errorf("address %d: %w", row.AddressID, - ErrSecretNotFound) - } - - addrID, err := int64ToUint32(row.AddressID) - if err != nil { - return nil, fmt.Errorf("address ID: %w", err) - } - - return &AddressSecret{ - AddressID: addrID, + return addressSecretRowToSecret(addressSecretRow{ + AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, - }, nil + }) } // sqliteAddressInfoRow is a type constraint union that represents all SQLite From ba7150544efc86bb24538a2eaf3e78cbee607cbb Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 12:13:16 -0300 Subject: [PATCH 393/691] wallet: require ErrMaxAddressIndexReached on test --- wallet/internal/db/itest/address_store_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 901da9b29a..572705b9b0 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -1078,7 +1078,7 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { Change: false, }, mockDeriveFunc(), ) - require.Error(t, err) + require.ErrorIs(t, err, db.ErrMaxAddressIndexReached) } // TestNewDerivedAddressMaxIndexInternal verifies that internal addresses can be From 3e703213b7674a2cd2e5cb5f9a14d4947cbbab88 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 12:53:30 -0300 Subject: [PATCH 394/691] wallet: use raw sql for test specific queries --- .../internal/db/itest/address_store_test.go | 8 ++--- wallet/internal/db/itest/fixtures_pg_test.go | 22 ++++++------ .../internal/db/itest/fixtures_sqlite_test.go | 22 ++++++------ wallet/internal/db/itest/pg_test.go | 17 +++++++-- wallet/internal/db/itest/sqlite_test.go | 17 +++++++-- .../internal/db/queries/postgres/accounts.sql | 14 -------- .../internal/db/queries/sqlite/accounts.sql | 14 -------- .../internal/db/sqlc/postgres/accounts.sql.go | 36 ------------------- wallet/internal/db/sqlc/postgres/db.go | 20 ----------- wallet/internal/db/sqlc/postgres/querier.go | 6 ---- .../internal/db/sqlc/sqlite/accounts.sql.go | 36 ------------------- wallet/internal/db/sqlc/sqlite/db.go | 20 ----------- wallet/internal/db/sqlc/sqlite/querier.go | 6 ---- 13 files changed, 52 insertions(+), 186 deletions(-) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 572705b9b0..4e77f9d630 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -1049,7 +1049,7 @@ func TestNewDerivedAddressBranchCounters(t *testing.T) { func TestNewDerivedAddressMaxIndex(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store, queries, dbConn := NewTestStoreWithDB(t) walletID := newWallet(t, store, "wallet-max-index") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") @@ -1060,7 +1060,7 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { CreateAddressWithIndex(t, queries, accountID, 0, math.MaxUint32-1) // Set the counter to MaxUint32 so the next allocation gives us MaxUint32 - UpdateAccountNextExternalIndex(t, queries, accountID, math.MaxUint32) + UpdateAccountNextExternalIndex(t, dbConn, accountID, math.MaxUint32) // This should succeed with address index = MaxUint32. info := newDerivedAddress( @@ -1087,7 +1087,7 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store, queries, dbConn := NewTestStoreWithDB(t) walletID := newWallet(t, store, "wallet-max-index-internal") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") @@ -1099,7 +1099,7 @@ func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { // Set the internal counter to MaxUint32 so the next allocation gives us // MaxUint32. - UpdateAccountNextInternalIndex(t, queries, accountID, math.MaxUint32) + UpdateAccountNextInternalIndex(t, dbConn, accountID, math.MaxUint32) // This should succeed with address index = MaxUint32. info := newDerivedAddress( diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index ed00da6f62..103242c142 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -68,30 +68,28 @@ func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, } // UpdateAccountNextExternalIndex updates the account's external index counter. -func UpdateAccountNextExternalIndex(t *testing.T, queries *sqlcpg.Queries, +func UpdateAccountNextExternalIndex(t *testing.T, dbConn *sql.DB, accountID int64, nextIndex uint32) { t.Helper() - err := queries.UpdateAccountNextExternalIndex( - t.Context(), sqlcpg.UpdateAccountNextExternalIndexParams{ - ID: accountID, - NextExternalIndex: int64(nextIndex), - }, + _, err := dbConn.ExecContext( + t.Context(), + "UPDATE accounts SET next_external_index = $1 WHERE id = $2", + int64(nextIndex), accountID, ) require.NoError(t, err) } // UpdateAccountNextInternalIndex updates the account's internal index counter. -func UpdateAccountNextInternalIndex(t *testing.T, queries *sqlcpg.Queries, +func UpdateAccountNextInternalIndex(t *testing.T, dbConn *sql.DB, accountID int64, nextIndex uint32) { t.Helper() - err := queries.UpdateAccountNextInternalIndex( - t.Context(), sqlcpg.UpdateAccountNextInternalIndexParams{ - ID: accountID, - NextInternalIndex: int64(nextIndex), - }, + _, err := dbConn.ExecContext( + t.Context(), + "UPDATE accounts SET next_internal_index = $1 WHERE id = $2", + int64(nextIndex), accountID, ) require.NoError(t, err) } diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index e0c68134fd..c7cfbbdc73 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -68,30 +68,28 @@ func CreateAddressWithIndex(t *testing.T, queries *sqlcsqlite.Queries, } // UpdateAccountNextExternalIndex updates the account's external index counter. -func UpdateAccountNextExternalIndex(t *testing.T, queries *sqlcsqlite.Queries, +func UpdateAccountNextExternalIndex(t *testing.T, dbConn *sql.DB, accountID int64, nextIndex uint32) { t.Helper() - err := queries.UpdateAccountNextExternalIndex( - t.Context(), sqlcsqlite.UpdateAccountNextExternalIndexParams{ - ID: accountID, - NextExternalIndex: int64(nextIndex), - }, + _, err := dbConn.ExecContext( + t.Context(), + "UPDATE accounts SET next_external_index = ? WHERE id = ?", + int64(nextIndex), accountID, ) require.NoError(t, err) } // UpdateAccountNextInternalIndex updates the account's internal index counter. -func UpdateAccountNextInternalIndex(t *testing.T, queries *sqlcsqlite.Queries, +func UpdateAccountNextInternalIndex(t *testing.T, dbConn *sql.DB, accountID int64, nextIndex uint32) { t.Helper() - err := queries.UpdateAccountNextInternalIndex( - t.Context(), sqlcsqlite.UpdateAccountNextInternalIndexParams{ - ID: accountID, - NextInternalIndex: int64(nextIndex), - }, + _, err := dbConn.ExecContext( + t.Context(), + "UPDATE accounts SET next_internal_index = ? WHERE id = ?", + int64(nextIndex), accountID, ) require.NoError(t, err) } diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index f5f5bd0cff..ac892a4b56 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -188,9 +188,11 @@ func NewPostgresDB(t *testing.T) *sql.DB { return dbConn } -// NewTestStore creates a PostgreSQL wallet store and returns it along with the -// underlying database connection for tests that also need direct DB access. -func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { +// NewTestStoreWithDB creates a PostgreSQL wallet store and also returns the +// raw sql.DB for fixture-level direct SQL setup. +func NewTestStoreWithDB(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries, + *sql.DB) { + t.Helper() dbConn := NewPostgresDB(t) @@ -200,5 +202,14 @@ func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { queries := sqlcpg.New(dbConn) + return store, queries, dbConn +} + +// NewTestStore creates a PostgreSQL wallet store and returns it with queries. +func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { + t.Helper() + + store, queries, _ := NewTestStoreWithDB(t) + return store, queries } diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 666b32c444..975872fd13 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -50,9 +50,11 @@ func NewSQLiteDB(t *testing.T) *sql.DB { return dbConn } -// NewTestStore creates the SQLite wallet store and returns it along with the -// underlying database connection for tests that also need direct DB access. -func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { +// NewTestStoreWithDB creates a SQLite wallet store and also returns the raw +// sql.DB for fixture-level direct SQL setup. +func NewTestStoreWithDB(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries, + *sql.DB) { + t.Helper() dbConn := NewSQLiteDB(t) @@ -62,5 +64,14 @@ func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { queries := sqlcsqlite.New(dbConn) + return store, queries, dbConn +} + +// NewTestStore creates the SQLite wallet store and returns it with queries. +func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { + t.Helper() + + store, queries, _ := NewTestStoreWithDB(t) + return store, queries } diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index afa4e885e9..2d4ca4169b 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -335,17 +335,3 @@ UPDATE accounts SET next_internal_index = next_internal_index + 1 WHERE id = $1 RETURNING (next_internal_index - 1)::BIGINT AS address_index; - --- name: UpdateAccountNextExternalIndex :exec --- Updates the next_external_index counter for an account. Used in tests --- to set up specific index scenarios. -UPDATE accounts -SET next_external_index = $2 -WHERE id = $1; - --- name: UpdateAccountNextInternalIndex :exec --- Updates the next_internal_index counter for an account. Used in tests --- to set up specific index scenarios. -UPDATE accounts -SET next_internal_index = $2 -WHERE id = $1; diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index cea8dc577a..a8110a1fad 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -306,17 +306,3 @@ UPDATE accounts SET next_internal_index = next_internal_index + 1 WHERE id = ? RETURNING next_internal_index - 1 AS address_index; - --- name: UpdateAccountNextExternalIndex :exec --- Updates the next_external_index counter for an account. Used in tests --- to set up specific index scenarios. -UPDATE accounts -SET next_external_index = ?2 -WHERE id = ?1; - --- name: UpdateAccountNextInternalIndex :exec --- Updates the next_internal_index counter for an account. Used in tests --- to set up specific index scenarios. -UPDATE accounts -SET next_internal_index = ?2 -WHERE id = ?1; diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index b3bd193e1b..ed6141a79f 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -934,39 +934,3 @@ func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, a } return result.RowsAffected() } - -const UpdateAccountNextExternalIndex = `-- name: UpdateAccountNextExternalIndex :exec -UPDATE accounts -SET next_external_index = $2 -WHERE id = $1 -` - -type UpdateAccountNextExternalIndexParams struct { - ID int64 - NextExternalIndex int64 -} - -// Updates the next_external_index counter for an account. Used in tests -// to set up specific index scenarios. -func (q *Queries) UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error { - _, err := q.exec(ctx, q.updateAccountNextExternalIndexStmt, UpdateAccountNextExternalIndex, arg.ID, arg.NextExternalIndex) - return err -} - -const UpdateAccountNextInternalIndex = `-- name: UpdateAccountNextInternalIndex :exec -UPDATE accounts -SET next_internal_index = $2 -WHERE id = $1 -` - -type UpdateAccountNextInternalIndexParams struct { - ID int64 - NextInternalIndex int64 -} - -// Updates the next_internal_index counter for an account. Used in tests -// to set up specific index scenarios. -func (q *Queries) UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error { - _, err := q.exec(ctx, q.updateAccountNextInternalIndexStmt, UpdateAccountNextInternalIndex, arg.ID, arg.NextInternalIndex) - return err -} diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 07cbfc7f13..17936fad3f 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -156,12 +156,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } - if q.updateAccountNextExternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextExternalIndex); err != nil { - return nil, fmt.Errorf("error preparing query UpdateAccountNextExternalIndex: %w", err) - } - if q.updateAccountNextInternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextInternalIndex); err != nil { - return nil, fmt.Errorf("error preparing query UpdateAccountNextInternalIndex: %w", err) - } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -393,16 +387,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } - if q.updateAccountNextExternalIndexStmt != nil { - if cerr := q.updateAccountNextExternalIndexStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing updateAccountNextExternalIndexStmt: %w", cerr) - } - } - if q.updateAccountNextInternalIndexStmt != nil { - if cerr := q.updateAccountNextInternalIndexStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing updateAccountNextInternalIndexStmt: %w", cerr) - } - } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -496,8 +480,6 @@ type Queries struct { lockAccountScopeStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt - updateAccountNextExternalIndexStmt *sql.Stmt - updateAccountNextInternalIndexStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -550,8 +532,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { lockAccountScopeStmt: q.lockAccountScopeStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, - updateAccountNextExternalIndexStmt: q.updateAccountNextExternalIndexStmt, - updateAccountNextInternalIndexStmt: q.updateAccountNextInternalIndexStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 54f20c5b51..b0bf31677f 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -129,12 +129,6 @@ type Querier interface { UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) - // Updates the next_external_index counter for an account. Used in tests - // to set up specific index scenarios. - UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error - // Updates the next_internal_index counter for an account. Used in tests - // to set up specific index scenarios. - UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 51c363249e..9131c8e9ed 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -900,39 +900,3 @@ func (q *Queries) UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, a } return result.RowsAffected() } - -const UpdateAccountNextExternalIndex = `-- name: UpdateAccountNextExternalIndex :exec -UPDATE accounts -SET next_external_index = ?2 -WHERE id = ?1 -` - -type UpdateAccountNextExternalIndexParams struct { - ID int64 - NextExternalIndex int64 -} - -// Updates the next_external_index counter for an account. Used in tests -// to set up specific index scenarios. -func (q *Queries) UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error { - _, err := q.exec(ctx, q.updateAccountNextExternalIndexStmt, UpdateAccountNextExternalIndex, arg.ID, arg.NextExternalIndex) - return err -} - -const UpdateAccountNextInternalIndex = `-- name: UpdateAccountNextInternalIndex :exec -UPDATE accounts -SET next_internal_index = ?2 -WHERE id = ?1 -` - -type UpdateAccountNextInternalIndexParams struct { - ID int64 - NextInternalIndex int64 -} - -// Updates the next_internal_index counter for an account. Used in tests -// to set up specific index scenarios. -func (q *Queries) UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error { - _, err := q.exec(ctx, q.updateAccountNextInternalIndexStmt, UpdateAccountNextInternalIndex, arg.ID, arg.NextInternalIndex) - return err -} diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 25ad7c5f16..b53304ffad 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -153,12 +153,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } - if q.updateAccountNextExternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextExternalIndex); err != nil { - return nil, fmt.Errorf("error preparing query UpdateAccountNextExternalIndex: %w", err) - } - if q.updateAccountNextInternalIndexStmt, err = db.PrepareContext(ctx, UpdateAccountNextInternalIndex); err != nil { - return nil, fmt.Errorf("error preparing query UpdateAccountNextInternalIndex: %w", err) - } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -385,16 +379,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } - if q.updateAccountNextExternalIndexStmt != nil { - if cerr := q.updateAccountNextExternalIndexStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing updateAccountNextExternalIndexStmt: %w", cerr) - } - } - if q.updateAccountNextInternalIndexStmt != nil { - if cerr := q.updateAccountNextInternalIndexStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing updateAccountNextInternalIndexStmt: %w", cerr) - } - } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -487,8 +471,6 @@ type Queries struct { listWalletsStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt - updateAccountNextExternalIndexStmt *sql.Stmt - updateAccountNextInternalIndexStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -540,8 +522,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listWalletsStmt: q.listWalletsStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, - updateAccountNextExternalIndexStmt: q.updateAccountNextExternalIndexStmt, - updateAccountNextInternalIndexStmt: q.updateAccountNextInternalIndexStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 3eba8e332e..4968d0ed5b 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -104,12 +104,6 @@ type Querier interface { UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) - // Updates the next_external_index counter for an account. Used in tests - // to set up specific index scenarios. - UpdateAccountNextExternalIndex(ctx context.Context, arg UpdateAccountNextExternalIndexParams) error - // Updates the next_internal_index counter for an account. Used in tests - // to set up specific index scenarios. - UpdateAccountNextInternalIndex(ctx context.Context, arg UpdateAccountNextInternalIndexParams) error UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } From cd54ce88eb68207cb11a0c90c30ba37dabf99a83 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 17:42:11 -0300 Subject: [PATCH 395/691] wallet: add count column for imported addresses --- .../internal/db/itest/address_store_test.go | 129 ++++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 33 +++++ .../internal/db/itest/fixtures_sqlite_test.go | 33 +++++ .../postgres/000005_accounts.up.sql | 6 + .../postgres/000006_addresses.up.sql | 36 +++++ .../migrations/sqlite/000005_accounts.up.sql | 6 + .../migrations/sqlite/000006_addresses.up.sql | 20 +++ .../internal/db/queries/postgres/accounts.sql | 46 ++----- .../internal/db/queries/sqlite/accounts.sql | 46 ++----- .../internal/db/sqlc/postgres/accounts.sql.go | 36 ++--- wallet/internal/db/sqlc/postgres/models.go | 1 + .../internal/db/sqlc/sqlite/accounts.sql.go | 36 ++--- wallet/internal/db/sqlc/sqlite/models.go | 1 + 13 files changed, 311 insertions(+), 118 deletions(-) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 4e77f9d630..c95a4dab09 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -334,6 +334,135 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { } } +// TestImportedAddressCounterInsertDelete verifies that imported address inserts +// increment the per-account counter and deletes decrement it. +func TestImportedAddressCounterInsertDelete(t *testing.T) { + t.Parallel() + + store, _, dbConn := NewTestStoreWithDB(t) + walletID := newWallet(t, store, "wallet-imported-counter") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + + const importedAddrCount = 5 + addressIDs := make([]uint32, 0, importedAddrCount) + + account := getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Zero(t, account.ImportedKeyCount) + + for i := 0; i < importedAddrCount; i++ { + info, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + ScriptPubKey: RandomBytes(32), + PubKey: RandomBytes(33), + }, + ) + require.NoError(t, err) + + addressIDs = append(addressIDs, info.ID) + } + + account = getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Equal(t, uint32(importedAddrCount), account.ImportedKeyCount) + + for _, addressID := range addressIDs { + MustDeleteAddress(t, dbConn, addressID) + } + + account = getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Zero(t, account.ImportedKeyCount) +} + +// TestImportedAddressCounterConcurrentInsert verifies that concurrent imported +// address inserts correctly update the per-account imported key counter. +func TestImportedAddressCounterConcurrentInsert(t *testing.T) { + t.Parallel() + + store, _, dbConn := NewTestStoreWithDB(t) + walletID := newWallet(t, store, "wallet-imported-counter-concurrent") + createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + + const workers = 20 + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + type insertResult struct { + id uint32 + err error + } + + insertResultChan := make(chan insertResult, workers) + var wg sync.WaitGroup + + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + + info, err := store.NewImportedAddress( + ctx, db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + ScriptPubKey: RandomBytes(32), + PubKey: RandomBytes(33), + }, + ) + if err != nil { + insertResultChan <- insertResult{err: err} + return + } + + insertResultChan <- insertResult{id: info.ID} + }() + } + + wg.Wait() + close(insertResultChan) + + addressIDs := make([]uint32, 0, workers) + for result := range insertResultChan { + require.NoError(t, result.err) + addressIDs = append(addressIDs, result.id) + } + + require.Len(t, addressIDs, workers) + + account := getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Equal(t, uint32(workers), account.ImportedKeyCount) + + deleteErrChan := make(chan error, workers) + for _, addressID := range addressIDs { + wg.Add(1) + go func() { + defer wg.Done() + deleteErrChan <- deleteAddress(ctx, dbConn, addressID) + }() + } + + wg.Wait() + close(deleteErrChan) + + for err := range deleteErrChan { + require.NoError(t, err) + } + + account = getAccountByName( + t, store, walletID, db.KeyScopeBIP0084, "imported", + ) + require.Zero(t, account.ImportedKeyCount) +} + // TestNewImportedAddressDuplicate verifies that importing an address with // a duplicate ScriptPubKey fails with a constraint error. func TestNewImportedAddressDuplicate(t *testing.T) { diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 103242c142..210b406cf5 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -3,7 +3,9 @@ package itest import ( + "context" "database/sql" + "fmt" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -149,3 +151,34 @@ func getAddressSecret(t *testing.T, queries *sqlcpg.Queries, return queries.GetAddressSecret(t.Context(), addressID) } + +// MustDeleteAddress deletes an address by ID for test scenarios. +func MustDeleteAddress(t *testing.T, dbConn *sql.DB, addressID uint32) { + t.Helper() + + err := deleteAddress(t.Context(), dbConn, addressID) + require.NoError(t, err) +} + +// deleteAddress removes a single address row by ID and validates row count. +func deleteAddress(ctx context.Context, dbConn *sql.DB, + addressID uint32) error { + + result, err := dbConn.ExecContext( + ctx, "DELETE FROM addresses WHERE id = $1", int64(addressID), + ) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows != 1 { + return fmt.Errorf("expected 1 deleted row, got %d", rows) + } + + return nil +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index c7cfbbdc73..034c30abe3 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -3,7 +3,9 @@ package itest import ( + "context" "database/sql" + "fmt" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -149,3 +151,34 @@ func getAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, return queries.GetAddressSecret(t.Context(), addressID) } + +// MustDeleteAddress deletes an address by ID for test scenarios. +func MustDeleteAddress(t *testing.T, dbConn *sql.DB, addressID uint32) { + t.Helper() + + err := deleteAddress(t.Context(), dbConn, addressID) + require.NoError(t, err) +} + +// deleteAddress removes a single address row by ID and validates row count. +func deleteAddress(ctx context.Context, dbConn *sql.DB, + addressID uint32) error { + + result, err := dbConn.ExecContext( + ctx, "DELETE FROM addresses WHERE id = ?", int64(addressID), + ) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows != 1 { + return fmt.Errorf("expected 1 deleted row, got %d", rows) + } + + return nil +} diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index c10eda819b..5108396780 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -70,12 +70,18 @@ CREATE TABLE accounts ( -- Next index to use for internal/change addresses (branch 1) next_internal_index BIGINT NOT NULL DEFAULT 0, + -- Number of imported addresses in this account. + imported_key_count BIGINT NOT NULL DEFAULT 0, + -- External derivation index must be non-negative. CHECK (next_external_index >= 0), -- Internal derivation index must be non-negative. CHECK (next_internal_index >= 0), + -- Imported address counter must be non-negative. + CHECK (imported_key_count >= 0), + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index f942265e2e..0fa11dbaf1 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -93,3 +93,39 @@ CREATE TABLE address_secrets ( -- that the address cannot be deleted if secrets still exist. FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT ); + +-- Increments imported_key_count for imported address inserts. +CREATE FUNCTION sync_account_imported_key_count_insert() RETURNS TRIGGER AS $$ +BEGIN + UPDATE accounts + SET imported_key_count = imported_key_count + 1 + WHERE id = NEW.account_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to keep imported_key_count accurate for imported address inserts. +CREATE TRIGGER trg_addresses_imported_key_count_insert +AFTER INSERT ON addresses +FOR EACH ROW +WHEN (new.address_branch IS NULL) +EXECUTE FUNCTION sync_account_imported_key_count_insert(); + +-- Decrements imported_key_count for imported address deletes. +CREATE FUNCTION sync_account_imported_key_count_delete() RETURNS TRIGGER AS $$ +BEGIN + UPDATE accounts + SET imported_key_count = imported_key_count - 1 + WHERE id = OLD.account_id; + + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to keep imported_key_count accurate for imported address deletes. +CREATE TRIGGER trg_addresses_imported_key_count_delete +AFTER DELETE ON addresses +FOR EACH ROW +WHEN (old.address_branch IS NULL) +EXECUTE FUNCTION sync_account_imported_key_count_delete(); diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql index 34c15e320a..e99fc90910 100644 --- a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql @@ -70,12 +70,18 @@ CREATE TABLE accounts ( -- Next index to use for internal/change addresses (branch 1) next_internal_index INTEGER NOT NULL DEFAULT 0, + -- Number of imported addresses in this account. + imported_key_count INTEGER NOT NULL DEFAULT 0, + -- External derivation index must be non-negative. CHECK (next_external_index >= 0), -- Internal derivation index must be non-negative. CHECK (next_internal_index >= 0), + -- Imported address counter must be non-negative. + CHECK (imported_key_count >= 0), + -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure -- that the key scope cannot be deleted if accounts still exist. FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index e7ccfd6f52..39e5f96d69 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -94,3 +94,23 @@ CREATE TABLE address_secrets ( -- that the address cannot be deleted if secrets still exist. FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE RESTRICT ); + +-- Increments imported_key_count when a new imported address is inserted. +CREATE TRIGGER trg_addresses_imported_key_count_insert +AFTER INSERT ON addresses +WHEN new.address_branch IS NULL +BEGIN + UPDATE accounts + SET imported_key_count = imported_key_count + 1 + WHERE id = new.account_id; +END; + +-- Decrements imported_key_count when an imported address is deleted. +CREATE TRIGGER trg_addresses_imported_key_count_delete +AFTER DELETE ON addresses +WHEN old.address_branch IS NULL +BEGIN + UPDATE accounts + SET imported_key_count = imported_key_count - 1 + WHERE id = old.account_id; +END; diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/db/queries/postgres/accounts.sql index 2d4ca4169b..0a92f1f307 100644 --- a/wallet/internal/db/queries/postgres/accounts.sql +++ b/wallet/internal/db/queries/postgres/accounts.sql @@ -88,12 +88,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.scope_id = $1 AND a.account_name = $2 -GROUP BY a.id, ks.id; +WHERE a.scope_id = $1 AND a.account_name = $2; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. @@ -108,12 +106,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.scope_id = $1 AND a.account_number = $2 -GROUP BY a.id, ks.id; +WHERE a.scope_id = $1 AND a.account_number = $2; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. @@ -128,16 +124,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND a.account_name = $4 -GROUP BY a.id, ks.id; + AND a.account_name = $4; -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. @@ -152,16 +146,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND a.account_number = $4 -GROUP BY a.id, ks.id; + AND a.account_number = $4; -- name: GetAccountPropsById :one -- Returns full account properties by account id. @@ -179,12 +171,10 @@ SELECT ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.id = $1 -GROUP BY a.id, ks.id; +WHERE a.id = $1; -- name: ListAccountsByScope :many -- Lists all accounts in a scope, ordered by account number. Imported accounts @@ -200,12 +190,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWalletScope :many @@ -222,15 +210,13 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWalletAndName :many @@ -247,12 +233,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND a.account_name = $2 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: ListAccountsByWallet :many @@ -269,12 +253,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST; -- name: UpdateAccountNameByWalletScopeAndNumber :execrows diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/db/queries/sqlite/accounts.sql index a8110a1fad..ed50418fd2 100644 --- a/wallet/internal/db/queries/sqlite/accounts.sql +++ b/wallet/internal/db/queries/sqlite/accounts.sql @@ -59,12 +59,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.scope_id = ? AND a.account_name = ? -GROUP BY a.id, ks.id; +WHERE a.scope_id = ? AND a.account_name = ?; -- name: GetAccountByScopeAndNumber :one -- Returns a single account by scope id and account number. @@ -79,12 +77,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.scope_id = ? AND a.account_number = ? -GROUP BY a.id, ks.id; +WHERE a.scope_id = ? AND a.account_number = ?; -- name: GetAccountByWalletScopeAndName :one -- Returns a single account by wallet id, scope tuple, and account name. @@ -99,16 +95,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND a.account_name = ? -GROUP BY a.id, ks.id; + AND a.account_name = ?; -- name: GetAccountByWalletScopeAndNumber :one -- Returns a single account by wallet id, scope tuple, and account number. @@ -123,16 +117,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND a.account_number = ? -GROUP BY a.id, ks.id; + AND a.account_number = ?; -- name: GetAccountPropsById :one -- Returns full account properties by account id. @@ -150,12 +142,10 @@ SELECT ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id -WHERE a.id = ? -GROUP BY a.id, ks.id; +WHERE a.id = ?; -- name: ListAccountsByScope :many -- Lists all accounts in a scope, ordered by account number. Imported accounts @@ -171,12 +161,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWalletScope :many @@ -193,15 +181,13 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWalletAndName :many @@ -218,12 +204,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND a.account_name = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: ListAccountsByWallet :many @@ -240,12 +224,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number; -- name: UpdateAccountNameByWalletScopeAndNumber :execrows diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/db/sqlc/postgres/accounts.sql.go index ed6141a79f..894cd597d1 100644 --- a/wallet/internal/db/sqlc/postgres/accounts.sql.go +++ b/wallet/internal/db/sqlc/postgres/accounts.sql.go @@ -184,12 +184,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 AND a.account_name = $2 -GROUP BY a.id, ks.id ` type GetAccountByScopeAndNameParams struct { @@ -243,12 +241,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 AND a.account_number = $2 -GROUP BY a.id, ks.id ` type GetAccountByScopeAndNumberParams struct { @@ -302,16 +298,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND a.account_name = $4 -GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNameParams struct { @@ -372,16 +366,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND a.account_number = $4 -GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNumberParams struct { @@ -445,12 +437,10 @@ SELECT ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.id = $1 -GROUP BY a.id, ks.id ` type GetAccountPropsByIdRow struct { @@ -537,12 +527,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = $1 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -609,12 +597,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -681,12 +667,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND a.account_name = $2 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` @@ -758,15 +742,13 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(*) FILTER (WHERE addr.address_branch IS NULL AND addr.id IS NOT NULL) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 -GROUP BY a.id, ks.id ORDER BY a.account_number NULLS LAST ` diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 3c11ebf44b..bc177a29bf 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -21,6 +21,7 @@ type Account struct { CreatedAt time.Time NextExternalIndex int64 NextInternalIndex int64 + ImportedKeyCount int64 } type AccountOrigin struct { diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/db/sqlc/sqlite/accounts.sql.go index 9131c8e9ed..f50130e742 100644 --- a/wallet/internal/db/sqlc/sqlite/accounts.sql.go +++ b/wallet/internal/db/sqlc/sqlite/accounts.sql.go @@ -182,12 +182,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? AND a.account_name = ? -GROUP BY a.id, ks.id ` type GetAccountByScopeAndNameParams struct { @@ -241,12 +239,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? AND a.account_number = ? -GROUP BY a.id, ks.id ` type GetAccountByScopeAndNumberParams struct { @@ -300,16 +296,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND a.account_name = ? -GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNameParams struct { @@ -370,16 +364,14 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND a.account_number = ? -GROUP BY a.id, ks.id ` type GetAccountByWalletScopeAndNumberParams struct { @@ -443,12 +435,10 @@ SELECT ks.external_type_id, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.id = ? -GROUP BY a.id, ks.id ` type GetAccountPropsByIdRow struct { @@ -535,12 +525,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE a.scope_id = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -607,12 +595,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -679,12 +665,10 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND a.account_name = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` @@ -756,15 +740,13 @@ SELECT ks.coin_type, a.next_external_index AS external_key_count, a.next_internal_index AS internal_key_count, - count(CASE WHEN addr.address_branch IS NULL AND addr.id IS NOT NULL THEN 1 END) AS imported_key_count + a.imported_key_count FROM accounts AS a INNER JOIN key_scopes AS ks ON a.scope_id = ks.id -LEFT JOIN addresses AS addr ON a.id = addr.account_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? -GROUP BY a.id, ks.id ORDER BY a.account_number IS NULL, a.account_number ` diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 8681080041..6352775b72 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -21,6 +21,7 @@ type Account struct { CreatedAt time.Time NextExternalIndex int64 NextInternalIndex int64 + ImportedKeyCount int64 } type AccountOrigin struct { From f6935740caa6b701d0a3ed2968c20f477b1b36b3 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 17:47:28 -0300 Subject: [PATCH 396/691] wallet: fix stale comment for CreateDerivedAddress --- wallet/internal/db/queries/postgres/addresses.sql | 3 ++- wallet/internal/db/queries/sqlite/addresses.sql | 3 ++- wallet/internal/db/sqlc/postgres/addresses.sql.go | 3 ++- wallet/internal/db/sqlc/postgres/querier.go | 3 ++- wallet/internal/db/sqlc/sqlite/addresses.sql.go | 3 ++- wallet/internal/db/sqlc/sqlite/querier.go | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/db/queries/postgres/addresses.sql index 710a2ed983..ad723709b9 100644 --- a/wallet/internal/db/queries/postgres/addresses.sql +++ b/wallet/internal/db/queries/postgres/addresses.sql @@ -44,7 +44,8 @@ WHERE a.id = $1; -- name: CreateDerivedAddress :one -- Creates a derived address with the given index and derived data. --- The index is allocated separately via GetAndIncrementNextAddressIndex. +-- The index is allocated separately via GetAndIncrementNextExternalIndex +-- or GetAndIncrementNextInternalIndex. INSERT INTO addresses ( account_id, script_pub_key, diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/db/queries/sqlite/addresses.sql index 81799b396a..0428c6d537 100644 --- a/wallet/internal/db/queries/sqlite/addresses.sql +++ b/wallet/internal/db/queries/sqlite/addresses.sql @@ -44,7 +44,8 @@ WHERE a.id = ?; -- name: CreateDerivedAddress :one -- Creates a derived address with the given index and derived data. --- The index is allocated separately via GetAndIncrementNextAddressIndex. +-- The index is allocated separately via GetAndIncrementNextExternalIndex +-- or GetAndIncrementNextInternalIndex. INSERT INTO addresses ( account_id, script_pub_key, diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go index d282bfe308..a6c61ad0a1 100644 --- a/wallet/internal/db/sqlc/postgres/addresses.sql.go +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -38,7 +38,8 @@ type CreateDerivedAddressRow struct { } // Creates a derived address with the given index and derived data. -// The index is allocated separately via GetAndIncrementNextAddressIndex. +// The index is allocated separately via GetAndIncrementNextExternalIndex +// or GetAndIncrementNextInternalIndex. func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, arg.AccountID, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index b0bf31677f..84f628a8c8 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -20,7 +20,8 @@ type Querier interface { // Used for testing account number overflow without creating billions of accounts. CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) // Creates a derived address with the given index and derived data. - // The index is allocated separately via GetAndIncrementNextAddressIndex. + // The index is allocated separately via GetAndIncrementNextExternalIndex + // or GetAndIncrementNextInternalIndex. CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/db/sqlc/sqlite/addresses.sql.go index ebae0bb83f..5ce938af6d 100644 --- a/wallet/internal/db/sqlc/sqlite/addresses.sql.go +++ b/wallet/internal/db/sqlc/sqlite/addresses.sql.go @@ -38,7 +38,8 @@ type CreateDerivedAddressRow struct { } // Creates a derived address with the given index and derived data. -// The index is allocated separately via GetAndIncrementNextAddressIndex. +// The index is allocated separately via GetAndIncrementNextExternalIndex +// or GetAndIncrementNextInternalIndex. func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, arg.AccountID, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 4968d0ed5b..e55e93f16a 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -19,7 +19,8 @@ type Querier interface { // Used for testing account number overflow without creating billions of accounts. CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) // Creates a derived address with the given index and derived data. - // The index is allocated separately via GetAndIncrementNextAddressIndex. + // The index is allocated separately via GetAndIncrementNextExternalIndex + // or GetAndIncrementNextInternalIndex. CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) // Creates a new imported account under the given scope with NULL account // number. Imported accounts don't follow BIP44 derivation, so they don't need From 821580fe6a054c8125eee4812a91e195b88d840b Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 18:01:57 -0300 Subject: [PATCH 397/691] wallet: add order by on list addresses query --- wallet/internal/db/queries/postgres/addresses.sql | 3 ++- wallet/internal/db/queries/sqlite/addresses.sql | 3 ++- wallet/internal/db/sqlc/postgres/addresses.sql.go | 1 + wallet/internal/db/sqlc/sqlite/addresses.sql.go | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/db/queries/postgres/addresses.sql index ad723709b9..c566ac3159 100644 --- a/wallet/internal/db/queries/postgres/addresses.sql +++ b/wallet/internal/db/queries/postgres/addresses.sql @@ -92,4 +92,5 @@ INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND acc.account_name = $4; + AND acc.account_name = $4 +ORDER BY a.id; diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/db/queries/sqlite/addresses.sql index 0428c6d537..d195991d7e 100644 --- a/wallet/internal/db/queries/sqlite/addresses.sql +++ b/wallet/internal/db/queries/sqlite/addresses.sql @@ -92,4 +92,5 @@ INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND acc.account_name = ?; + AND acc.account_name = ? +ORDER BY a.id; diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go index a6c61ad0a1..52c0892aa0 100644 --- a/wallet/internal/db/sqlc/postgres/addresses.sql.go +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -222,6 +222,7 @@ LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 AND acc.account_name = $4 +ORDER BY a.id ` type ListAddressesByAccountParams struct { diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/db/sqlc/sqlite/addresses.sql.go index 5ce938af6d..393fdafc4d 100644 --- a/wallet/internal/db/sqlc/sqlite/addresses.sql.go +++ b/wallet/internal/db/sqlc/sqlite/addresses.sql.go @@ -222,6 +222,7 @@ LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? AND acc.account_name = ? +ORDER BY a.id ` type ListAddressesByAccountParams struct { From e1baf88df6ffcc465be694b69823e11c69fdc9cc Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 18:23:37 -0300 Subject: [PATCH 398/691] wallet: move derivation path validation to convertAddressPath --- wallet/internal/db/addresses_common.go | 31 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index 2c9bbf8870..204712b022 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -270,9 +270,24 @@ func convertAddressMetadata[TypeID, OriginIDType any]( return addrType, origin, nil } -// convertAddressPath converts BIP44 branch and index values with error -// handling. -func convertAddressPath(branch, index sql.NullInt64) (uint32, uint32, error) { +// convertAddressPath converts BIP44 branch/index values into uint32 fields. +// Imported addresses must have both branch/index unset and return zero values. +// Derived addresses must have both fields set and convertible to uint32. +func convertAddressPath(origin AccountOrigin, branch, + index sql.NullInt64) (uint32, uint32, error) { + + if origin == ImportedAccount { + if branch.Valid || index.Valid { + return 0, 0, errInvalidDerivationPath + } + + return 0, 0, nil + } + + if !branch.Valid || !index.Valid { + return 0, 0, errInvalidDerivationPath + } + addrBranch, err := int64ToUint32(branch.Int64) if err != nil { return 0, 0, fmt.Errorf("address branch: %w", err) @@ -301,16 +316,8 @@ func addressRowToInfo[TypeID, OriginIDType any]( return nil, err } - if origin == DerivedAccount { - if !row.AddressIndex.Valid || - !row.AddressBranch.Valid { - - return nil, errInvalidDerivationPath - } - } - addrBranch, addrIndex, err := convertAddressPath( - row.AddressBranch, row.AddressIndex, + origin, row.AddressBranch, row.AddressIndex, ) if err != nil { return nil, err From 50c4bfce9da1929e7fdbd31a739daf41b1b2a7e3 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 10 Feb 2026 18:52:16 -0300 Subject: [PATCH 399/691] wallet: use smallint for branch index --- wallet/internal/db/addresses_pg.go | 27 ++++++++++---- wallet/internal/db/itest/fixtures_pg_test.go | 4 +- .../postgres/000006_addresses.up.sql | 8 ++-- wallet/internal/db/safecasting.go | 11 ++++++ wallet/internal/db/safecasting_test.go | 37 +++++++++++++++++++ .../db/sqlc/postgres/addresses.sql.go | 6 +-- wallet/internal/db/sqlc/postgres/models.go | 2 +- 7 files changed, 78 insertions(+), 17 deletions(-) diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index cb0f14b722..78ca288a0c 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -3,6 +3,7 @@ package db import ( "context" "database/sql" + "fmt" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -151,13 +152,20 @@ func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, branch uint32, index uint32, scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { + branchNum, err := uint32ToInt16(branch) + if err != nil { + return sqlcpg.CreateDerivedAddressRow{}, fmt.Errorf( + "address branch: %w", err, + ) + } + return qtx.CreateDerivedAddress( ctx, sqlcpg.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int16(addrType), - AddressBranch: sql.NullInt64{ - Int64: int64(branch), + AddressBranch: sql.NullInt16{ + Int16: branchNum, Valid: true, }, AddressIndex: sql.NullInt64{ @@ -274,12 +282,15 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { HasPrivateKey: base.HasPrivateKey, HasScript: base.HasScript, CreatedAt: base.CreatedAt, - AddressBranch: base.AddressBranch, - AddressIndex: base.AddressIndex, - ScriptPubKey: base.ScriptPubKey, - PubKey: base.PubKey, - IDToAddrType: idToAddressType[int16], - IDToOrigin: idToOrigin[int16], + AddressBranch: sql.NullInt64{ + Int64: int64(base.AddressBranch.Int16), + Valid: base.AddressBranch.Valid, + }, + AddressIndex: base.AddressIndex, + ScriptPubKey: base.ScriptPubKey, + PubKey: base.PubKey, + IDToAddrType: idToAddressType[int16], + IDToOrigin: idToOrigin[int16], }) if err != nil { return nil, err diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 210b406cf5..83b77b09a4 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -53,7 +53,7 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, // index. Used to test address index overflow without creating billions of // addresses. func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, - accountID int64, branch uint32, index uint32) { + accountID int64, branch int16, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( @@ -61,7 +61,7 @@ func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int16(db.WitnessPubKey), - AddressBranch: sql.NullInt64{Int64: int64(branch), Valid: true}, + AddressBranch: sql.NullInt16{Int16: branch, Valid: true}, AddressIndex: sql.NullInt64{Int64: int64(index), Valid: true}, PubKey: nil, }, diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 0fa11dbaf1..3e97d62d35 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -22,9 +22,11 @@ CREATE TABLE addresses ( -- how the address is encoded and how funds can be spent. type_id SMALLINT NOT NULL, - -- Branch number in BIP44 derivation path (typically 0 for external, 1 for - -- internal/change). NULL for imported addresses. - address_branch BIGINT, + -- Branch number in BIP44 derivation path. We currently use only 0 + -- (external) and 1 (internal/change), so SMALLINT is sufficient. This can + -- be widened to BIGINT later with ALTER COLUMN if branch semantics expand. + -- NULL for imported addresses. + address_branch SMALLINT, -- Index number in BIP44 derivation path (sequential counter within each -- branch). NULL for imported addresses. diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 499c29fdad..5393670073 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -72,6 +72,17 @@ func uint32ToInt32(v uint32) (int32, error) { return int32(v), nil } +// uint32ToInt16 safely casts an uint32 to an int16, returning an error +// if the value is out of range. +func uint32ToInt16(v uint32) (int16, error) { + if v > math.MaxInt16 { + return 0, fmt.Errorf("could not cast %d to int16: %w", v, + ErrCastingOverflow) + } + + return int16(v), nil +} + // uint32ToNullInt32 safely casts an uint32 to a sql.NullInt32, returning // an error if the value is out of range. func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index ec4b4d748a..0dd260851f 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -237,6 +237,43 @@ func TestUint32ToInt32(t *testing.T) { } } +// TestUint32ToInt16 checks that an uint32 value is safely converted to int16 +// only when it fits within the signed 16 bit range. It should fail loudly +// for any value that exceeds those limits. +func TestUint32ToInt16(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + val uint32 + want int16 + wantErr bool + }{ + {name: "zero", val: 0, want: 0}, + {name: "max int16", val: math.MaxInt16, want: math.MaxInt16}, + { + name: "overflow", + val: uint32(math.MaxInt16) + 1, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := uint32ToInt16(tc.val) + if tc.wantErr { + require.ErrorIs(t, err, ErrCastingOverflow) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + // TestUint32ToNullInt32 checks that we respect the signed 32 bit limits // before converting an uint32 value into sql.NullInt32. It should fail // loudly when the value is out of range or when valid is false. diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go index 52c0892aa0..3d5b0c0777 100644 --- a/wallet/internal/db/sqlc/postgres/addresses.sql.go +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -27,7 +27,7 @@ type CreateDerivedAddressParams struct { AccountID int64 ScriptPubKey []byte TypeID int16 - AddressBranch sql.NullInt64 + AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 PubKey []byte } @@ -122,7 +122,7 @@ type GetAddressByScriptPubKeyRow struct { ID int64 AccountID int64 TypeID int16 - AddressBranch sql.NullInt64 + AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 ScriptPubKey []byte PubKey []byte @@ -236,7 +236,7 @@ type ListAddressesByAccountRow struct { ID int64 AccountID int64 TypeID int16 - AddressBranch sql.NullInt64 + AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 ScriptPubKey []byte PubKey []byte diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index bc177a29bf..411c05c8c6 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -39,7 +39,7 @@ type Address struct { AccountID int64 ScriptPubKey []byte TypeID int16 - AddressBranch sql.NullInt64 + AddressBranch sql.NullInt16 AddressIndex sql.NullInt64 PubKey []byte CreatedAt time.Time From 39277aec961323a7f99947140c6d1b600acefb3e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 5 Feb 2026 03:31:24 +0800 Subject: [PATCH 400/691] build: fix make lint in git worktrees This commit updates the Makefile to correctly handle git worktrees when running the linter. By default, the linter's diff processor fails in worktrees because the .git file points to a different location. We now detect this and mount the common git directory into the Docker container so that revgrep can access the git history. --- Makefile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 81582e266a..f1eff8611c 100644 --- a/Makefile +++ b/Makefile @@ -30,10 +30,22 @@ ifneq ($(workers),) LINT_WORKERS = --concurrency=$(workers) endif +# Detect if we're in a git worktree. Use git rev-parse --git-common-dir to get +# the path to the main git directory for the linter's diff processor to work +# correctly with the new-from-rev setting. +GIT_COMMON_DIR := $(shell \ + common_dir="$$(git rev-parse --git-common-dir 2>/dev/null)"; \ + if [ "$$common_dir" != ".git" ] && [ -n "$$common_dir" ]; then \ + echo "$$common_dir"; \ + fi) +GIT_VOLUME := $(if $(GIT_COMMON_DIR),-v "$(GIT_COMMON_DIR):$(GIT_COMMON_DIR):ro",) + DOCKER_TOOLS = docker run \ --rm \ -v $(shell bash -c "mkdir -p /tmp/go-build-cache; echo /tmp/go-build-cache"):/root/.cache/go-build \ - -v $$(pwd):/build btcwallet-tools + -v $$(pwd):/build \ + $(GIT_VOLUME) \ + btcwallet-tools SQLFLUFF = docker run \ --rm \ From 97cd45dc164b8703f731cf5c6edd63810cb427d6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 5 Feb 2026 23:45:31 +0800 Subject: [PATCH 401/691] doc(adr): add adr-0008 integration test framework --- .../adr/0008-integration-test-framework.md | 121 ++++++++++++++++++ docs/developer/adr/README.md | 1 + 2 files changed, 122 insertions(+) create mode 100644 docs/developer/adr/0008-integration-test-framework.md diff --git a/docs/developer/adr/0008-integration-test-framework.md b/docs/developer/adr/0008-integration-test-framework.md new file mode 100644 index 0000000000..515854d2e4 --- /dev/null +++ b/docs/developer/adr/0008-integration-test-framework.md @@ -0,0 +1,121 @@ +# ADR 0008: Integration Test Framework + +## 1. Context + +The `btcwallet` project requires a robust integration testing framework to verify the correctness of its core `wallet` package against various configurations. The current testing landscape is insufficient for verifying the complex matrix of supported backends and modes. + +There is a requirement to support: +1. **Multiple Chain Backends**: `btcd` (native), `bitcoind` (external process), and `neutrino` (SPV). +2. **Multiple Database Backends**: `kvdb` (bbolt/etcd), `sqlite`, and `postgres`. +3. **Public API Testing**: Verifying the public methods of the `wallet` package (e.g., `Create`, `Load`, `Start`, `Stop`). + +We need a standardized, reusable approach to write integration tests that can run across all these permutations without duplicating setup logic. + +## 2. Decision + +We will implement a modular integration test framework modeled after `lnd`'s `lntest`, adapted for library-mode testing. + +### 2.1. Library-Mode Testing +Tests will run `btcwallet` in **Library Mode** (in-process). +- **Why**: Faster execution, easier debugging, and direct internal state assertion. +- **How**: The test harness instantiates `wallet.Manager` directly. + +### 2.2. Architecture & Components + +The framework is split into `bwtest` (framework) and `itest` (test cases). + +#### Component Interaction + +```mermaid +graph TD + subgraph "Test Execution (itest)" + Test["Test Function (t.Run)"] + Harness["HarnessTest (Instance)"] + end + + subgraph "Infrastructure (bwtest)" + Miner["Miner (btcd)"] + ChainNode["Chain Node Process"] + DB["Database Backend"] + end + + subgraph "Application Under Test" + CB["ChainBackend (Interface Wrapper)"] + W["Wallet (In-Memory)"] + end + + Test -->|Creates & Owns| Harness + Harness -->|Starts| Miner + Harness -->|Starts| ChainNode + Harness -->|Initializes| DB + Harness -->|Configures| CB + Harness -->|Creates| W + + ChainNode -->|"P2P Sync"| Miner + CB -->|"RPC / P2P"| ChainNode + W -->|"Uses (chain.Interface)"| CB + W -->|"Stores Data"| DB +``` + +* **`Miner`**: A dedicated `btcd` instance responsible for generating blocks. It acts as the source of truth for the blockchain state. +* **`Chain Node`**: The software providing chain data (`btcd`, `bitcoind`). + * For `btcd` tests: A separate `btcd` process is started as the Chain Node and connects to the Miner. + * For `bitcoind` tests: A separate `bitcoind` process connects (P2P) to the Miner to sync. + * For `neutrino` tests: There is no separate "Chain Node" process; Neutrino connects directly to the Miner. +* **`ChainBackend`**: The interface wrapper (`rpcclient` or `neutrino.ChainService`) used by the Wallet to communicate with the Chain Node. +* **`Database Backend`**: The storage layer (`kvdb`, `postgres`, `sqlite`). +* **`HarnessTest`**: The specific test instance. It manages unique ports, temp directories, and process lifecycles. + +### 2.3. Package Structure + +- **`bwtest/`**: + - `harness.go`: `HarnessTest` orchestrator. Manages `t.Cleanup` for resource teardown. + - `chain_backend.go`: Logic to start/stop `btcd`/`bitcoind` processes and configure `neutrino`. + - `miner.go`: `Miner` implementation (controls the primary `btcd` instance). + - `database.go`: Helpers to setup/teardown DBs (create temp bolt files, init SQL schemas). +- **`itest/`**: + - `main_test.go`: Flag parsing (`-chain`, `-db`) and global test runner. + - `manager_test.go`: Test cases for `wallet.Manager`. + +### 2.4. Configuration & Isolation + +Configuration is handled via `go test` flags. + +```bash +# Default (btcd + kvdb) +make itest + +# Explicit configuration +make itest chain=bitcoind db=postgres + +# Run specific test case +make itest case=TestNewWallet +``` + +* **Sequential Execution**: Tests run sequentially to avoid resource exhaustion and port conflicts, given the heavy overhead of spinning up multiple full nodes per test. +* **Neutrino Support**: The `Miner` (btcd) will be configured with `--cfilters` to serve compact block filters, allowing Neutrino clients to connect directly to it for SPV synchronization. + +## 3. Implementation Plan + +1. **Scaffold Framework (`bwtest`)**: + - Implement `Miner` (wraps `rpctest`). + - Implement `ChainBackend` logic (start bitcoind/btcd process, setup neutrino). + - Implement `Database` setup (init postgres schema, temp sqlite files). + - Implement `HarnessTest` to orchestrate the dependency graph: + `Miner -> ChainNode -> ChainBackend -> DB -> Wallet`. +2. **Scaffold Tests (`itest`)**: + - Create `manager_test.go` as a proof-of-concept. +3. **CI Integration**: + - Update `Makefile` with `itest` targets mapping flags correctly. + +## 4. Consequences + +### Pros +- **Consistency**: Clear separation between Network, Infrastructure, and Application. +- **Isolation**: Per-test harnesses prevent state leaks. +- **Coverage**: capable of validating the entire support matrix. + +### Cons +- **Resource Intensity**: Running a separate `bitcoind`/`btcd` process per test is CPU/RAM intensive. +- **Complexity**: Dynamic port allocation and process lifecycle management are error-prone. +- **Execution Time**: Tests will take longer to run due to sequential execution and process startup costs. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index 90cbbf1959..c2efa58d16 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -7,3 +7,4 @@ ADRs serve as a historical log of important design choices, providing context fo ## Existing ADRs * [ADR 0001: Multi-Wallet Architecture](./0001-multi-wallet-architecture.md) +* [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) From 3d1122659a54ded27f7639d3fa7f400ddb5d4c39 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:48 +0800 Subject: [PATCH 402/691] bwtest: add wait.NoError polling helper Introduce a small polling helper for itests so harness setup and sync checks can wait for eventual consistency without fixed sleeps. --- bwtest/wait/wait.go | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 bwtest/wait/wait.go diff --git a/bwtest/wait/wait.go b/bwtest/wait/wait.go new file mode 100644 index 0000000000..fd41a5ebae --- /dev/null +++ b/bwtest/wait/wait.go @@ -0,0 +1,58 @@ +// Package wait provides polling helpers for integration tests. +package wait + +import ( + "errors" + "time" +) + +var ( + // ErrNoResponse is returned when f does not return within the timeout. + ErrNoResponse = errors.New("method did not return within the timeout") +) + +// PollInterval is the default polling interval used by NoError. +const PollInterval = 200 * time.Millisecond + +// NoError polls f until it returns nil or the timeout is reached. +// +// If the timeout is reached, the last error returned by f is returned. +func NoError(f func() error, timeout time.Duration) error { + // f is expected to be cheap and non-blocking. This helper is intended for + // polling state (e.g. "is the node ready?") rather than performing a long + // operation. + // + // NOTE: NoError does not interrupt f. If f blocks, NoError may block longer + // than the provided timeout. + + deadline := time.NewTimer(timeout) + defer deadline.Stop() + + ticker := time.NewTicker(PollInterval) + defer ticker.Stop() + + // Call f() immediately to avoid the initial ticker delay. + lastErr := f() + if lastErr == nil { + return nil + } + + for { + select { + case <-deadline.C: + if lastErr == nil { + return ErrNoResponse + } + + return lastErr + + case <-ticker.C: + err := f() + if err == nil { + return nil + } + + lastErr = err + } + } +} From 9fcce94f69cd91ac6ecb9fb55ff4341a163d4467 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 403/691] bwtest: add rpctest miner wrapper Wrap rpctest.Harness to provide a shared btcd miner for itests, keeping logs under itest/test-logs with time-based directory names. --- bwtest/miner.go | 202 ++++++++++++++++++++++++++++++++++++++++++++++++ bwtest/utils.go | 39 ++++++++++ 2 files changed, 241 insertions(+) create mode 100644 bwtest/miner.go create mode 100644 bwtest/utils.go diff --git a/bwtest/miner.go b/bwtest/miner.go new file mode 100644 index 0000000000..e8cf7c0ef1 --- /dev/null +++ b/bwtest/miner.go @@ -0,0 +1,202 @@ +package bwtest + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcd/rpcclient" + "github.com/stretchr/testify/require" +) + +const ( + // MinerLogFilename is the default log filename for the miner node. + MinerLogFilename = "output_btcd_miner.log" + + // MinerLogDir is the default log dir for the miner node. + // + // Note: When running the integration tests with `go test ./itest`, the + // working directory is `itest`, so logs are written under + // `itest/test-logs`. + MinerLogDir = "test-logs" + + // minerSetupOutputs is the number of outputs to generate during miner + // setup. + minerSetupOutputs = 50 + + // minMatureBlocks is the minimum number of blocks to mine to ensure + // coinbase maturity. + minMatureBlocks = 100 + + // retryMultiplier is the multiplier for connection retries to make tests + // more robust. + retryMultiplier = 2 + + // minerWindowMultiplier is the multiplier for the miner confirmation + // window to ensure we mine enough blocks for activation. + minerWindowMultiplier = 2 + + // minerLogDirPerm is the file mode used when creating the miner log dir. + minerLogDirPerm = 0o750 + + // maxMinerLogDirAttempts is the maximum number of attempts to create a + // unique log directory. + maxMinerLogDirAttempts = 1000 +) + +var ( + // harnessNetParams is the network parameters used for the harness. + harnessNetParams = &chaincfg.RegressionNetParams +) + +// minerHarness is a wrapper around rpctest.Harness that provides a mining node +// for integration tests. +type minerHarness struct { + *testing.T + + *rpctest.Harness + + // logPath is the directory path of the miner's logs. + logPath string + + // logFilename is the saved log filename of the miner node. + logFilename string +} + +// newMiner creates a new minerHarness instance. +func newMiner(t *testing.T) *minerHarness { + t.Helper() + + btcdBinary, err := GetBtcdBinary() + require.NoError(t, err, "unable to find btcd binary") + + logDir := createMinerLogDir(t) + + args := []string{ + "--rejectnonstd", // Reject non-standard txs in tests. + "--txindex", // Required for some RPC queries. + "--nowinservice", // Avoid Windows service integration. + "--nobanning", // Avoid peer banning in local tests. + "--debuglevel=debug", // Provide detailed logs for debugging. + "--logdir=" + logDir, // Write logs into our per-run dir. + "--trickleinterval=100ms", // Speed up inv relay in regtest. + "--nostalldetect", // Avoid stall detection flakiness. + } + + // We use an empty handlers struct as we don't need to handle notifications + // directly in the miner wrapper for now. + handlers := &rpcclient.NotificationHandlers{} + + harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) + require.NoError(t, err, "unable to create rpctest harness") + + m := &minerHarness{ + T: t, + Harness: harness, + logPath: logDir, + logFilename: MinerLogFilename, + } + + return m +} + +// createMinerLogDir creates a per-run log directory for the miner. +// +// The directory is named using the format log-YYYYMMDD-HHMMSS. If the +// directory already exists, a numeric suffix is appended. +func createMinerLogDir(t *testing.T) string { + t.Helper() + + // Ensure the log root exists. + err := os.MkdirAll(MinerLogDir, minerLogDirPerm) + require.NoError(t, err, "unable to create miner log root") + + base := "log-" + time.Now().Format("20060102-150405") + + for i := range maxMinerLogDirAttempts { + dir := base + if i > 0 { + dir = fmt.Sprintf("%s-%d", base, i) + } + + fullPath := filepath.Join(MinerLogDir, dir) + + err := os.Mkdir(fullPath, minerLogDirPerm) + if err == nil { + return fullPath + } + + if os.IsExist(err) { + continue + } + + require.NoError(t, err, "unable to create miner log dir") + } + + t.Fatalf( + "unable to create miner log dir: too many collisions (%d)", + maxMinerLogDirAttempts, + ) + + return "" +} + +// SetUp starts the miner node and generates initial blocks to activate SegWit. +func (m *minerHarness) SetUp() { + m.Helper() + + // Increase connection retries to make tests more robust. + m.MaxConnRetries = rpctest.DefaultMaxConnectionRetries * retryMultiplier + m.ConnectionRetryTimeout = rpctest.DefaultConnectionRetryTimeout * + retryMultiplier + + require.NoError( + m, m.Harness.SetUp(true, minerSetupOutputs), + "unable to setup miner", + ) + + // Mine enough blocks to activate SegWit. + // MinerConfirmationWindow is usually 144 for mainnet, but likely smaller + // for regtest. For rpctest, standard is often to mine ~200 blocks + // total to ensure maturity and activation. Assuming harness params are + // standard regtest. + numBlocks := max( + harnessNetParams.MinerConfirmationWindow*minerWindowMultiplier, + minMatureBlocks, + ) + + _, err := m.Client.Generate(numBlocks) + require.NoError(m, err, "unable to generate initial blocks") +} + +// SetUpNoChain starts the miner node without generating a test chain. +// +// This is intended for scenarios where the miner will sync to an existing +// chain (for example, when spawning a temporary miner for reorg tests). +func (m *minerHarness) SetUpNoChain() { + m.Helper() + + // Increase connection retries to make tests more robust. + m.MaxConnRetries = rpctest.DefaultMaxConnectionRetries * retryMultiplier + m.ConnectionRetryTimeout = rpctest.DefaultConnectionRetryTimeout * + retryMultiplier + + // SetUp(true, 0) starts the node, sets up the in-memory wallet, and + // registers notifications, but does not mine any blocks. + require.NoError( + m, m.Harness.SetUp(true, 0), + "unable to setup miner", + ) +} + +// Stop shuts down the miner. +func (m *minerHarness) Stop() { + require.NoError(m, m.TearDown(), "tear down miner failed") + + // Always keep logs for debugging, even for passing tests. + m.Logf("Miner logs available at: %s", m.logPath) +} diff --git a/bwtest/utils.go b/bwtest/utils.go new file mode 100644 index 0000000000..baf7ede64c --- /dev/null +++ b/bwtest/utils.go @@ -0,0 +1,39 @@ +package bwtest + +import ( + "fmt" + "os/exec" + "path/filepath" +) + +// GetBtcdBinary returns the path to the btcd binary. +// It checks if "btcd" is in the PATH. +func GetBtcdBinary() (string, error) { + // If specific path is needed, we could check env vars here. + path, err := exec.LookPath("btcd") + if err != nil { + return "", fmt.Errorf("failed to find btcd binary: %w", err) + } + + path, err = filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + return path, nil +} + +// GetBitcoindBinary returns the path to the bitcoind binary. +func GetBitcoindBinary() (string, error) { + path, err := exec.LookPath("bitcoind") + if err != nil { + return "", fmt.Errorf("failed to find bitcoind binary: %w", err) + } + + path, err = filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + return path, nil +} From 19a8081d124ba460d40c1a0eb6933d563fb9e1b9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 404/691] bwtest: add backend and database helpers Add a minimal chain backend abstraction plus helpers for opening wallet DBs across supported backends, sharing common timeouts and constants. --- bwtest/chain_backend.go | 133 ++++++++++++++++++++++++++++++++++++++++ bwtest/database.go | 53 ++++++++++++++++ bwtest/timeouts.go | 9 +++ 3 files changed, 195 insertions(+) create mode 100644 bwtest/chain_backend.go create mode 100644 bwtest/database.go create mode 100644 bwtest/timeouts.go diff --git a/bwtest/chain_backend.go b/bwtest/chain_backend.go new file mode 100644 index 0000000000..c6a8eb2d76 --- /dev/null +++ b/bwtest/chain_backend.go @@ -0,0 +1,133 @@ +// Package bwtest provides the integration test framework for btcwallet. +package bwtest + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcd/rpcclient" + "github.com/stretchr/testify/require" +) + +// ChainBackend defines the interface that all chain backends must implement. +type ChainBackend interface { + // Start launches the chain backend process. + Start() error + + // Stop shuts down the chain backend process. + Stop() error + + // RPCConfig returns the credentials to connect to this backend. + RPCConfig() rpcclient.ConnConfig + + // P2PAddr returns the P2P address of this node. + P2PAddr() string + + // ConnectMiner connects this node to the miner. + ConnectMiner(minerAddr string) error + + // Name returns the name of the backend ("btcd", "bitcoind", "neutrino"). + Name() string +} + +// BtcdBackend is a ChainBackend backed by a btcd node (via rpctest). +type BtcdBackend struct { + // harness is the underlying rpctest harness that manages the btcd process. + harness *rpctest.Harness +} + +// NewBtcdBackend creates a new BtcdBackend. +func NewBtcdBackend(t *testing.T) *BtcdBackend { + t.Helper() + + btcdBinary, err := GetBtcdBinary() + require.NoError(t, err, "unable to find btcd binary") + + // Create a separate harness for the chain backend. + // We use the same regression net params. + args := []string{ + "--rejectnonstd", // Reject non-standard txs in tests. + "--txindex", // Required for some RPC queries. + "--nowinservice", // Avoid Windows service integration. + "--nobanning", // Avoid peer banning in local tests. + "--debuglevel=debug", // Provide detailed logs for debugging. + "--trickleinterval=100ms", // Speed up inv relay in regtest. + "--nostalldetect", // Avoid stall detection flakiness. + } + + handlers := &rpcclient.NotificationHandlers{} + harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) + require.NoError(t, err, "unable to create btcd backend harness") + + return &BtcdBackend{ + harness: harness, + } +} + +// Start launches the btcd node. +func (b *BtcdBackend) Start() error { + // SetUp(false, 0) means we don't treat it as a miner + // (no mining addrs needed immediately) and don't cache block templates. + err := b.harness.SetUp(false, 0) + if err != nil { + return fmt.Errorf("failed to setup btcd harness: %w", err) + } + + return nil +} + +// Stop shuts down the btcd node. +func (b *BtcdBackend) Stop() error { + err := b.harness.TearDown() + if err != nil { + return fmt.Errorf("failed to teardown btcd harness: %w", err) + } + + return nil +} + +// RPCConfig returns the RPC connection config. +func (b *BtcdBackend) RPCConfig() rpcclient.ConnConfig { + return b.harness.RPCConfig() +} + +// P2PAddr returns the P2P address. +func (b *BtcdBackend) P2PAddr() string { + return b.harness.P2PAddress() +} + +// ConnectMiner connects the backend to the miner. +func (b *BtcdBackend) ConnectMiner(minerAddr string) error { + // We use "add" to make it persistent. + err := b.harness.Client.AddNode(minerAddr, "add") + if err != nil { + return fmt.Errorf("failed to add miner node %s: %w", minerAddr, + err) + } + + return nil +} + +// Name returns "btcd". +func (b *BtcdBackend) Name() string { + return "btcd" +} + +// Ensure BtcdBackend implements ChainBackend. +var _ ChainBackend = (*BtcdBackend)(nil) + +// NewBackend creates a ChainBackend based on the type string. +// Currently only supports "btcd". +func NewBackend(t *testing.T, backendType string) ChainBackend { + t.Helper() + + switch backendType { + case "btcd": + return NewBtcdBackend(t) + // TODO: Add bitcoind and neutrino support. + default: + t.Fatalf("unknown backend type: %s", backendType) + return nil + } +} diff --git a/bwtest/database.go b/bwtest/database.go new file mode 100644 index 0000000000..5fc84f17fe --- /dev/null +++ b/bwtest/database.go @@ -0,0 +1,53 @@ +package bwtest + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" // Register bdb driver. +) + +var ( + // ErrUnknownDBBackend is returned when an unknown db backend is requested. + ErrUnknownDBBackend = errors.New("unknown db backend") +) + +const ( + // dbNameKvdb is the identifier used for the kvdb wallet backend. + dbNameKvdb = "kvdb" + + // kvdbDriver is the walletdb driver name used for kvdb. + kvdbDriver = "bdb" + + // walletDBFilename is the default wallet database filename. + walletDBFilename = "wallet.db" +) + +// OpenWalletDB opens a wallet database instance rooted at baseDir. +// +// The returned cleanup function should be called to close the database. +func OpenWalletDB(dbType, baseDir string) (walletdb.DB, func() error, error) { + switch dbType { + case dbNameKvdb: + dbPath := filepath.Join(baseDir, walletDBFilename) + + db, err := walletdb.Create(kvdbDriver, dbPath, true, + defaultTestTimeout, false) + if err != nil { + return nil, nil, fmt.Errorf("unable to create bdb instance: %w", + err) + } + + cleanup := func() error { + return db.Close() + } + + return db, cleanup, nil + + // TODO: Add sqlite and postgres support. + default: + return nil, nil, fmt.Errorf("%w: %s", ErrUnknownDBBackend, dbType) + } +} diff --git a/bwtest/timeouts.go b/bwtest/timeouts.go new file mode 100644 index 0000000000..6f348a8935 --- /dev/null +++ b/bwtest/timeouts.go @@ -0,0 +1,9 @@ +package bwtest + +import "time" + +const ( + // defaultTestTimeout is a shared default timeout for polling and setup + // steps in integration tests. + defaultTestTimeout = 30 * time.Second +) From 96ced4a4ee7df3531b7bf50a2c28e6dfca93016c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 405/691] bwtest: add integration harness core Introduce a HarnessTest that owns shared miner/backend infrastructure and creates per-test chain clients and wallet DBs for isolated test cases. --- bwtest/harness.go | 240 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 bwtest/harness.go diff --git a/bwtest/harness.go b/bwtest/harness.go new file mode 100644 index 0000000000..679b921d33 --- /dev/null +++ b/bwtest/harness.go @@ -0,0 +1,240 @@ +package bwtest + +import ( + "runtime/debug" + "sync" + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/stretchr/testify/require" +) + +const ( + // defaultChainReconnectAttempts is the number of times the chain RPC client + // will attempt to reconnect before failing. + defaultChainReconnectAttempts = 5 +) + +// HarnessTest is the integration test harness. +type HarnessTest struct { + *testing.T + + // miner is the shared mining node used to generate blocks. + miner *minerHarness + + // Backend is the chain backend under test. + Backend ChainBackend + + // ChainClient is an RPC chain client connected to the active chain backend. + // + // This client is created for each subtest harness and is intended to be + // passed to wallets under test. + ChainClient chain.Interface + + // WalletDB is a wallet database instance created for the current subtest. + WalletDB walletdb.DB + + // dbType is the configured wallet database backend. + dbType string + + // mu protects harness state that can be accessed across the main test and + // subtests. This includes the wallet registry and idempotent shutdown. + mu sync.Mutex + + // wallets is the set of wallets created by a test case. + wallets []*wallet.Wallet + + // stopped prevents stopping shared infrastructure more than once. + stopped bool +} + +// SetupHarness creates a new HarnessTest. +func SetupHarness(t *testing.T, chainBackendType, dbType string) *HarnessTest { + t.Helper() + + // 1. Start Miner (always btcd). + miner := newMiner(t) + miner.SetUp() + + // 2. Start Chain Backend. + backend := NewBackend(t, chainBackendType) + require.NoError(t, backend.Start(), "failed to start chain backend") + + ht := &HarnessTest{ + T: t, + miner: miner, + Backend: backend, + dbType: dbType, + } + + // Ensure the harness is cleaned up when the test finishes. + t.Cleanup(ht.Stop) + + // 3. Connect Backend to Miner. + // Backend startup can take a moment, so we retry until it succeeds. + err := wait.NoError(func() error { + return backend.ConnectMiner(miner.P2PAddress()) + }, defaultTestTimeout) + require.NoError(t, err, "failed to connect backend to miner") + + return ht +} + +// Subtest creates a child harness that shares the miner and chain backend. +// +// The returned harness has its own wallet registry and per-test resources. +// Callers should not call Stop on the returned harness as it would stop shared +// infrastructure. +func (h *HarnessTest) Subtest(t *testing.T) *HarnessTest { + h.Helper() + + st := &HarnessTest{ + T: t, + miner: h.miner, + Backend: h.Backend, + dbType: h.dbType, + } + + // Use the subtest's testing context for miner assertions. + // + // The miner is shared across test cases, but we want failures to be + // attributed to the active subtest. + // + // NOTE: The miner is shared across the whole suite and this assignment + // mutates that shared state. + // + // This is safe because the integration test suite runs subtests serially + // (no t.Parallel()). Do not enable parallel integration cases unless this + // is refactored. + st.miner.T = st.T + + st.setUpChainClient() + st.setUpWalletDB() + + return st +} + +// RegisterWallet registers a wallet with the harness. +// +// Registered wallets are automatically included in harness-level assertions, +// such as MineBlocks. +func (h *HarnessTest) RegisterWallet(w *wallet.Wallet) { + h.Helper() + + if w == nil { + h.Fatalf("cannot register nil wallet") + } + + h.mu.Lock() + h.wallets = append(h.wallets, w) + h.mu.Unlock() +} + +// ActiveWallets returns a snapshot of wallets registered with this harness. +func (h *HarnessTest) ActiveWallets() []*wallet.Wallet { + h.Helper() + + h.mu.Lock() + wallets := append([]*wallet.Wallet(nil), h.wallets...) + h.mu.Unlock() + + return wallets +} + +// RunTestCase executes a harness test case. +// +// Any panic from the test function is converted into a fatal test failure with +// a stack trace. +func (h *HarnessTest) RunTestCase(name string, + testFunc func(t *HarnessTest)) { + + h.Helper() + + defer func() { + r := recover() + if r == nil { + return + } + + stack := debug.Stack() + h.Fatalf("failed (%s): panic=%v\n%s", name, r, stack) + }() + + if testFunc == nil { + h.Fatalf("nil test func for %s", name) + } + + // Execute the case. + testFunc(h) +} + +// NetParams returns the chain parameters used by the harness. +func (h *HarnessTest) NetParams() *chaincfg.Params { + h.Helper() + + return harnessNetParams +} + +// Stop shuts down all resources owned by the harness. +func (h *HarnessTest) Stop() { + h.Helper() + + h.mu.Lock() + + if h.stopped { + h.mu.Unlock() + return + } + + h.stopped = true + h.mu.Unlock() + + // Stop the chain backend first to avoid it attempting to reconnect while + // the miner is being torn down. + require.NoError(h, h.Backend.Stop(), "failed to stop chain backend") + + // Finally, stop the miner. + h.miner.Stop() +} + +// setUpChainClient creates and starts an RPC chain client for the active +// harness backend. +func (h *HarnessTest) setUpChainClient() { + h.Helper() + + backendCfg := h.Backend.RPCConfig() + rpcCfg := backendCfg + + chainConfig := &chain.RPCClientConfig{ + Conn: &rpcCfg, + Chain: harnessNetParams, + ReconnectAttempts: defaultChainReconnectAttempts, + } + chainClient, err := chain.NewRPCClientWithConfig(chainConfig) + require.NoError(h, err, "unable to create chain client") + + err = chainClient.Start(h.Context()) + require.NoError(h, err, "unable to start chain client") + h.Cleanup(chainClient.Stop) + + h.ChainClient = chainClient +} + +// setUpWalletDB opens a wallet database for the configured test backend. +func (h *HarnessTest) setUpWalletDB() { + h.Helper() + + dbDir := h.TempDir() + db, cleanup, err := OpenWalletDB(h.dbType, dbDir) + require.NoError(h, err, "unable to create wallet db") + + h.Cleanup(func() { + require.NoError(h, cleanup(), "failed to close database") + }) + + h.WalletDB = db +} From 3743b0431b502af114d64f051e9a5b101854c377 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 406/691] bwtest: add harness assertions Add harness-level assertions that poll for wallet sync and other global invariants using wait.NoError. --- bwtest/harness_assertions.go | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 bwtest/harness_assertions.go diff --git a/bwtest/harness_assertions.go b/bwtest/harness_assertions.go new file mode 100644 index 0000000000..ac62a554df --- /dev/null +++ b/bwtest/harness_assertions.go @@ -0,0 +1,44 @@ +package bwtest + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/btcsuite/btcwallet/wallet" +) + +var ( + // ErrWalletNotSynced is returned when a wallet has not reached the chain + // tip. + ErrWalletNotSynced = errors.New("wallet not synced") +) + +// AssertWalletSynced polls until the wallet reports it is synced to the +// miner's best known height. +func (h *HarnessTest) AssertWalletSynced(w *wallet.Wallet) { + h.Helper() + + if w == nil { + h.Fatalf("nil wallet") + } + + err := wait.NoError(func() error { + syncedTo := w.SyncedTo() + + _, bestHeight, err := h.miner.Client.GetBestBlock() + if err != nil { + return fmt.Errorf("get best block: %w", err) + } + + if syncedTo.Height != bestHeight { + return fmt.Errorf("%w: wallet=%d chain=%d", ErrWalletNotSynced, + syncedTo.Height, bestHeight) + } + + return nil + }, defaultTestTimeout) + if err != nil { + h.Fatalf("wallet sync timeout: %v", err) + } +} From 61525944ecdc28c219b47049f1f469d34a0cc08a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 407/691] bwtest: add mining and mempool assertions Add harness-level mining helpers and mempool/block assertions modeled after lnd's itest harness, including strict no-txn mining helpers and reorg support. --- bwtest/harness_miner.go | 505 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 bwtest/harness_miner.go diff --git a/bwtest/harness_miner.go b/bwtest/harness_miner.go new file mode 100644 index 0000000000..35884acd65 --- /dev/null +++ b/bwtest/harness_miner.go @@ -0,0 +1,505 @@ +package bwtest + +import ( + "errors" + "fmt" + "strings" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/stretchr/testify/require" +) + +var ( + // ErrMempoolTxNotFound is returned when a transaction is not found in the + // miner's mempool. + ErrMempoolTxNotFound = errors.New("transaction not found in mempool") + + // ErrMempoolTxFound is returned when a transaction is found in the miner's + // mempool. + ErrMempoolTxFound = errors.New("transaction found in mempool") + + // ErrMempoolNumTxnsMismatch is returned when the number of transactions in + // the mempool doesn't match the expected value. + ErrMempoolNumTxnsMismatch = errors.New("mempool txn count mismatch") + + // ErrBlockMissingTx is returned when a transaction is not found in a block. + ErrBlockMissingTx = errors.New("transaction not found in block") + + // ErrBlockUnexpectedTxns is returned when a block contains unexpected + // transactions. + ErrBlockUnexpectedTxns = errors.New("block contains unexpected txns") + + // ErrOutpointNotFound is returned when an outpoint is not found in the + // miner's mempool. + ErrOutpointNotFound = errors.New("outpoint not found in mempool") + + // ErrNilBlock is returned when a nil block is provided. + ErrNilBlock = errors.New("nil block") + + // ErrMinerNotSynced is returned when a temporary miner is not synced to the + // harness miner. + ErrMinerNotSynced = errors.New("miner not synced") +) + +const ( + // coinbaseAndOneTxn is the number of transactions expected in a block that + // contains only a coinbase transaction and a single non-coinbase + // transaction. + coinbaseAndOneTxn = 2 +) + +// GenerateBlocks generates the specified number of blocks. +func (h *HarnessTest) GenerateBlocks(num uint32) []*chainhash.Hash { + h.Helper() + + hashes, err := h.miner.Client.Generate(num) + require.NoError(h, err, "unable to generate blocks") + + return hashes +} + +// AssertTxInMempool asserts a transaction can be found in the miner's mempool. +func (h *HarnessTest) AssertTxInMempool(txid chainhash.Hash) *wire.MsgTx { + h.Helper() + + var foundTx *wire.MsgTx + + err := wait.NoError(func() error { + txids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + for _, memTxid := range txids { + if memTxid == txid { + tx, err := h.miner.Client.GetRawTransaction(&txid) + if err != nil { + return fmt.Errorf("get raw transaction: %w", err) + } + + foundTx = tx.MsgTx() + + return nil + } + } + + return fmt.Errorf("%w: txid=%s", ErrMempoolTxNotFound, txid) + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for txn in mempool") + require.NotNil(h, foundTx, "found tx is nil") + + return foundTx +} + +// AssertTxNotInMempool asserts a transaction cannot be found in the miner's +// mempool. +func (h *HarnessTest) AssertTxNotInMempool(txid chainhash.Hash) { + h.Helper() + + err := wait.NoError(func() error { + txids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + for _, memTxid := range txids { + if memTxid == txid { + return fmt.Errorf("%w: txid=%s", ErrMempoolTxFound, + txid) + } + } + + return nil + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for txn to leave mempool") +} + +// AssertNumTxnsInMempool polls until finding the expected number of +// transactions in the miner's mempool. +func (h *HarnessTest) AssertNumTxnsInMempool(n int) []chainhash.Hash { + h.Helper() + + if n < 0 { + h.Fatalf("invalid mempool size: %d", n) + } + + var txids []chainhash.Hash + + err := wait.NoError(func() error { + mempoolTxids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + if len(mempoolTxids) != n { + return fmt.Errorf("%w: want=%d got=%d", ErrMempoolNumTxnsMismatch, + n, len(mempoolTxids)) + } + + txids = mempoolTxids + + return nil + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for mempool size") + + return txids +} + +// AssertOutpointInMempool asserts an outpoint is spent by a transaction in the +// miner's mempool. +func (h *HarnessTest) AssertOutpointInMempool(op wire.OutPoint) *wire.MsgTx { + h.Helper() + + var foundTx *wire.MsgTx + + err := wait.NoError(func() error { + txids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + for _, txid := range txids { + tx, err := h.miner.Client.GetRawTransaction(&txid) + if err != nil { + return fmt.Errorf("get raw transaction: %w", err) + } + + msgTx := tx.MsgTx() + for _, txIn := range msgTx.TxIn { + if txIn.PreviousOutPoint == op { + foundTx = msgTx + return nil + } + } + } + + return fmt.Errorf("%w: outpoint=%v", ErrOutpointNotFound, op) + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for outpoint in mempool") + require.NotNil(h, foundTx, "found tx is nil") + + return foundTx +} + +// AssertTxInBlock asserts a transaction can be found in a block. +func (h *HarnessTest) AssertTxInBlock(block *wire.MsgBlock, + txid chainhash.Hash) { + + h.Helper() + + if block == nil { + h.Fatalf("nil block") + } + + for _, tx := range block.Transactions { + if tx == nil { + continue + } + + if tx.TxHash() == txid { + return + } + } + + h.Fatalf("%v: block=%v", fmt.Errorf("%w: txid=%s", ErrBlockMissingTx, + txid), block.BlockHash()) +} + +// MineBlocks mines blocks and asserts no transactions are found in the mined +// blocks. +// +// After each block is mined, all registered wallets are required to be synced. +func (h *HarnessTest) MineBlocks(num int) { + h.Helper() + + err := h.MineBlocksNoTxns(num) + if err != nil { + require.Fail(h, "MineBlocks", err.Error()) + } +} + +// MineBlocksNoTxns mines blocks and returns an error if any mined block +// contains non-coinbase transactions. +func (h *HarnessTest) MineBlocksNoTxns(num int) error { + h.Helper() + + blocks := h.generateBlocks(num) + for _, b := range blocks { + err := h.blockHasNoTxns(b) + if err != nil { + return err + } + } + + return nil +} + +// MineEmptyBlocks mines blocks and asserts the mempool remains empty. +// +// This differs from MineBlocks in that it explicitly requires the miner's +// mempool to have no transactions before mining begins. +func (h *HarnessTest) MineEmptyBlocks(num int) []*wire.MsgBlock { + h.Helper() + + // Require the mempool is empty before mining, otherwise these blocks might + // confirm pending transactions. + h.AssertNumTxnsInMempool(0) + + blocks := h.generateBlocks(num) + for _, b := range blocks { + err := h.blockHasNoTxns(b) + if err != nil { + require.Fail(h, "MineEmptyBlocks", err.Error()) + } + } + + return blocks +} + +// MineBlocksAndAssertNumTxns mines blocks and asserts that numTxns +// transactions are included in the first mined block. +func (h *HarnessTest) MineBlocksAndAssertNumTxns(num uint32, + numTxns int) []*wire.MsgBlock { + + h.Helper() + + if num == 0 { + h.Fatalf("invalid block count: %d", num) + } + + txids := h.AssertNumTxnsInMempool(numTxns) + blocks := h.generateBlocks(int(num)) + + for _, txid := range txids { + h.AssertTxInBlock(blocks[0], txid) + h.AssertTxNotInMempool(txid) + } + + return blocks +} + +// MineBlockWithTx mines a single block and asserts it contains the given +// transaction. +func (h *HarnessTest) MineBlockWithTx(tx *wire.MsgTx) *wire.MsgBlock { + h.Helper() + + if tx == nil { + h.Fatalf("nil tx") + } + + txid := tx.TxHash() + h.AssertTxInMempool(txid) + + // Ensure the mempool only contains our transaction so the mined block + // contains only the coinbase and this transaction. + mempoolTxids := h.AssertNumTxnsInMempool(1) + require.Equal(h, txid, mempoolTxids[0], "unexpected txn in mempool") + + blocks := h.MineBlocksAndAssertNumTxns(1, 1) + require.Len(h, blocks, 1, "expected exactly 1 block") + + block := blocks[0] + require.NotNil(h, block, "mined block is nil") + require.Len(h, block.Transactions, coinbaseAndOneTxn, + "expected coinbase and one txn") + require.Equal(h, txid, block.Transactions[1].TxHash(), + "unexpected txn in mined block") + + return block +} + +// SpawnTempMiner creates a temporary miner that is synced with the current +// miner. +// +// This is useful for reorg tests where an alternative chain needs to be mined +// in isolation. +func (h *HarnessTest) SpawnTempMiner() *HarnessTest { + h.Helper() + + tempMiner := newMiner(h.T) + tempMiner.SetUpNoChain() + + th := &HarnessTest{T: h.T, miner: tempMiner} + h.Cleanup(tempMiner.Stop) + + // Connect the miners and wait for the temp miner to sync. + h.ConnectToMiner(th) + + _, mainHeight := h.GetBestBlock() + err := wait.NoError(func() error { + _, tempHeight, err := tempMiner.Client.GetBestBlock() + if err != nil { + return fmt.Errorf("get best block: %w", err) + } + + if tempHeight != mainHeight { + return fmt.Errorf("%w: main=%d temp=%d", ErrMinerNotSynced, + mainHeight, tempHeight) + } + + return nil + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for temp miner to sync") + + // Disconnect the temp miner so it can mine an alternative chain. + h.DisconnectFromMiner(th) + + return th +} + +// ConnectToMiner connects the harness miner to tempMiner. +func (h *HarnessTest) ConnectToMiner(tempMiner *HarnessTest) { + h.Helper() + + if tempMiner == nil { + h.Fatalf("nil temp miner") + } + + if tempMiner.miner == nil { + h.Fatalf("nil temp miner harness") + } + + err := h.miner.Client.AddNode(tempMiner.miner.P2PAddress(), "add") + require.NoError(h, err, "failed to connect to temp miner") + + err = tempMiner.miner.Client.AddNode(h.miner.P2PAddress(), "add") + require.NoError(h, err, "failed to connect temp miner") +} + +// DisconnectFromMiner disconnects the harness miner from tempMiner. +func (h *HarnessTest) DisconnectFromMiner(tempMiner *HarnessTest) { + h.Helper() + + if tempMiner == nil { + h.Fatalf("nil temp miner") + } + + if tempMiner.miner == nil { + h.Fatalf("nil temp miner harness") + } + + err := h.miner.Client.AddNode(tempMiner.miner.P2PAddress(), "remove") + require.NoError(h, err, "failed to disconnect from temp miner") + + err = tempMiner.miner.Client.AddNode(h.miner.P2PAddress(), "remove") + require.NoError(h, err, "failed to disconnect temp miner") +} + +// generateBlocks mines num blocks and returns the full blocks. +// +// After each block is mined, all registered wallets are required to be synced. +func (h *HarnessTest) generateBlocks(num int) []*wire.MsgBlock { + h.Helper() + + // Mining 0 blocks is a no-op. + if num == 0 { + return nil + } + + if num < 0 { + h.Fatalf("invalid block count: %d", num) + } + + if num > int(^uint32(0)) { + h.Fatalf("too many blocks requested: %d", num) + } + + blocks := make([]*wire.MsgBlock, 0, num) + for range num { + hashes := h.GenerateBlocks(1) + require.Len(h, hashes, 1, "expected 1 block hash") + + block, err := h.miner.Client.GetBlock(hashes[0]) + require.NoError(h, err, "failed to get mined block") + + blocks = append(blocks, block) + + // Ensure all wallets we created in this test have caught up. + for _, w := range h.ActiveWallets() { + h.AssertWalletSynced(w) + } + } + + return blocks +} + +// getRawMempool returns the miner's mempool transaction ids. +func (h *HarnessTest) getRawMempool() ([]chainhash.Hash, error) { + h.Helper() + + txids, err := h.miner.Client.GetRawMempool() + if err != nil { + return nil, fmt.Errorf("get raw mempool: %w", err) + } + + result := make([]chainhash.Hash, 0, len(txids)) + for _, txid := range txids { + if txid == nil { + continue + } + + result = append(result, *txid) + } + + return result, nil +} + +// blockHasNoTxns returns an error if block contains non-coinbase transactions. +func (h *HarnessTest) blockHasNoTxns(block *wire.MsgBlock) error { + h.Helper() + + if block == nil { + return ErrNilBlock + } + + if len(block.Transactions) <= 1 { + return nil + } + + var desc strings.Builder + desc.WriteString(fmt.Sprintf( + "block %v has %d txns:\n", + block.BlockHash(), len(block.Transactions)-1, + )) + + for _, tx := range block.Transactions[1:] { + if tx == nil { + continue + } + + desc.WriteString(fmt.Sprintf("%v\n", tx.TxHash())) + } + + desc.WriteString( + "Consider using `MineBlocksAndAssertNumTxns` if you expect " + + "txns, or `MineEmptyBlocks` if you want to keep txns " + + "unconfirmed.", + ) + + return fmt.Errorf("%w: %s", ErrBlockUnexpectedTxns, desc.String()) +} + +// SendOutput sends funds from the miner. +func (h *HarnessTest) SendOutput(output *wire.TxOut, + feeRate btcutil.Amount) *chainhash.Hash { + + h.Helper() + + txid, err := h.miner.SendOutputs([]*wire.TxOut{output}, feeRate) + require.NoError(h, err, "failed to send output") + + return txid +} + +// GetBestBlock returns the hash and height of the best block. +func (h *HarnessTest) GetBestBlock() (*chainhash.Hash, int32) { + h.Helper() + + hash, height, err := h.miner.Client.GetBestBlock() + require.NoError(h, err, "failed to get best block") + + return hash, height +} From 61f0ce6b045a0099ffde3780d68c36930fb6cf86 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:49 +0800 Subject: [PATCH 408/691] itest: add case runner Add an lnd-style suite runner that executes registered itest cases under a shared bwtest harness, with case naming validation for component grouping. --- itest/list_on_test.go | 22 ++++++++++++ itest/main_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 itest/list_on_test.go create mode 100644 itest/main_test.go diff --git a/itest/list_on_test.go b/itest/list_on_test.go new file mode 100644 index 0000000000..9b5e0e2cf6 --- /dev/null +++ b/itest/list_on_test.go @@ -0,0 +1,22 @@ +//go:build itest + +package itest + +import "github.com/btcsuite/btcwallet/bwtest" + +// testCase defines a single integration test case. +type testCase struct { + // Name is the human-readable name of the test case. + Name string + + // TestFunc executes the test case. + TestFunc func(t *bwtest.HarnessTest) +} + +// allTestCases is the full set of integration test cases. +var allTestCases = []*testCase{ + { + Name: "manager create wallet", + TestFunc: testCreateWallet, + }, +} diff --git a/itest/main_test.go b/itest/main_test.go new file mode 100644 index 0000000000..1f01854c38 --- /dev/null +++ b/itest/main_test.go @@ -0,0 +1,84 @@ +//go:build itest + +package itest + +import ( + "flag" + "fmt" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcwallet/bwtest" +) + +var ( + // chainBackend defines the blockchain backend to be used for the + // integration tests. + // Options: "btcd" (default), "bitcoind", "neutrino". + chainBackend = flag.String( + "chain", "btcd", + "chain backend to use (btcd, bitcoind, neutrino)", + ) + + // dbBackend defines the database backend to be used for the wallet + // storage. + // Options: "kvdb" (default), "sqlite", "postgres". + // + // This flag allows verifying that the wallet functions correctly across all + // supported database drivers. + dbBackend = flag.String( + "db", "kvdb", + "database backend to use (kvdb, sqlite, postgres)", + ) +) + +// TestBtcWallet runs the btcwallet integration test suite. +func TestBtcWallet(t *testing.T) { + if len(allTestCases) == 0 { + t.Skip("no integration test cases registered") + } + + harness := bwtest.SetupHarness(t, *chainBackend, *dbBackend) + + for _, tc := range allTestCases { + if tc == nil { + continue + } + + validateTestCaseName(t, tc.Name) + + name := fmt.Sprintf("%s/%s", *chainBackend, tc.Name) + + success := t.Run(name, func(st *testing.T) { + ht := harness.Subtest(st) + ht.RunTestCase(tc.Name, tc.TestFunc) + }) + if !success { + t.Logf("failure time: %v", time.Now().Format( + "2006-01-02 15:04:05.000", + )) + break + } + } +} + +// validateTestCaseName enforces a consistent naming convention for integration +// test cases. +// +// Names must be in the format "component action" (space separated), and must +// not include underscores. +func validateTestCaseName(t *testing.T, name string) { + t.Helper() + + if strings.Contains(name, "_") { + t.Fatalf("invalid test case name %q: underscores are not allowed", + name) + } + + words := strings.Fields(name) + if len(words) < 2 { + t.Fatalf("invalid test case name %q: want 'component action'", + name) + } +} From da7e01b6190bbd9f152abe1fd1f5722e48a2c340 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:50 +0800 Subject: [PATCH 409/691] itest: add manager create wallet test case Add a basic manager create wallet case that creates, starts, and syncs a wallet against the harness chain backend and per-case wallet database. --- itest/manager_test.go | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 itest/manager_test.go diff --git a/itest/manager_test.go b/itest/manager_test.go new file mode 100644 index 0000000000..c7274818e4 --- /dev/null +++ b/itest/manager_test.go @@ -0,0 +1,53 @@ +//go:build itest + +package itest + +import ( + "context" + "time" + + "github.com/btcsuite/btcwallet/bwtest" + "github.com/btcsuite/btcwallet/wallet" + "github.com/stretchr/testify/require" +) + +// testCreateWallet verifies a wallet can be created, started, and synced. +func testCreateWallet(h *bwtest.HarnessTest) { + h.Helper() + + // Create a wallet using the Manager API. + cfg := wallet.Config{ + DB: h.WalletDB, + Chain: h.ChainClient, + ChainParams: h.NetParams(), + RecoveryWindow: 20, + WalletSyncRetryInterval: 500 * time.Millisecond, + Name: "testwallet", + PubPassphrase: []byte("public"), + } + + manager := wallet.NewManager() + params := wallet.CreateWalletParams{ + Mode: wallet.ModeGenSeed, + PubPassphrase: []byte("public"), + PrivatePassphrase: []byte("private"), + Birthday: time.Now().Add(-1 * time.Hour), + } + + w, err := manager.Create(cfg, params) + require.NoError(h, err, "failed to create wallet") + + err = w.Start(h.Context()) + require.NoError(h, err, "failed to start wallet") + h.Cleanup(func() { + // We use a background context here because h.Context() might be + // cancelled already. + require.NoError(h, w.Stop(context.Background()), "failed to stop wallet") + }) + + // Register the wallet so harness helpers can assert global invariants. + h.RegisterWallet(w) + + // Mine a few blocks and require the wallet catches up. + h.MineBlocks(5) +} From e7347f73416b4442beeb3c2e2a8fc8559a04cedb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:50 +0800 Subject: [PATCH 410/691] build: add itest make target Add a make target for running integration tests and pass through harness flags via -args, including case filtering via icase. --- Makefile | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Makefile b/Makefile index f1eff8611c..e34fd8f86b 100644 --- a/Makefile +++ b/Makefile @@ -136,6 +136,22 @@ itest-db-race: @$(call print, "Running $(IT_DB_LABEL) integration tests (race).") env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(ITEST_DB_RACE) +#? itest: Run integration tests +#? itest (vars): chain=btcd|bitcoind|neutrino db=kvdb|sqlite|postgres +#? itest (vars): icase= (filter itest cases) +#? itest (vars): timeout= verbose=1 nocache=1 +#? itest (ex): make itest icase=manager +#? itest (ex): make itest chain=btcd db=kvdb +itest: + @$(call print, "Running integration tests.") + @$(GOTEST) -v ./itest \ + -tags="itest $(DEV_TAGS) nolog" \ + $(if $(icase),-test.run="TestBtcWallet/.*/$(icase)",) \ + $(filter-out -test.run=%,$(TEST_FLAGS)) \ + -args \ + -chain="$(if $(chain),$(chain),btcd)" \ + -db="$(if $(db),$(db),kvdb)" + # ========= # UTILITIES # ========= @@ -279,6 +295,7 @@ sql-lint-check: unit-race \ unit-debug \ unit-bench \ + itest \ itest-db \ itest-db-race \ fmt \ From 9ef29b0438ae3fd4e1bfec46e4de42159fce6a94 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 08:06:50 +0800 Subject: [PATCH 411/691] .gitignore: ignore itest logs Ignore integration test log directories produced by the bwtest miner wrapper to keep worktrees clean. --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 38ab05487f..218d27859d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ coverage.out *.prof *.test *cpu.out + +# Integration test logs. +itest/test-logs/ + +# Backwards compatibility for older log dir. +itest/.minerlogs/ From 31e3bb1fca814c3d2c64ebd75b9e07418cf7e0fa Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 28 Jan 2026 18:15:19 +0800 Subject: [PATCH 412/691] docs: add ADR for tx schema --- docs/developer/adr/0006-wtxmgr-sql-schema.md | 400 +++++++++++++++++++ docs/developer/adr/README.md | 8 +- docs/developer/utxo_data_model.md | 298 ++++++++++++++ 3 files changed, 704 insertions(+), 2 deletions(-) create mode 100644 docs/developer/adr/0006-wtxmgr-sql-schema.md create mode 100644 docs/developer/utxo_data_model.md diff --git a/docs/developer/adr/0006-wtxmgr-sql-schema.md b/docs/developer/adr/0006-wtxmgr-sql-schema.md new file mode 100644 index 0000000000..55778aca79 --- /dev/null +++ b/docs/developer/adr/0006-wtxmgr-sql-schema.md @@ -0,0 +1,400 @@ +# ADR 0006: Wallet Transaction Manager SQL Schema + +## 1. Context + +As part of the migration from a Key-Value store to a relational SQL backend, the Wallet Transaction Manager (`wtxmgr`) requires a new schema design. The `wtxmgr` is responsible for tracking: +1. **Transactions:** Immutable blockchain data and user intent. +2. **UTXOs (Credits):** Outputs owned by the wallet (the primary operational unit). +3. **Spends (Debits):** Inputs that spend those outputs. +4. **Metadata:** User labels and transient locks (leases). + +For a detailed theoretical analysis of the data model, see [UTXO Data Model and Lifecycle](../utxo_data_model.md). + +Note on txid uniqueness: Bitcoin has historical txid-duplicate edge cases (primarily coinbase). However, since BIP30, duplicates cannot create concurrently-unspent outpoints. This schema therefore treats `tx_hash` as unique per wallet for balance/coin selection purposes. + +## 2. Decision + +We will adopt a **UTXO-Centered, Soft-Deletion Schema**. + +### 2.1 Core Principles +1. **UTXO-Centered Operations:** The schema is optimized for `Balance()` and `CoinSelection()` queries, which focus on the `utxos` table. +2. **Transaction-Centered Integrity:** Validity flows from Parent to Child. A UTXO is only valid if its parent Transaction is valid (`status='published'`) or is explicitly allowed for chaining (`status='pending'`). +3. **Immutable History (Soft Deletion):** We **NEVER** automatically `DELETE` rows. + * Failed/RBF'd transactions are marked with a `status` field (e.g., `replaced`, `failed`). + * They remain in the database for audit history but are excluded from balance queries. + * Foreign Keys use `ON DELETE RESTRICT` for creation relationships to prevent accidental data loss. +4. **Wallet-Scoped Rows:** All `wtxmgr` tables are scoped by `wallet_id` to support multiple wallets sharing a single database without row-level conflicts. + +### 2.1.1 Wallet-Scoped vs Global Transactions +Two designs were considered: + +* **Wallet-scoped tables (chosen):** + * Pros: Simple schema; no join tables; queries stay local to a wallet; avoids cross-wallet coordination. + * Cons: Duplicates transaction rows across wallets that observe the same global tx. +* **Global transactions table (alternative):** + * Pros: Storage efficiency; one canonical row per `tx_hash`. + * Cons: Requires additional mapping tables (e.g., `wallet_transactions`) for per-wallet ownership/metadata; more complex queries and constraints; more careful concurrency semantics. + +This ADR chooses wallet-scoped tables for simplicity and implementability. A future migration to a global transactions table is possible but is considered a separate architectural decision. + +### 2.1.2 Explicit Status vs. Derived Status +Two designs were considered for tracking transaction validity: + +* **Explicit `status` column (chosen):** + * The `status` field is a pre-computed materialization of the transaction's validity state, set atomically at write time when the invalidating event occurs (RBF, double-spend, reorg). + * Pros: Balance and coin-selection queries filter on a single status predicate (`status IN ('published', 'pending')`) with no joins (and can be indexed if profiling justifies it); cascading invalidation is performed once at write time and never re-derived; audit states (`failed`, `replaced`, `orphaned`) are directly queryable. + * Cons: Introduces a field that could theoretically drift from the underlying facts. This is partially mitigated by `CHECK` constraints (`check_confirmed_published`, `check_coinbase_confirmation_state`) and coinbase reorg triggers; transition correctness for `failed`/`replaced` remains a write-path responsibility validated by tests. + +* **Derived status from other columns (alternative):** + * At coarse granularity (pending vs active vs invalid), most states are derivable: `orphaned` = `is_coinbase AND block_height IS NULL`; `replaced` and `failed` (direct victim) = existence in `tx_replacements.replaced_tx_id`. + * However, a full replacement requires preserving the distinct invalid states (`replaced`, `failed`, `orphaned`), which have different operational semantics: RBF (`replaced`) allows re-spending the same inputs with a new tx; `orphaned` coinbase can recover on reconfirmation; `failed` (double-spend) is permanent. Collapsing these into a single boolean loses information needed for recovery logic and user-facing audit. + * A boolean decomposition (e.g., `is_broadcast` + `is_invalid`) also introduces problems: `is_broadcast` does not equal `published` — a tx can be valid/published because it was received from the network, not broadcast by this wallet; boolean pairs create ambiguous combinations (e.g., `is_broadcast=false, is_invalid=true`) requiring additional constraints. + * A fully-derived alternative that preserves the same information would require at least three columns (`is_broadcast`/source marker, `invalidation_reason` enum, optional `invalidated_by_tx_id`) — strictly more schema surface than a single `status` column. + * Cascading invalidation adds further complexity: downstream txs whose parent became invalid were never directly "replaced." Without a pre-computed status, every balance/coin-selection query would need a recursive CTE walking up the ancestor chain — O(depth) per tx on the hot path. + +This ADR chooses explicit status as the minimal representation that captures all lifecycle states without ambiguity. The `status` column is a pre-computed materialization set once at write time; database constraints/triggers enforce key invariants (confirmation and coinbase semantics), while write-path logic is responsible for setting `failed`/`replaced` transitions correctly. + +### 2.2 Consistency & Concurrency Model +This design assumes standard SQL ACID guarantees. + +* **Atomic updates:** Reorg disconnects, status transitions (RBF/failure/orphaning), and lease acquisition MUST be performed using explicit SQL transactions. +* **Leases are the concurrency primitive:** `utxo_leases` provides an application-level lock that prevents concurrent coin selection from choosing the same UTXO. +* **Isolation:** Wallet implementations SHOULD treat coin selection + lease acquisition as a single atomic unit. If multiple processes share one database, they must rely on database-enforced constraints (primary keys/uniques) and transactional semantics rather than best-effort in-memory coordination. + +Recommended operational defaults: +* Prefer running write paths under `SERIALIZABLE` and retry on serialization failures. +* If using weaker isolation (e.g., `READ COMMITTED`), rely on unique constraints and single-statement lease acquisition (no read-then-write without conflict handling). + +### 2.3 Reorg & RBF Strategy +* **Reorgs:** The `blocks` table represents the current best chain. If a block is disconnected, any referencing transaction will have `block_height` set to `NULL` via `ON DELETE SET NULL`. + * **Effect (non-coinbase):** Regular transactions become unconfirmed (but remain `published`). + * **Coinbase special case:** Coinbase transactions from the disconnected block are marked `orphaned` (they cannot exist outside the block that created them). + * **Atomicity requirement:** The coinbase status update MUST occur atomically with the disconnect. + * PostgreSQL evaluates `CHECK` constraints immediately, including updates performed by foreign-key actions such as `ON DELETE SET NULL`. + * If you enforce the coinbase invariant at the database level, a simple `DELETE FROM blocks ...` will fail unless the coinbase row's `status` is updated to `orphaned` as part of the same statement. + * Recommended: use a trigger to rewrite coinbase `status` during the `block_height -> NULL` update (see 3.6). + * **Reconfirmation:** If an orphaned coinbase transaction re-enters the best chain, restoring it requires setting `block_height` and `status='published'` atomically. +* **RBF:** Handled by updating the `utxos.spent_by_tx_id` pointer to the new transaction and marking the old transaction as `replaced`. + +## 3. Reference Schema + +### 3.1 Wallet: `transactions` +Stores the provenance of funds for a specific wallet. Acts as the source of truth for Validity (`status`) and Confirmation (`block_height`). + +```sql +CREATE TABLE transactions ( + wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + id BIGSERIAL NOT NULL, + tx_hash BYTEA NOT NULL CHECK (length(tx_hash) = 32), + -- Raw transaction bytes. This is typically TOASTed in PostgreSQL. + -- Hot-path queries (balance/coin selection) SHOULD avoid selecting this column. + raw_tx BYTEA NOT NULL, + + -- Confirmation State: + -- NULL = Unconfirmed (Mempool) + -- INT = Confirmed (Mined) + -- ON DELETE SET NULL: If block is reorged, tx becomes unconfirmed. + block_height INTEGER REFERENCES blocks(block_height) ON DELETE SET NULL, + + -- Validity State (Soft Deletion): + -- pending: Created locally, not yet broadcast. + -- published: Active in mempool or blockchain (Valid); not necessarily broadcast by this wallet. + -- replaced: RBF'd by another transaction (Invalid). + -- failed: Double-spent by a competitor (Invalid). + -- orphaned: Coinbase tx that was reorged out (Invalid). + status VARCHAR(20) NOT NULL DEFAULT 'pending', + + -- Absolute wall clock time, stored in UTC. + received_time TIMESTAMPTZ NOT NULL, + is_coinbase BOOLEAN NOT NULL DEFAULT FALSE, + + -- Composite primary key is intentional: it allows foreign keys to enforce + -- wallet scoping (preventing cross-wallet references). Do not replace this + -- with a single-column PK on `id` unless all referencing FKs are also + -- reworked to maintain the same invariant. + CONSTRAINT pidx_transactions PRIMARY KEY (wallet_id, id), + CONSTRAINT uidx_transactions_hash UNIQUE (wallet_id, tx_hash), + CONSTRAINT valid_status CHECK (status IN ('pending', 'published', 'replaced', 'failed', 'orphaned')), + -- Invariant: If a transaction is confirmed (mined), it must be 'published'. + CONSTRAINT check_confirmed_published CHECK ( + block_height IS NULL OR status = 'published' + ), + -- Invariant: Coinbase transactions cannot exist in the mempool. + -- If a coinbase transaction loses its block via a reorg, it becomes orphaned. + CONSTRAINT check_coinbase_not_pending CHECK (NOT (is_coinbase AND status = 'pending')), + CONSTRAINT check_coinbase_confirmation_state CHECK ( + NOT is_coinbase OR + (block_height IS NOT NULL AND status = 'published') OR + (block_height IS NULL AND status = 'orphaned') + ) +); + +-- Optimization for Mempool lookups +CREATE INDEX idx_transactions_unconfirmed +ON transactions (wallet_id, block_height) +WHERE block_height IS NULL; + +-- Optimization for "all transactions in block X" queries +CREATE INDEX idx_transactions_by_block +ON transactions (wallet_id, block_height) +WHERE block_height IS NOT NULL; + +-- Optimization for "latest transactions" queries +CREATE INDEX idx_transactions_by_received_time +ON transactions (wallet_id, received_time DESC); + +-- Optimization for status-based filtering (non-hot path) +-- Optional: profile before enabling (low cardinality) +-- CREATE INDEX idx_transactions_by_status +-- ON transactions (wallet_id, status); +``` + +### 3.2 Local: `utxos` +The **Single Source of Truth** for wallet balance. +Note: The table keeps the `utxos` name for consistency with wallet abstractions, even though it retains spent rows for audit history (`spent_by_tx_id` marks spent outputs). + +```sql +CREATE TABLE utxos ( + wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + id BIGSERIAL NOT NULL, + + -- Creation (OutPoint): + -- ON DELETE RESTRICT: We NEVER delete confirmed transactions or their UTXOs. + -- To remove history, the user must explicitly Prune (manual op). + tx_id BIGINT NOT NULL, + output_index INTEGER NOT NULL CHECK (output_index >= 0), + + amount BIGINT NOT NULL CHECK (amount >= 0), + -- The output script is stored on the address record (see the address-manager + -- schema). `addresses.script_pub_key` is expected to be NOT NULL for all + -- addresses, including HD-derived addresses. + -- Optional address book reference for indexing and UX. + -- The `addresses` table is part of the address-manager schema (tracked + -- separately from this ADR's `wtxmgr` schema). + -- + -- Note: in the draft address schema (`wallet/internal/db/migrations/*/000006_addresses.up.sql`), + -- `addresses` is keyed by an `account_id` that is ultimately rooted in a + -- specific wallet (via key scopes). Implementations must ensure `address_id` + -- always refers to an address belonging to the same `wallet_id`. + address_id BIGINT NOT NULL REFERENCES addresses(id) ON DELETE RESTRICT, + + -- Spending (Input): + -- ON DELETE SET NULL: If the spending tx is manually pruned, the UTXO becomes unspent. + spent_by_tx_id BIGINT, + -- NULL when unspent; non-NULL when spent (enforced by pair constraint). + spent_input_index INTEGER CHECK (spent_input_index IS NULL OR spent_input_index >= 0), + + -- Composite primary key is intentional: it allows leases and other + -- references to enforce wallet scoping. + CONSTRAINT pidx_utxos PRIMARY KEY (wallet_id, id), + CONSTRAINT fkey_utxos_tx FOREIGN KEY (wallet_id, tx_id) + REFERENCES transactions(wallet_id, id) ON DELETE RESTRICT, + CONSTRAINT fkey_utxos_spent_by FOREIGN KEY (wallet_id, spent_by_tx_id) + REFERENCES transactions(wallet_id, id) ON DELETE SET NULL, + + CONSTRAINT check_spent_tx_and_index_pair CHECK ( + (spent_by_tx_id IS NULL AND spent_input_index IS NULL) OR + (spent_by_tx_id IS NOT NULL AND spent_input_index IS NOT NULL) + ), + + CONSTRAINT uidx_utxos_outpoint UNIQUE (wallet_id, tx_id, output_index) +); + +-- Optimization for Balance Queries (Index-Only Scan) +CREATE INDEX idx_utxos_unspent ON utxos (address_id, amount) WHERE spent_by_tx_id IS NULL; + +-- Optimization for listing all UTXOs for an address (including spent) +CREATE INDEX idx_utxos_by_address ON utxos (address_id); + +-- Optimization for finding inputs (debits) of a transaction +CREATE INDEX idx_utxos_spent_by ON utxos(wallet_id, spent_by_tx_id); + +-- Optimization for listing all outputs of a transaction +CREATE INDEX idx_utxos_by_tx ON utxos(wallet_id, tx_id); +``` + +Denormalization note: +* This schema is normalized: the canonical locking script is stored in the address book (`addresses.script_pub_key`). +* Joining through `address_id` adds work to the signing/reconstruction path, but keeps the hot UTXO table small and avoids duplicating script data. + +Cross-wallet integrity note: +* Ideally, the database should prevent a `utxos` row from referencing an `address_id` belonging to a different wallet. +* The strongest enforcement is a composite FK `FOREIGN KEY (wallet_id, address_id) REFERENCES addresses(wallet_id, id)`. +* If the address-manager schema does not expose `wallet_id` on `addresses`, this invariant must be enforced by application logic. + +### 3.3 Audit: `tx_replacements` +Tracks the history of RBF and Double-Spends. + +```sql +CREATE TABLE tx_replacements ( + wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + id BIGSERIAL NOT NULL, + + -- ON DELETE CASCADE: Supports Manual Pruning. + -- If a transaction is physically deleted (to save space), + -- its audit history is automatically cleaned up to prevent FK violations. + replaced_tx_id BIGINT NOT NULL, + replacement_tx_id BIGINT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Composite primary key is intentional: it enforces wallet scoping. + CONSTRAINT pidx_tx_replacements PRIMARY KEY (wallet_id, id), + CONSTRAINT fkey_tx_replacements_replaced FOREIGN KEY (wallet_id, replaced_tx_id) + REFERENCES transactions(wallet_id, id) ON DELETE CASCADE, + CONSTRAINT fkey_tx_replacements_replacement FOREIGN KEY (wallet_id, replacement_tx_id) + REFERENCES transactions(wallet_id, id) ON DELETE CASCADE, + CONSTRAINT check_not_self_replacement CHECK (replaced_tx_id != replacement_tx_id), + CONSTRAINT uidx_tx_replacements_edge UNIQUE (wallet_id, replaced_tx_id, replacement_tx_id) +); +``` + +### 3.4 Local: `utxo_leases` +Transient application locks. + +```sql +CREATE TABLE utxo_leases ( + wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + utxo_id BIGINT NOT NULL, + external_lock_id BYTEA NOT NULL CHECK (length(external_lock_id) = 32), + expires_at TIMESTAMPTZ NOT NULL, + + -- Composite primary key is intentional: it enforces wallet scoping. + CONSTRAINT pidx_utxo_leases PRIMARY KEY (wallet_id, utxo_id), + CONSTRAINT fkey_utxo_leases_utxo FOREIGN KEY (wallet_id, utxo_id) + REFERENCES utxos(wallet_id, id) ON DELETE CASCADE +); + +-- Optimization for lease cleanup +CREATE INDEX idx_utxo_leases_expires_at ON utxo_leases(expires_at); + +-- Lease cleanup is expected to be periodic: +-- DELETE FROM utxo_leases WHERE expires_at <= CURRENT_TIMESTAMP; + +-- Lease acquisition is expected to be a single atomic statement. +-- Recommended pattern (PostgreSQL): acquire if absent, renew if same external_lock_id, +-- or steal only if expired. +-- Portability note: SQLite supports UPSERT, but uses different time functions. +-- Prefer `CURRENT_TIMESTAMP` in cross-database examples. +-- The `WHERE` clause in `ON CONFLICT DO UPDATE` is supported in SQLite 3.24.0+; verify compatibility with your target version. +-- +-- INSERT INTO utxo_leases (wallet_id, utxo_id, external_lock_id, expires_at) +-- VALUES ($1, $2, $3, CURRENT_TIMESTAMP + $4) +-- ON CONFLICT (wallet_id, utxo_id) DO UPDATE +-- SET external_lock_id = EXCLUDED.external_lock_id, +-- expires_at = EXCLUDED.expires_at +-- WHERE utxo_leases.expires_at <= CURRENT_TIMESTAMP +-- OR utxo_leases.external_lock_id = EXCLUDED.external_lock_id +-- RETURNING expires_at; +-- +-- If no row is returned, the UTXO is currently leased by another external_lock_id. + +Deadlock avoidance note: +* When acquiring multiple leases in one transaction, acquire them in a stable order + (for example sorted by `(wallet_id, utxo_id)`) to reduce deadlock risk. +``` + +### 3.5 Convenience Views + +**`spendable_utxos` View:** +Encapsulates the logic of joining transactions to filter out invalid/failed parents. +Note: Filtering for maturity (Coinbase > 100 confs) is done at the application layer using the exposed `block_height` and `is_coinbase` columns, as Views cannot accept dynamic parameters like `current_height`. + +Suggested helper (parameterized query): +* Expose a query helper that takes `current_height` and filters out immature coinbase outputs: + * `WHERE (NOT is_coinbase) OR (current_height - block_height) >= 100` + +Important: The view includes `pending` parent transactions to enable zero-latency chaining. This is an advanced mode and should be opt-in for conservative spending policies. + +Note: Leases are time-based and depend on the database's current time function. For clarity, the view does not attempt to exclude leased UTXOs. Coin selection MUST exclude active leases (for example using a `NOT EXISTS` subquery against `utxo_leases` where `expires_at > CURRENT_TIMESTAMP`). + +```sql +CREATE VIEW spendable_utxos AS +SELECT + u.*, + t.block_height, + t.is_coinbase, + t.status as tx_status +FROM utxos u +JOIN transactions t ON t.wallet_id = u.wallet_id AND t.id = u.tx_id +WHERE u.spent_by_tx_id IS NULL + AND t.status IN ('published', 'pending'); +``` + +### 3.6 Triggers (PostgreSQL) + +To enforce the coinbase invariant under `ON DELETE SET NULL`, PostgreSQL needs a trigger because `CHECK` constraints are not deferrable. + +Recommended behavior: +* When `block_height` transitions from `NOT NULL` to `NULL`, and `is_coinbase = TRUE`, automatically rewrite `status = 'orphaned'`. + +This can be implemented with a `BEFORE UPDATE` trigger on `transactions`. Foreign-key actions execute as ordinary `UPDATE` statements, and triggers on the referencing table will fire. + +Portability note: +* PostgreSQL: Foreign-key actions (such as `ON DELETE SET NULL`) are performed via ordinary `UPDATE` statements on the referencing table, and triggers on that table will fire. +* SQLite: Foreign-key actions occur after the parent row operation. If you cannot rely on triggers to rewrite `status` during the FK action, enforce coinbase orphaning with an explicit application-side update in the same SQL transaction as the disconnect. + +**SQLite example:** To atomically disconnect a block and orphan its coinbase transactions, run the following within a single transaction: +```sql +BEGIN; +DELETE FROM blocks WHERE block_height = ?; +UPDATE transactions SET status = 'orphaned' WHERE is_coinbase AND block_height IS NULL; +COMMIT; +``` + +Example sketch: + +```sql +CREATE FUNCTION set_coinbase_orphaned_on_disconnect() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.block_height IS NULL AND OLD.block_height IS NOT NULL AND NEW.is_coinbase THEN + NEW.status := 'orphaned'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_set_coinbase_orphaned_on_disconnect +BEFORE UPDATE OF block_height ON transactions +FOR EACH ROW +EXECUTE FUNCTION set_coinbase_orphaned_on_disconnect(); +``` + +## 4. Consequences + +### 4.1. True Multi-Wallet Support +All `wtxmgr` tables are scoped by `wallet_id`. This allows multiple wallets to share the same database without conflicting unique constraints (for example, outpoints and transaction hashes are unique per wallet). + +Note: A separate, truly global `transactions` table shared across wallets is a different design. That approach would require a join table (e.g., `wallet_transactions`) to track per-wallet ownership and metadata. + +### 4.2. Native SQL Efficiency +Balances are calculated using `SUM(amount)` on the `utxos` table (or `spendable_utxos` view), leveraging database optimizations. + +### 4.3. Audit Trail +By using "Soft Deletion" (`status='replaced'`), we maintain a complete history of user attempts, even those that failed. This is superior to previous designs that physically deleted failed transactions. + +### 4.4. Complexity Trade-off +We accept slightly more complexity in **Transaction Reconstruction** (joining inputs/outputs) in exchange for maximal performance in **Balance Calculation** and **Coin Selection**, which are the high-frequency operations. + +Additional operational consequences: +* **Pending-chaining is advanced:** The `spendable_utxos` view includes `pending` parents to enable zero-latency chaining. This increases operational risk (child transactions depend on parents being broadcast) and should be disabled for conservative policies. +* **Recursive invalidation is unbounded in theory:** Marking downstream transactions `failed` after an upstream double-spend can require recursive graph traversal. Implementations should assume bounded typical depth but plan for worst-case behavior (e.g., set a maximum recursion depth or iteration limit). + +### 4.5 Operational Notes (Out of Scope for This ADR) +This ADR defines the target schema and invariants. Production operations still require explicit policies and tooling: + +* **Pruning:** Define criteria for manual pruning (what can be deleted, and how to preserve audit semantics). +* **Migration:** Define how to migrate from the existing key-value store (offline conversion, incremental migration, rollback plan). +* **Backup/Restore:** Large immutable histories impact backup size and restore time; restores may require revalidation of unconfirmed transactions. + * After restore, the wallet should re-fetch the current mempool and re-evaluate RBF/double-spend status for unconfirmed transactions. +* **Schema evolution:** Future changes should be delivered via versioned migrations; preserve immutable-history guarantees when introducing new columns or constraints. + +Monitoring note: +* Track table growth, count of active leases, and lease cleanup latency. These are important for long-running nodes and multi-process deployments. + +## 5. Status + +Accepted. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index c2efa58d16..bdd3aa154b 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -6,5 +6,9 @@ ADRs serve as a historical log of important design choices, providing context fo ## Existing ADRs -* [ADR 0001: Multi-Wallet Architecture](./0001-multi-wallet-architecture.md) -* [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) +- [ADR 0001: Multi-Wallet Architecture](./0001-multi-wallet-architecture.md) - Decides on the architecture for managing multiple distinct wallets and networks within a single daemon instance. +- [ADR 0002: Controller-Syncer-State Architecture](./0002-controller-syncer-architecture.md) - Decouples lifecycle management, synchronization logic, and state tracking from the monolithic `Wallet` struct. +- [ADR 0003: Optimistic CFilter Batch Scanning](./0003-optimistic-cfilter-batching.md) - Optimizes BIP 157/158 Compact Filter synchronization using optimistic batch scanning. +- [ADR 0004: Targeted Rescan vs. Global Rewind](./0004-targeted-rescan-vs-rewind.md) - Introduces "Targeted Rescans" to replace global "Rewinds" for more efficient transaction discovery. +- [ADR 0005: Explicit Rescan on Import](./0005-no-auto-rescan-on-import.md) - Disables automatic blockchain scanning during import operations, requiring explicit user initiation. +- [ADR 0006: Wallet Transaction Manager SQL Schema](./0006-wtxmgr-sql-schema.md) - Defines the relational SQL schema for the Wallet Transaction Manager (`wtxmgr`) migration. diff --git a/docs/developer/utxo_data_model.md b/docs/developer/utxo_data_model.md new file mode 100644 index 0000000000..ed077c2390 --- /dev/null +++ b/docs/developer/utxo_data_model.md @@ -0,0 +1,298 @@ +# Wallet Data Model and Lifecycle + +This document defines the core data model, state machines, and design philosophy of the `btcwallet` transaction subsystem. It serves as the definitive reference for understanding how the wallet manages money, history, and state. + +## 1. Design Philosophy + +The `btcwallet` architecture is built upon a specific worldview: **The wallet is a UTXO Manager.** + +While Bitcoin users often think in terms of "Balances" and "Transactions," the operational reality of the Bitcoin protocol is the **Unspent Transaction Output (UTXO)**. A wallet's primary responsibility is to discover, secure, and select these outputs for spending. + +### 1.1 Structural vs. Operational Centrality + +Our data model distinguishes between data integrity and operational efficiency: + +* **Structurally Transaction-Centered:** Data integrity flows from the parent Transaction to the child UTXO. A UTXO cannot exist without a creating transaction. If a transaction is invalid (double-spent), its outputs are invalid. +* **Operationally UTXO-Centered:** The read path is optimized for UTXOs. 99% of wallet operations (calculating balance, selecting coins for a new payment) query the **UTXO set**, not the transaction history. + +### 1.2 The "Immutable History" Policy + +We adhere to a strict **"Never Delete History"** policy. +* **Chain Data is Immutable:** Once a transaction is mined, it happened. We record it. +* **Intent is Immutable:** If a user attempts a transaction that later fails (e.g., RBF replaced), that attempt is part of the wallet's audit trail. +* **Soft Deletion:** We do not physically `DELETE` rows. Invalid or replaced transactions are marked with a status (`failed`, `replaced`) so they are ignored by balance calculations but preserved for history. + +--- + +## 2. The Data Model + +The wallet state is composed of four primary entities. + +```mermaid +classDiagram + direction LR + + class Block { + Height + Hash + Timestamp + } + + class Transaction { + TxHash + RawBytes + Status + BlockHeight (FK) + } + + class UTXO { + OutPoint (TxHash:Index) + Amount + Address + SpentBy (FK) + } + + class Lease { + LockID + Expiration + } + + Block "1" --> "0..*" Transaction : Confirms + Transaction "1" --> "0..*" UTXO : Creates + Transaction "0..1" --> "0..*" UTXO : Spends (Inputs) + UTXO "1" --> "0..1" Lease : Locks +``` + +Note: This diagram is conceptual and uses natural identifiers like `TxHash` and an outpoint (`TxHash:Index`). The SQL schema uses surrogate keys (for example `transactions.id`) and scopes all `wtxmgr` rows by `wallet_id`. + +Mapping (conceptual -> SQL): + +| Concept | SQL (example) | +| --- | --- | +| `TxHash` | `transactions.tx_hash` (unique per `wallet_id`) | +| Transaction row ID | `(transactions.wallet_id, transactions.id)` | +| OutPoint (`TxHash:Index`) | `(utxos.wallet_id, utxos.tx_id, utxos.output_index)` | +| Address (address book) | `addresses.id` (address-manager schema) | +| Confirmation | `transactions.block_height` (FK to `blocks(block_height)`) | +| Lease | `(utxo_leases.wallet_id, utxo_leases.utxo_id)` | + +### 2.1 Entity Roles + +* **Block:** The anchor for time and confirmation. It handles reorgs. If a block is disconnected, linked transactions automatically lose their confirmation status. +* **Transaction:** The provenance record. It stores the *intent* (Who sent? When?) and the *status* (Confirmed? Failed?). +* **UTXO:** The unit of value. This is the "Coin". It is the bridge between transactions. +* **Lease:** A temporary, memory-like lock on a UTXO to prevent double-spending within the wallet application (e.g., reserving a coin for a Lightning channel open). + +--- + +## 3. Transaction Lifecycle + +A Transaction represents an event that *moves* value. Its lifecycle tracks the journey from user intent to blockchain finality. + +### 3.1 State Machine + +```mermaid +stateDiagram-v2 + [*] --> Pending : User Creates + [*] --> Published : P2P/Mempool + + Pending --> Published : Broadcast + + %% Confirmation is orthogonal (BlockHeight != NULL) + + Published --> Replaced : RBF (Higher Fee Tx Wins) + Published --> Failed : Double-Spend (Other Tx Wins) + Published --> Orphaned : Reorged Coinbase + + Pending --> Failed : Inputs Spent Elsewhere + + %% Coinbase can re-enter the best chain after a reorg. + Orphaned --> Published : Reconfirmed + + Replaced --> [*] + Failed --> [*] + Orphaned --> [*] +``` + +### 3.2 Status vs. Confirmation + +We distinguish between a transaction's **Validity** (Status) and its **Confirmation** (Block Height). + +**1. Confirmation (Source of Truth: `BlockHeight`)** +* **Unconfirmed:** `BlockHeight IS NULL` +* **Confirmed:** `BlockHeight IS NOT NULL` (points to a valid block) + +**2. Validity (Source of Truth: `Status`)** + +| Status | Meaning | Affects Balance? | +| :--- | :--- | :--- | +| `pending` | Created locally, not yet broadcast. | **No** (Locked) | +| `published` | Active in mempool or blockchain. | **Yes** (If valid) | +| `replaced` | Replaced by a higher-fee transaction (RBF). | **No** | +| `failed` | Invalidated by a conflicting transaction (Double-Spend). | **No** | +| `orphaned` | Coinbase tx that was reorged out (Invalid). | **No** | + +*Additional invariant: Coinbase transactions cannot exist in the mempool. A coinbase transaction is either confirmed (`BlockHeight IS NOT NULL` and `Status='published'`) or orphaned (`Status='orphaned'` and `BlockHeight IS NULL`).* + +*Note: There is no "Abandoned" state. A broadcast transaction cannot be safely abandoned; it can only be invalidated by double-spending its inputs.* + +--- + +## 4. UTXO Lifecycle + +A UTXO represents **spendable potential**. Its state is derived entirely from its parent Transaction and its "Spent By" pointer. + +### 4.1 State Machine + +```mermaid +stateDiagram-v2 + state "Spendable (Unspent)" as Unspent + state "Immature (Coinbase)" as Immature + state "Leased (Locked)" as Leased + state "Spent (Archive)" as Spent + state "Dead (Invalid)" as Dead + + [*] --> Unspent : Created by Valid Tx + [*] --> Immature : Coinbase Confirmed + + Unspent --> Leased : Application Lock + Leased --> Unspent : Lock Expire/Release + + Unspent --> Spent : Used in New Tx + Leased --> Spent : Forced Spend + + Spent --> Unspent : Reorg (Spending Tx Invalidated) + + %% Coinbase maturity + Immature --> Unspent : Matures (100 blocks) + + %% Parent validity changes (derived, not persisted) + Unspent --> Dead : Parent Invalidated + Immature --> Dead : Parent Orphaned + Dead --> Immature : Parent Reconfirmed (Coinbase) +``` + +### 4.2 Derivation Logic + +A UTXO does not store its own status field. Its status is calculated dynamically to ensure consistency. + +| UTXO State | Parent Tx Status | `BlockHeight` | `SpentBy` Pointer | Meaning | +| :--- | :--- | :--- | :--- | :--- | +| **Confirmed** | `published` | `NOT NULL` | `NULL` | Available, mature funds. | +| **Unconfirmed** | `published` | `NULL` | `NULL` | Incoming funds, risk of RBF. | +| **Immature** | `published` (Coinbase) | `NOT NULL` | `NULL` | Mined coins, must wait 100 blocks. | +| **Spent** | `published` | `NOT NULL` | `NOT NULL` | History. We used this money. | +| **Dead (Permanent)** | `failed` / `replaced` | *Any* | *Any* | Permanently invalid output from a failed attempt. | +| **Dead (Recoverable)** | `orphaned` (coinbase) | `NULL` | *Any* | Orphaned coinbase output. Can become `Immature` if parent is reconfirmed. | + +--- + +## 5. Operational Behaviors + +### 5.1 Reorg Handling (Auto-Healing) + +```mermaid +graph TD + Block[Block N - Orphaned] -.->|Invalidates| Tx[Transaction] + + Tx -->|State becomes Unconfirmed| Output[Created UTXO] + Output -.->|State becomes Unconfirmed| Balance[Wallet Balance] + + Tx -->|Releases| Input[Spent UTXO] + Input -.->|State becomes Unspent| Balance +``` + +When the blockchain reorganizes (disconnects blocks): +1. **Block** record is disconnected from the best chain (deleted/replaced). +2. **Transactions** in that block have their `BlockHeight` set to `NULL`. +3. **Effect (non-coinbase):** Regular transactions revert to `Unconfirmed` state (but remain `published`). + * **Coinbase special case:** Coinbase transactions from the disconnected block are marked `orphaned` (as per ADR 0006) instead of reverting to `Unconfirmed`, since they cannot exist outside of the block that created them. +4. **Cascading Effect:** + * Outputs created by these txs become `Unconfirmed` (except for outputs of `orphaned` coinbase txs, which are no longer considered part of the spendable UTXO set). + * Inputs spent by these txs revert to `Unspent` (if the spending tx is completely invalidated). + +Implementation note: The SQL `blocks` table models the current best chain. During a disconnect, the best-chain association is removed (for example by deleting the `blocks` row at that height and inserting the new one). The coinbase status update MUST be atomic with clearing `BlockHeight` to avoid an invalid "unconfirmed coinbase" state. + +Coinbase reconfirmation note: If an orphaned coinbase transaction re-enters the best chain, restoring it requires updating `BlockHeight` and `Status='published'` atomically (one SQL statement within a transaction) to satisfy coinbase invariants. + +Reconfirmation semantics: +* The transaction keeps the same `tx_hash` and is associated with a new `block_height`. +* Outputs created by the transaction transition from `Dead (Recoverable)` back to `Immature` (and later become spendable after maturity). + +Deep reorg note: A deep reorg is handled as a batch of block disconnects. Implementations should treat this as a bounded, transactional operation (potentially chunked) rather than assuming a single-block example. + +### 5.2 RBF (Replace-By-Fee) + +```mermaid +graph TD + Input[Input UTXO] + + Input -->|Spent By| TxB[Tx B - New] + TxB -->|Status: Published| Network + + Input -.->|Was Spent By| TxA[Tx A - Old] + TxA -->|Status: Replaced| Archive +``` + +When the wallet detects a replacement transaction (Tx B) for an existing unconfirmed transaction (Tx A): +1. **Detection:** Tx B spends the same inputs as Tx A. +2. **Update:** The input UTXOs are updated to point to Tx B (`SpentBy = TxB`). +3. **Archive:** Tx A is marked as `status = 'replaced'`. It remains in the DB for history but is ignored by balance queries. + +### 5.3 Upstream Double-Spend (Invalidation) + +```mermaid +graph TD + Input[Input UTXO] + + Input -->|Spent By| TxD[Tx D - Competitor] + TxD -->|Status: Confirmed| Blockchain + + Input -.->|Was Spent By| TxC[Tx C - Ours] + TxC -->|Status: Failed| Archive + + TxC -->|Creates| Output[Output UTXO] + Output -.->|Status: Dead| Archive +``` + +When a transaction (Tx C) becomes invalid because *its input* was spent by a conflicting transaction (Tx D) that confirmed: +1. **Detection:** We see Tx D confirmed in a block. It spends UTXO X. +2. **Conflict:** We have Tx C (unconfirmed) which also spends UTXO X. +3. **Resolution:** + * Update UTXO X: `SpentBy = TxD`. + * Mark Tx C: `Status = 'failed'`. + * **Cascade:** Any UTXOs created by Tx C are now effectively "Dead" (because their parent is `failed`). + * Any transactions spending those dead UTXOs must also be marked `failed` (recursive invalidation). + +Architectural consequence: Recursive invalidation can require graph traversal across transaction chains. Typical wallet histories are shallow, but the design should acknowledge worst-case depth and define an execution strategy (batch processing, recursion limits, and/or iterative traversal) to avoid pathological performance. + +Suggested implementation strategy: +1. Start with a queue of newly-invalid transactions (double-spent, replaced, or orphaned). +2. Mark those transactions `failed`/`replaced`/`orphaned`. +3. Find UTXOs created by those transactions. +4. For each such UTXO, find transactions that spend it and enqueue them. +5. Repeat until the queue is empty. + +This can be implemented with an application-side loop (portable across databases) or a database-side recursive CTE (PostgreSQL). + +### 5.4 Coin Selection +Coin selection queries focus purely on the UTXO set: +1. Filter by `SpentBy IS NULL`. +2. Join Parent Transaction to ensure `Status IN ('published', 'pending')`. + * *Note:* Including `pending` allows chaining unbroadcast transactions (Zero-Latency Chaining), but creates a strict dependency: the parent must be broadcast before the child. + * This is an advanced feature and should be opt-in for conservative policies. + * If confirmation is required (e.g., for safety), add `AND BlockHeight IS NOT NULL`. +3. Filter out active `Leases`. + +Important: The `spendable_utxos` view described in ADR 0006 does not exclude leases (because it depends on the database's current time function and requires a `NOT EXISTS` against `utxo_leases`). Coin selection must explicitly filter `utxo_leases` where `expires_at > CURRENT_TIMESTAMP`. + +Transaction reconstruction note: This model optimizes the high-frequency UTXO read path. Reconstructing a full transaction view (inputs/outputs for UI/history) is inherently more join-heavy and should be treated as a lower-frequency, separately optimized read path. + +### 5.5 Multi-Wallet Concurrency & Consistency +When multiple wallets share a single database, all `wtxmgr` rows are scoped by `wallet_id` (see ADR 0006). This avoids conflicts and makes correctness properties explicit. + +* **Consistency model:** The system assumes ACID semantics from the database. Reorg handling, status transitions, and lease acquisition must be performed as explicit SQL transactions. +* **Concurrency primitive:** UTXO leases are the wallet-level lock. Coin selection must exclude leased UTXOs, and lease acquisition should be treated as part of the same atomic unit as input selection. +* **Coinbase maturity:** Coinbase UTXOs require 100 confirmations. This rule should be enforced in a single, well-defined place (query helper/view + height parameter) to avoid accidental selection of immature funds. + +Suggested enforcement: a parameterized query helper or SQL function that takes `current_height` and filters out coinbase UTXOs where `current_height - block_height < 100`. From abdc1a47220239b0ce71575cd17a42da86b2f9ea Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:13:12 +0800 Subject: [PATCH 413/691] itest: simplify case runner and add shuffle --- itest/main_test.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/itest/main_test.go b/itest/main_test.go index 1f01854c38..1549e3f504 100644 --- a/itest/main_test.go +++ b/itest/main_test.go @@ -5,11 +5,14 @@ package itest import ( "flag" "fmt" + "math/rand" "strings" "testing" "time" + "github.com/btcsuite/btcd/integration/rpctest" "github.com/btcsuite/btcwallet/bwtest" + "github.com/btcsuite/btcwallet/chain/port" ) var ( @@ -31,14 +34,34 @@ var ( "db", "kvdb", "database backend to use (kvdb, sqlite, postgres)", ) + + // shuffleSeedFlag is the source of randomness used to shuffle the test + // cases. If not specified, the test cases won't be shuffled. + shuffleSeedFlag = flag.Uint64( + "shuffleseed", 0, + "if set, shuffles the test cases using this as the source of "+ + "randomness", + ) ) +func init() { + // Use system-unique ports for rpctest harnesses so multiple local test runs + // don't collide. + rpctest.ListenAddressGenerator = func() (string, string) { + p2p := fmt.Sprintf(rpctest.ListenerFormat, port.NextAvailablePort()) + rpc := fmt.Sprintf(rpctest.ListenerFormat, port.NextAvailablePort()) + return p2p, rpc + } +} + // TestBtcWallet runs the btcwallet integration test suite. func TestBtcWallet(t *testing.T) { if len(allTestCases) == 0 { t.Skip("no integration test cases registered") } + maybeShuffleTestCases() + harness := bwtest.SetupHarness(t, *chainBackend, *dbBackend) for _, tc := range allTestCases { @@ -63,6 +86,20 @@ func TestBtcWallet(t *testing.T) { } } +// maybeShuffleTestCases shuffles the test cases if the flag `shuffleseed` is +// set and not 0. +func maybeShuffleTestCases() { + // Exit if set to 0. + if *shuffleSeedFlag == 0 { + return + } + + r := rand.New(rand.NewSource(int64(*shuffleSeedFlag))) + r.Shuffle(len(allTestCases), func(i, j int) { + allTestCases[i], allTestCases[j] = allTestCases[j], allTestCases[i] + }) +} + // validateTestCaseName enforces a consistent naming convention for integration // test cases. // From e73614d4952623800487cf86dab1f425509de612 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:13:29 +0800 Subject: [PATCH 414/691] build: update itest backend vars and filtering --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index e34fd8f86b..5e6fe85144 100644 --- a/Makefile +++ b/Makefile @@ -137,7 +137,7 @@ itest-db-race: env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(ITEST_DB_RACE) #? itest: Run integration tests -#? itest (vars): chain=btcd|bitcoind|neutrino db=kvdb|sqlite|postgres +#? itest (vars): chain=btcd|bitcoind|neutrino backend=btcd|bitcoind|neutrino db=kvdb|sqlite|postgres #? itest (vars): icase= (filter itest cases) #? itest (vars): timeout= verbose=1 nocache=1 #? itest (ex): make itest icase=manager @@ -146,10 +146,10 @@ itest: @$(call print, "Running integration tests.") @$(GOTEST) -v ./itest \ -tags="itest $(DEV_TAGS) nolog" \ - $(if $(icase),-test.run="TestBtcWallet/.*/$(icase)",) \ + $(if $(icase),-test.run="TestBtcWallet$$|$(icase)",) \ $(filter-out -test.run=%,$(TEST_FLAGS)) \ -args \ - -chain="$(if $(chain),$(chain),btcd)" \ + -chain="$(if $(backend),$(backend),$(if $(chain),$(chain),btcd))" \ -db="$(if $(db),$(db),kvdb)" # ========= From e0be6b7dfe6b5e20cdf080248d50d7f490c191a6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 7 Feb 2026 02:49:32 +0800 Subject: [PATCH 415/691] bwtest: refactor itest harness and log capture --- bwtest/backend.go | 83 +++++++++++++++++ bwtest/btcd.go | 138 +++++++++++++++++++++++++++++ bwtest/chain_backend.go | 133 ---------------------------- bwtest/doc.go | 3 + bwtest/harness.go | 118 ++++++++++++++++++++----- bwtest/harness_miner.go | 5 +- bwtest/logdir.go | 158 +++++++++++++++++++++++++++++++++ bwtest/logs_finalize.go | 186 +++++++++++++++++++++++++++++++++++++++ bwtest/miner.go | 80 ++--------------- bwtest/perms.go | 7 ++ bwtest/utils.go | 11 +++ bwtest/wallet_logging.go | 78 ++++++++++++++++ 12 files changed, 767 insertions(+), 233 deletions(-) create mode 100644 bwtest/backend.go create mode 100644 bwtest/btcd.go delete mode 100644 bwtest/chain_backend.go create mode 100644 bwtest/doc.go create mode 100644 bwtest/logdir.go create mode 100644 bwtest/logs_finalize.go create mode 100644 bwtest/perms.go create mode 100644 bwtest/wallet_logging.go diff --git a/bwtest/backend.go b/bwtest/backend.go new file mode 100644 index 0000000000..8b1035b575 --- /dev/null +++ b/bwtest/backend.go @@ -0,0 +1,83 @@ +package bwtest + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcwallet/chain" +) + +const ( + backendBtcd = "btcd" + backendBitcoind = "bitcoind" + backendNeutrino = "neutrino" +) + +var ( + errMissingMinerAddr = errors.New("missing miner address") +) + +// ChainBackend defines the interface that all chain backends must implement. +// +// A ChainBackend instance is shared across the whole itest suite run. +// Implementations must be safe to reuse across subtests that run serially. +type ChainBackend interface { + // Name returns the name of the backend ("btcd", "bitcoind", "neutrino"). + Name() string + + // Start launches the chain backend. + Start() error + + // Stop shuts down the chain backend. + Stop() error + + // ConnectMiner connects this backend to the miner. + ConnectMiner(minerAddr string) error + + // NewChainClient creates a new chain.Interface instance backed by this + // backend. + // + // This is expected to be called once for each subtest. The returned cleanup + // function must stop and release all resources created by the client. + NewChainClient(ctx context.Context) (chain.Interface, func(), error) + + // LogDir returns the directory where the backend writes its logs (if any). + LogDir() string +} + +// NewBackend creates a ChainBackend based on the type string. +func NewBackend(t *testing.T, backendType, logDir string) ChainBackend { + t.Helper() + + switch backendType { + case backendBtcd: + return NewBtcdBackend(t, logDir) + + case backendBitcoind, backendNeutrino: + t.Fatalf("chain backend %q is not implemented yet", backendType) + return nil + + default: + t.Fatalf("unknown chain backend %q", backendType) + return nil + } +} + +// validateBackendType validates the chain backend identifier provided by test +// flags before backend construction starts. +func validateBackendType(t *testing.T, backendType string) { + t.Helper() + + switch backendType { + case backendBtcd: + return + + case backendBitcoind, backendNeutrino: + t.Fatalf("chain backend %q is not implemented yet", backendType) + return + + default: + t.Fatalf("unknown chain backend %q", backendType) + } +} diff --git a/bwtest/btcd.go b/bwtest/btcd.go new file mode 100644 index 0000000000..205def545e --- /dev/null +++ b/bwtest/btcd.go @@ -0,0 +1,138 @@ +package bwtest + +import ( + "context" + "fmt" + "testing" + + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcwallet/chain" + "github.com/stretchr/testify/require" +) + +// BtcdBackend is a ChainBackend backed by a btcd node (via rpctest). +type BtcdBackend struct { + // harness is the underlying rpctest harness that manages the btcd process. + harness *rpctest.Harness + + // logDir is the directory where btcd writes its logs. + logDir string + + // minerAddr is the P2P address of the shared miner. + minerAddr string +} + +// NewBtcdBackend creates a new BtcdBackend. +func NewBtcdBackend(t *testing.T, logDir string) *BtcdBackend { + t.Helper() + + btcdBinary, err := GetBtcdBinary() + require.NoError(t, err, "unable to find btcd binary") + + err = ensureLogDir(logDir) + require.NoError(t, err, "unable to create btcd backend log dir") + + // Create a separate harness for the chain backend. + args := []string{ + "--rejectnonstd", // Reject non-standard txs in tests. + "--txindex", // Required for some RPC queries. + "--nowinservice", // Avoid Windows service integration. + "--nobanning", // Avoid peer banning in local tests. + "--debuglevel=debug", // Provide detailed logs for debugging. + "--logdir=" + logDir, // Write logs into our per-run dir. + "--trickleinterval=100ms", // Speed up inv relay in regtest. + "--nostalldetect", // Avoid stall detection flakiness. + } + + handlers := &rpcclient.NotificationHandlers{} + harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) + require.NoError(t, err, "unable to create btcd backend harness") + + return &BtcdBackend{ + harness: harness, + logDir: logDir, + } +} + +// Name returns the identifier of the backend. +func (b *BtcdBackend) Name() string { + return backendBtcd +} + +// Start launches the backend daemon. +func (b *BtcdBackend) Start() error { + // SetUp(false, 0) means we don't treat it as a miner and don't cache block + // templates. + err := b.harness.SetUp(false, 0) + if err != nil { + return fmt.Errorf("setup btcd harness: %w", err) + } + + if b.minerAddr == "" { + return fmt.Errorf("btcd: %w", errMissingMinerAddr) + } + + // Connect the backend to the miner after the node is up. + err = b.harness.Client.AddNode(b.minerAddr, "add") + if err != nil { + return fmt.Errorf("add miner node %s: %w", b.minerAddr, err) + } + + return nil +} + +// Stop shuts down the backend daemon. +func (b *BtcdBackend) Stop() error { + err := b.harness.TearDown() + if err != nil { + return fmt.Errorf("teardown btcd harness: %w", err) + } + + return nil +} + +// ConnectMiner records the miner address for later use. +func (b *BtcdBackend) ConnectMiner(minerAddr string) error { + b.minerAddr = minerAddr + + return nil +} + +// NewChainClient creates a new RPC-backed chain.Interface connected to this +// backend. +func (b *BtcdBackend) NewChainClient(ctx context.Context) (chain.Interface, + func(), error) { + + backendCfg := b.harness.RPCConfig() + rpcCfg := backendCfg + + chainConfig := &chain.RPCClientConfig{ + Conn: &rpcCfg, + Chain: harnessNetParams, + ReconnectAttempts: defaultChainReconnectAttempts, + } + + chainClient, err := chain.NewRPCClientWithConfig(chainConfig) + if err != nil { + return nil, nil, fmt.Errorf("create chain client: %w", err) + } + + err = chainClient.Start(ctx) + if err != nil { + return nil, nil, fmt.Errorf("start chain client: %w", err) + } + + cleanup := func() { + chainClient.Stop() + } + + return chainClient, cleanup, nil +} + +// LogDir returns the directory where btcd wrote its logs for this run. +func (b *BtcdBackend) LogDir() string { + return b.logDir +} + +var _ ChainBackend = (*BtcdBackend)(nil) diff --git a/bwtest/chain_backend.go b/bwtest/chain_backend.go deleted file mode 100644 index c6a8eb2d76..0000000000 --- a/bwtest/chain_backend.go +++ /dev/null @@ -1,133 +0,0 @@ -// Package bwtest provides the integration test framework for btcwallet. -package bwtest - -import ( - "fmt" - "testing" - - "github.com/btcsuite/btcd/integration/rpctest" - "github.com/btcsuite/btcd/rpcclient" - "github.com/stretchr/testify/require" -) - -// ChainBackend defines the interface that all chain backends must implement. -type ChainBackend interface { - // Start launches the chain backend process. - Start() error - - // Stop shuts down the chain backend process. - Stop() error - - // RPCConfig returns the credentials to connect to this backend. - RPCConfig() rpcclient.ConnConfig - - // P2PAddr returns the P2P address of this node. - P2PAddr() string - - // ConnectMiner connects this node to the miner. - ConnectMiner(minerAddr string) error - - // Name returns the name of the backend ("btcd", "bitcoind", "neutrino"). - Name() string -} - -// BtcdBackend is a ChainBackend backed by a btcd node (via rpctest). -type BtcdBackend struct { - // harness is the underlying rpctest harness that manages the btcd process. - harness *rpctest.Harness -} - -// NewBtcdBackend creates a new BtcdBackend. -func NewBtcdBackend(t *testing.T) *BtcdBackend { - t.Helper() - - btcdBinary, err := GetBtcdBinary() - require.NoError(t, err, "unable to find btcd binary") - - // Create a separate harness for the chain backend. - // We use the same regression net params. - args := []string{ - "--rejectnonstd", // Reject non-standard txs in tests. - "--txindex", // Required for some RPC queries. - "--nowinservice", // Avoid Windows service integration. - "--nobanning", // Avoid peer banning in local tests. - "--debuglevel=debug", // Provide detailed logs for debugging. - "--trickleinterval=100ms", // Speed up inv relay in regtest. - "--nostalldetect", // Avoid stall detection flakiness. - } - - handlers := &rpcclient.NotificationHandlers{} - harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) - require.NoError(t, err, "unable to create btcd backend harness") - - return &BtcdBackend{ - harness: harness, - } -} - -// Start launches the btcd node. -func (b *BtcdBackend) Start() error { - // SetUp(false, 0) means we don't treat it as a miner - // (no mining addrs needed immediately) and don't cache block templates. - err := b.harness.SetUp(false, 0) - if err != nil { - return fmt.Errorf("failed to setup btcd harness: %w", err) - } - - return nil -} - -// Stop shuts down the btcd node. -func (b *BtcdBackend) Stop() error { - err := b.harness.TearDown() - if err != nil { - return fmt.Errorf("failed to teardown btcd harness: %w", err) - } - - return nil -} - -// RPCConfig returns the RPC connection config. -func (b *BtcdBackend) RPCConfig() rpcclient.ConnConfig { - return b.harness.RPCConfig() -} - -// P2PAddr returns the P2P address. -func (b *BtcdBackend) P2PAddr() string { - return b.harness.P2PAddress() -} - -// ConnectMiner connects the backend to the miner. -func (b *BtcdBackend) ConnectMiner(minerAddr string) error { - // We use "add" to make it persistent. - err := b.harness.Client.AddNode(minerAddr, "add") - if err != nil { - return fmt.Errorf("failed to add miner node %s: %w", minerAddr, - err) - } - - return nil -} - -// Name returns "btcd". -func (b *BtcdBackend) Name() string { - return "btcd" -} - -// Ensure BtcdBackend implements ChainBackend. -var _ ChainBackend = (*BtcdBackend)(nil) - -// NewBackend creates a ChainBackend based on the type string. -// Currently only supports "btcd". -func NewBackend(t *testing.T, backendType string) ChainBackend { - t.Helper() - - switch backendType { - case "btcd": - return NewBtcdBackend(t) - // TODO: Add bitcoind and neutrino support. - default: - t.Fatalf("unknown backend type: %s", backendType) - return nil - } -} diff --git a/bwtest/doc.go b/bwtest/doc.go new file mode 100644 index 0000000000..bf125c105d --- /dev/null +++ b/bwtest/doc.go @@ -0,0 +1,3 @@ +// Package bwtest contains the integration test harness used by the itest +// package. +package bwtest diff --git a/bwtest/harness.go b/bwtest/harness.go index 679b921d33..ec201a069a 100644 --- a/bwtest/harness.go +++ b/bwtest/harness.go @@ -1,12 +1,14 @@ package bwtest import ( + "context" + "fmt" + "path/filepath" "runtime/debug" "sync" "testing" "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcwallet/bwtest/wait" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/wallet" "github.com/btcsuite/btcwallet/walletdb" @@ -23,6 +25,9 @@ const ( type HarnessTest struct { *testing.T + // logDir is the per-run root log directory. + logDir string + // miner is the shared mining node used to generate blocks. miner *minerHarness @@ -50,22 +55,39 @@ type HarnessTest struct { // stopped prevents stopping shared infrastructure more than once. stopped bool + + // cleaned indicates the subtest cleanup has already run. + cleaned bool } // SetupHarness creates a new HarnessTest. func SetupHarness(t *testing.T, chainBackendType, dbType string) *HarnessTest { t.Helper() + logDir := createTestLogDir(t, chainBackendType, dbType) + // 1. Start Miner (always btcd). - miner := newMiner(t) + minerLogDir := createOrEnsureLogSubDir(t, logDir, "miner") + miner := newMiner(t, minerLogDir) miner.SetUp() // 2. Start Chain Backend. - backend := NewBackend(t, chainBackendType) + backendLogDir := "" + + // Neutrino runs in-process and has no separate daemon log directory. The + // external daemon backends (btcd/bitcoind) each get a dedicated backend log + // sub-directory. + if chainBackendType != backendNeutrino { + backendLogDir = createOrEnsureLogSubDir(t, logDir, "chain-backend") + } + + backend := NewBackend(t, chainBackendType, backendLogDir) + require.NoError(t, backend.ConnectMiner(miner.P2PAddress())) require.NoError(t, backend.Start(), "failed to start chain backend") ht := &HarnessTest{ T: t, + logDir: logDir, miner: miner, Backend: backend, dbType: dbType, @@ -74,13 +96,6 @@ func SetupHarness(t *testing.T, chainBackendType, dbType string) *HarnessTest { // Ensure the harness is cleaned up when the test finishes. t.Cleanup(ht.Stop) - // 3. Connect Backend to Miner. - // Backend startup can take a moment, so we retry until it succeeds. - err := wait.NoError(func() error { - return backend.ConnectMiner(miner.P2PAddress()) - }, defaultTestTimeout) - require.NoError(t, err, "failed to connect backend to miner") - return ht } @@ -94,6 +109,7 @@ func (h *HarnessTest) Subtest(t *testing.T) *HarnessTest { st := &HarnessTest{ T: t, + logDir: h.logDir, miner: h.miner, Backend: h.Backend, dbType: h.dbType, @@ -112,9 +128,42 @@ func (h *HarnessTest) Subtest(t *testing.T) *HarnessTest { // is refactored. st.miner.T = st.T + walletLogCleanup := setUpWalletLogging( + t, filepath.Join(st.logDir, walletLogFileName(t)), + ) + st.Cleanup(walletLogCleanup) + st.setUpChainClient() st.setUpWalletDB() + st.Cleanup(func() { + // If a test fails, we still try to stop wallets to avoid leaking + // goroutines into the next test, but we skip assertions. + if st.Failed() { + err := st.stopActiveWallets(context.Background()) + if err != nil { + st.Logf("failed to stop wallets during failed-test cleanup: %v", + err) + } + + return + } + + if st.cleaned { + return + } + + err := st.stopActiveWallets(context.Background()) + require.NoError(st, err, "failed to stop wallets") + + mempool, err := st.getRawMempool() + require.NoError(st, err, "failed to query miner mempool") + require.Empty(st, mempool, "mempool not cleaned") + + st.cleaned = true + h.cleaned = true + }) + return st } @@ -199,28 +248,49 @@ func (h *HarnessTest) Stop() { // Finally, stop the miner. h.miner.Stop() + + // Flatten logs into the per-run log dir. + h.finalizeLogs() } -// setUpChainClient creates and starts an RPC chain client for the active -// harness backend. -func (h *HarnessTest) setUpChainClient() { +// stopActiveWallets stops all wallets registered with the harness. +// +// This is used as part of the per-subtest cleanup to avoid leaking background +// goroutines into the next test. +func (h *HarnessTest) stopActiveWallets(ctx context.Context) error { h.Helper() - backendCfg := h.Backend.RPCConfig() - rpcCfg := backendCfg + for _, w := range h.ActiveWallets() { + if w == nil { + // Keep cleanup robust against partially initialized test state. A + // caller could register a wallet reference and fail before the + // assignment completes. + continue + } - chainConfig := &chain.RPCClientConfig{ - Conn: &rpcCfg, - Chain: harnessNetParams, - ReconnectAttempts: defaultChainReconnectAttempts, + // The modern Wallet controller's Stop method is idempotent. + // + // NOTE: We intentionally don't call the deprecated WaitForShutdown/ + // ShuttingDown methods here, as modern wallets might not have the + // legacy fields initialized. + err := w.Stop(ctx) + if err != nil { + return fmt.Errorf("stop wallet: %w", err) + } } - chainClient, err := chain.NewRPCClientWithConfig(chainConfig) - require.NoError(h, err, "unable to create chain client") - err = chainClient.Start(h.Context()) - require.NoError(h, err, "unable to start chain client") - h.Cleanup(chainClient.Stop) + return nil +} + +// setUpChainClient creates and starts a chain client for the active harness +// backend. +func (h *HarnessTest) setUpChainClient() { + h.Helper() + + chainClient, cleanup, err := h.Backend.NewChainClient(h.Context()) + require.NoError(h, err, "unable to create chain client") + h.Cleanup(cleanup) h.ChainClient = chainClient } diff --git a/bwtest/harness_miner.go b/bwtest/harness_miner.go index 35884acd65..5c1410aa40 100644 --- a/bwtest/harness_miner.go +++ b/bwtest/harness_miner.go @@ -319,10 +319,11 @@ func (h *HarnessTest) MineBlockWithTx(tx *wire.MsgTx) *wire.MsgBlock { func (h *HarnessTest) SpawnTempMiner() *HarnessTest { h.Helper() - tempMiner := newMiner(h.T) + minerLogDir := createUniqueLogSubDir(h.T, h.logDir, "miner-temp") + tempMiner := newMiner(h.T, minerLogDir) tempMiner.SetUpNoChain() - th := &HarnessTest{T: h.T, miner: tempMiner} + th := &HarnessTest{T: h.T, logDir: h.logDir, miner: tempMiner} h.Cleanup(tempMiner.Stop) // Connect the miners and wait for the temp miner to sync. diff --git a/bwtest/logdir.go b/bwtest/logdir.go new file mode 100644 index 0000000000..17c7a72fdf --- /dev/null +++ b/bwtest/logdir.go @@ -0,0 +1,158 @@ +package bwtest + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + // testLogsRootDir is the directory under the itest package where all + // per-run logs are stored. + // + // Note: When running the integration tests with `go test ./itest`, the + // working directory is `itest`, so logs are written under + // `itest/test-logs`. + testLogsRootDir = "test-logs" + + maxLogDirAttempts = 1000 +) + +// createTestLogDir creates a per-run log directory under `test-logs`. +// +// The directory is named using the format: +// +// log---YYYYMMDD-HHMMSS +// +// If the directory already exists, a numeric suffix is appended. +func createTestLogDir(t *testing.T, chainBackend, dbBackend string) string { + t.Helper() + + err := os.MkdirAll(testLogsRootDir, logDirPerm) + require.NoError(t, err, "unable to create test log root") + + chainBackend = sanitizeLogToken(chainBackend) + dbBackend = sanitizeLogToken(dbBackend) + + base := fmt.Sprintf( + "log-%s-%s-%s", chainBackend, dbBackend, + time.Now().Format("20060102-150405"), + ) + + // Use Mkdir instead of MkdirAll so we can detect collisions and retry + // with a deterministic numeric suffix. + for i := range maxLogDirAttempts { + dir := base + if i > 0 { + dir = fmt.Sprintf("%s-%d", base, i) + } + + fullPath := filepath.Join(testLogsRootDir, dir) + + err := os.Mkdir(fullPath, logDirPerm) + if err == nil { + _, _ = fmt.Fprintf(os.Stdout, "itest logs dir: %s\n", fullPath) + return fullPath + } + + if os.IsExist(err) { + continue + } + + require.NoError(t, err, "unable to create test log dir") + } + + t.Fatalf( + "unable to create test log dir: too many collisions (%d)", + maxLogDirAttempts, + ) + + return "" +} + +// sanitizeLogToken converts a string into a safe filename token. +func sanitizeLogToken(token string) string { + if token == "" { + return "unknown" + } + + var b strings.Builder + for _, r := range token { + if isSafeLogRune(r) { + b.WriteRune(r) + continue + } + + b.WriteByte('_') + } + + return b.String() +} + +// isSafeLogRune reports whether r can be used in log directory/file names +// without additional escaping. +func isSafeLogRune(r rune) bool { + switch { + case r >= 'a' && r <= 'z': + return true + case r >= 'A' && r <= 'Z': + return true + case r >= '0' && r <= '9': + return true + case r == '-' || r == '_': + return true + default: + return false + } +} + +// createOrEnsureLogSubDir creates a named sub-directory under a per-run log +// directory. +func createOrEnsureLogSubDir(t *testing.T, parent, name string) string { + t.Helper() + + full := filepath.Join(parent, name) + err := os.MkdirAll(full, logDirPerm) + require.NoError(t, err, "unable to create log subdir") + + return full +} + +// createUniqueLogSubDir creates a uniquely named sub-directory under a per-run +// log directory. +func createUniqueLogSubDir(t *testing.T, parent, prefix string) string { + t.Helper() + + // Retry with a numeric suffix when a directory name collision occurs. + for i := range maxLogDirAttempts { + dir := prefix + if i > 0 { + dir = fmt.Sprintf("%s-%d", prefix, i) + } + + full := filepath.Join(parent, dir) + + err := os.Mkdir(full, logDirPerm) + if err == nil { + return full + } + + if os.IsExist(err) { + continue + } + + require.NoError(t, err, "unable to create log subdir") + } + + t.Fatalf( + "unable to create log subdir: too many collisions (%d)", + maxLogDirAttempts, + ) + + return "" +} diff --git a/bwtest/logs_finalize.go b/bwtest/logs_finalize.go new file mode 100644 index 0000000000..813056e15f --- /dev/null +++ b/bwtest/logs_finalize.go @@ -0,0 +1,186 @@ +package bwtest + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +const ( + minerLogFilename = "miner.log" + chainBackendLogFilename = "chain_backend.log" + + logFilePerm = 0o600 +) + +// finalizeLogs flattens component logs into the per-run log directory. +func (h *HarnessTest) finalizeLogs() { + h.Helper() + + // Flatten miner logs. + minerDst := filepath.Join(h.logDir, minerLogFilename) + + err := flattenBtcdLogs(h.T, h.miner.logPath, minerDst) + if err != nil { + h.Logf("failed to flatten miner logs: %v", err) + } + + chainLogDir := h.Backend.LogDir() + + chainDst := filepath.Join(h.logDir, chainBackendLogFilename) + if chainLogDir == "" { + // Some backends (eg. neutrino) do not have an external process log + // directory. Still create the file for consistent log collection. + // #nosec G304 -- chainDst is created by the test harness. + f, err := os.OpenFile( + chainDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, logFilePerm, + ) + if err != nil { + h.Logf("failed to create chain backend log file: %v", err) + return + } + + _ = f.Close() + + return + } + + switch h.Backend.Name() { + case backendBtcd: + err = flattenBtcdLogs(h.T, chainLogDir, chainDst) + if err != nil { + h.Logf("failed to flatten btcd backend logs: %v", err) + } + + default: + // No backend logs to flatten. + } +} + +// flattenBtcdLogs concatenates btcd logs under srcDir into dstFile. +func flattenBtcdLogs(t *testing.T, srcDir, dstFile string) error { + t.Helper() + + pattern := filepath.Join(srcDir, "*", "btcd.log*") + + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("glob btcd logs: %w", err) + } + + if len(matches) == 0 { + return nil + } + + files := filterRegularFiles(matches) + if len(files) == 0 { + return nil + } + + sortBtcdLogs(files) + + // #nosec G304 -- dstFile is created by the test harness. + f, err := os.OpenFile(dstFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + logFilePerm) + if err != nil { + return fmt.Errorf("open dst log: %w", err) + } + + defer func() { + _ = f.Close() + }() + + for i, p := range files { + // Add a small delimiter between rotated files. + if i > 0 { + _, _ = f.WriteString("\n") + } + + base := filepath.Base(p) + _, _ = f.WriteString("--- " + base + " ---\n") + + // #nosec G304 -- p is discovered under the harness-controlled log dir. + src, err := os.Open(p) + if err != nil { + return fmt.Errorf("open src log: %w", err) + } + + _, cpErr := io.Copy(f, src) + _ = src.Close() + + if cpErr != nil { + return fmt.Errorf("copy src log: %w", cpErr) + } + } + + // Best effort cleanup to keep the log dir shallow. + _ = os.RemoveAll(srcDir) + + return nil +} + +// filterRegularFiles filters to existing regular files. +func filterRegularFiles(paths []string) []string { + files := make([]string, 0, len(paths)) + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + continue + } + + if info.Mode().IsRegular() { + files = append(files, p) + } + } + + return files +} + +// sortBtcdLogs sorts btcd logs by rotation index so older logs appear first. +func sortBtcdLogs(paths []string) { + sort.Slice(paths, func(i, j int) bool { + iBase := filepath.Base(paths[i]) + jBase := filepath.Base(paths[j]) + + // Prefer older rotated logs first: btcd.log.N ... btcd.log.1 then + // btcd.log. + iN, iOk := btcdLogRotationIndex(iBase) + + jN, jOk := btcdLogRotationIndex(jBase) + if iOk && jOk { + // Larger rotation index means an older file. For example, + // btcd.log.3 is older than btcd.log.1 and should be concatenated + // first. + return iN > jN + } + + if iOk != jOk { + // Rotated logs before the active log. + return iOk + } + + // Fallback to lexicographic ordering. + return iBase < jBase + }) +} + +// btcdLogRotationIndex parses the rotation suffix of btcd log filenames. +func btcdLogRotationIndex(base string) (int, bool) { + // btcd rotates logs like btcd.log.1, btcd.log.2, ... + const prefix = "btcd.log." + if !strings.HasPrefix(base, prefix) { + return 0, false + } + + n, err := strconv.Atoi(strings.TrimPrefix(base, prefix)) + if err != nil { + return 0, false + } + + return n, true +} diff --git a/bwtest/miner.go b/bwtest/miner.go index e8cf7c0ef1..872bd68de9 100644 --- a/bwtest/miner.go +++ b/bwtest/miner.go @@ -1,11 +1,7 @@ package bwtest import ( - "fmt" - "os" - "path/filepath" "testing" - "time" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/integration/rpctest" @@ -14,16 +10,6 @@ import ( ) const ( - // MinerLogFilename is the default log filename for the miner node. - MinerLogFilename = "output_btcd_miner.log" - - // MinerLogDir is the default log dir for the miner node. - // - // Note: When running the integration tests with `go test ./itest`, the - // working directory is `itest`, so logs are written under - // `itest/test-logs`. - MinerLogDir = "test-logs" - // minerSetupOutputs is the number of outputs to generate during miner // setup. minerSetupOutputs = 50 @@ -39,13 +25,6 @@ const ( // minerWindowMultiplier is the multiplier for the miner confirmation // window to ensure we mine enough blocks for activation. minerWindowMultiplier = 2 - - // minerLogDirPerm is the file mode used when creating the miner log dir. - minerLogDirPerm = 0o750 - - // maxMinerLogDirAttempts is the maximum number of attempts to create a - // unique log directory. - maxMinerLogDirAttempts = 1000 ) var ( @@ -62,19 +41,17 @@ type minerHarness struct { // logPath is the directory path of the miner's logs. logPath string - - // logFilename is the saved log filename of the miner node. - logFilename string } // newMiner creates a new minerHarness instance. -func newMiner(t *testing.T) *minerHarness { +func newMiner(t *testing.T, logDir string) *minerHarness { t.Helper() btcdBinary, err := GetBtcdBinary() require.NoError(t, err, "unable to find btcd binary") - logDir := createMinerLogDir(t) + err = ensureLogDir(logDir) + require.NoError(t, err, "unable to create miner log dir") args := []string{ "--rejectnonstd", // Reject non-standard txs in tests. @@ -95,56 +72,14 @@ func newMiner(t *testing.T) *minerHarness { require.NoError(t, err, "unable to create rpctest harness") m := &minerHarness{ - T: t, - Harness: harness, - logPath: logDir, - logFilename: MinerLogFilename, + T: t, + Harness: harness, + logPath: logDir, } return m } -// createMinerLogDir creates a per-run log directory for the miner. -// -// The directory is named using the format log-YYYYMMDD-HHMMSS. If the -// directory already exists, a numeric suffix is appended. -func createMinerLogDir(t *testing.T) string { - t.Helper() - - // Ensure the log root exists. - err := os.MkdirAll(MinerLogDir, minerLogDirPerm) - require.NoError(t, err, "unable to create miner log root") - - base := "log-" + time.Now().Format("20060102-150405") - - for i := range maxMinerLogDirAttempts { - dir := base - if i > 0 { - dir = fmt.Sprintf("%s-%d", base, i) - } - - fullPath := filepath.Join(MinerLogDir, dir) - - err := os.Mkdir(fullPath, minerLogDirPerm) - if err == nil { - return fullPath - } - - if os.IsExist(err) { - continue - } - - require.NoError(t, err, "unable to create miner log dir") - } - - t.Fatalf( - "unable to create miner log dir: too many collisions (%d)", - maxMinerLogDirAttempts, - ) - - return "" -} - // SetUp starts the miner node and generates initial blocks to activate SegWit. func (m *minerHarness) SetUp() { m.Helper() @@ -196,7 +131,4 @@ func (m *minerHarness) SetUpNoChain() { // Stop shuts down the miner. func (m *minerHarness) Stop() { require.NoError(m, m.TearDown(), "tear down miner failed") - - // Always keep logs for debugging, even for passing tests. - m.Logf("Miner logs available at: %s", m.logPath) } diff --git a/bwtest/perms.go b/bwtest/perms.go new file mode 100644 index 0000000000..3db2c9ffc7 --- /dev/null +++ b/bwtest/perms.go @@ -0,0 +1,7 @@ +package bwtest + +// logDirPerm is the default permission for harness-managed log directories. +// +// 0o750 keeps logs accessible to the current user/group while avoiding +// world-readable test artifacts that may contain sensitive runtime details. +const logDirPerm = 0o750 diff --git a/bwtest/utils.go b/bwtest/utils.go index baf7ede64c..7a5e0485a3 100644 --- a/bwtest/utils.go +++ b/bwtest/utils.go @@ -2,6 +2,7 @@ package bwtest import ( "fmt" + "os" "os/exec" "path/filepath" ) @@ -37,3 +38,13 @@ func GetBitcoindBinary() (string, error) { return path, nil } + +// ensureLogDir creates the log directory if it doesn't exist. +func ensureLogDir(dir string) error { + err := os.MkdirAll(dir, logDirPerm) + if err != nil { + return fmt.Errorf("mkdir log dir: %w", err) + } + + return nil +} diff --git a/bwtest/wallet_logging.go b/bwtest/wallet_logging.go new file mode 100644 index 0000000000..a623644402 --- /dev/null +++ b/bwtest/wallet_logging.go @@ -0,0 +1,78 @@ +package bwtest + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btclog" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" +) + +// walletLogFilePerm is intentionally more restrictive than logDirPerm because +// wallet logs may contain addresses, txids, and operational details that should +// not be readable by other users on a shared machine. +const walletLogFilePerm = 0o600 + +// setUpWalletLogging configures the btclog-based loggers used by btcwallet to +// write into the provided log file path. +// +// NOTE: This is package-global logger configuration. It should only be used in +// serial integration tests. +func setUpWalletLogging(t *testing.T, logPath string) func() { + t.Helper() + + // #nosec G304 -- logPath is created by the test harness. + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + walletLogFilePerm) + require.NoError(t, err, "unable to create wallet log file") + + backend := btclog.NewBackend(f) + + btwl := backend.Logger("BTWL") + amgr := backend.Logger("AMGR") + tmgr := backend.Logger("TMGR") + chio := backend.Logger("CHIO") + rpcl := backend.Logger("RPCC") + + level, _ := btclog.LevelFromString("debug") + btwl.SetLevel(level) + amgr.SetLevel(level) + tmgr.SetLevel(level) + chio.SetLevel(level) + rpcl.SetLevel(level) + + wallet.UseLogger(btwl) + waddrmgr.UseLogger(amgr) + wtxmgr.UseLogger(tmgr) + chain.UseLogger(chio) + rpcclient.UseLogger(rpcl) + + return func() { + _ = f.Sync() + _ = f.Close() + } +} + +// walletLogFileName returns the per-test wallet log filename. +func walletLogFileName(t *testing.T) string { + t.Helper() + + // Use the leaf subtest name to keep filenames short. + name := t.Name() + + parts := strings.Split(name, "/") + if len(parts) > 0 { + name = parts[len(parts)-1] + } + + name = sanitizeLogToken(name) + + return fmt.Sprintf("wallet-%s.log", name) +} From 84d2ad35ca26d1751bc1c657290eb963b8263538 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 7 Feb 2026 02:50:40 +0800 Subject: [PATCH 416/691] bwtest: add bitcoind backend --- bwtest/backend.go | 10 +- bwtest/bitcoind.go | 388 ++++++++++++++++++++++++++++++++++++++++ bwtest/logs_finalize.go | 77 ++++++++ bwtest/rlimit_other.go | 11 ++ bwtest/rlimit_unix.go | 83 +++++++++ 5 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 bwtest/bitcoind.go create mode 100644 bwtest/rlimit_other.go create mode 100644 bwtest/rlimit_unix.go diff --git a/bwtest/backend.go b/bwtest/backend.go index 8b1035b575..2428d676a0 100644 --- a/bwtest/backend.go +++ b/bwtest/backend.go @@ -54,7 +54,10 @@ func NewBackend(t *testing.T, backendType, logDir string) ChainBackend { case backendBtcd: return NewBtcdBackend(t, logDir) - case backendBitcoind, backendNeutrino: + case backendBitcoind: + return NewBitcoindBackend(t, logDir) + + case backendNeutrino: t.Fatalf("chain backend %q is not implemented yet", backendType) return nil @@ -73,7 +76,10 @@ func validateBackendType(t *testing.T, backendType string) { case backendBtcd: return - case backendBitcoind, backendNeutrino: + case backendBitcoind: + return + + case backendNeutrino: t.Fatalf("chain backend %q is not implemented yet", backendType) return diff --git a/bwtest/bitcoind.go b/bwtest/bitcoind.go new file mode 100644 index 0000000000..b949df838a --- /dev/null +++ b/bwtest/bitcoind.go @@ -0,0 +1,388 @@ +package bwtest + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/chain/port" + "github.com/stretchr/testify/require" +) + +const ( + // bitcoindRPCUser/bitcoindRPCPass are test-only credentials used by the + // chain client. They match the static rpcauth entry below. + bitcoindRPCUser = "weks" + bitcoindRPCPass = "weks" + + // bitcoindLogFilePerm protects daemon stdout/stderr logs written by the + // harness. + bitcoindLogFilePerm = 0o600 + + // bitcoindZMQReadDeadline bounds how long we wait on each ZMQ read. + bitcoindZMQReadDeadline = 5 * time.Second + // bitcoindMempoolPollingInterval controls fallback mempool polling cadence. + bitcoindMempoolPollingInterval = 100 * time.Millisecond + // bitcoindMaxConnections keeps descriptor requirements low in CI. + bitcoindMaxConnections = 16 + // bitcoindMaxMempoolMB reduces memory usage for short-lived test runs. + bitcoindMaxMempoolMB = 50 + + // bitcoindRPCAuthorization enables RPC access with user/pass without + // storing cleartext credentials in the datadir. + // + // Generated with: bitcoind -rpcauth=weks:weks. + bitcoindRPCAuthorization = "weks:469e9bb14ab2360f8e226efed5ca6f" + + "d$507c670e800a95284294edb5773b05544b" + + "220110063096c221be9933c82d38e1" +) + +var ( + errBitcoindNotSynced = errors.New("bitcoind not synced") +) + +// BitcoindBackend is a ChainBackend backed by a bitcoind process. +type BitcoindBackend struct { + // binary is the resolved bitcoind executable path. + binary string + + // cmd is the running bitcoind process. + cmd *exec.Cmd + + // logDir is the bitcoind data directory used by this backend instance. + logDir string + + // rpcPort is the HTTP-RPC port used by bitcoind. + rpcPort int + // p2pPort is the inbound/outbound p2p port used by bitcoind. + p2pPort int + + // zmqBlockHost publishes raw block notifications for chain clients. + zmqBlockHost string + // zmqTxHost publishes raw transaction notifications for chain clients. + zmqTxHost string + + // minerAddr is the shared miner peer address that bitcoind connects to. + minerAddr string + + // stdoutPath/stderrPath are harness-managed daemon log files. + stdoutPath string + stderrPath string + + // stdoutFile/stderrFile stay open for the lifetime of the daemon process. + stdoutFile *os.File + stderrFile *os.File + + // cmdCancel cancels the process context to unblock shutdown paths. + cmdCancel context.CancelFunc +} + +// NewBitcoindBackend creates a new BitcoindBackend. +// +// The backend writes its stdout/stderr into the passed logDir and uses ZMQ for +// block and transaction notifications. +func NewBitcoindBackend(t *testing.T, logDir string) *BitcoindBackend { + t.Helper() + + bitcoindBinary, err := GetBitcoindBinary() + require.NoError(t, err, "unable to find bitcoind binary") + + absLogDir, err := filepath.Abs(logDir) + require.NoError(t, err, "unable to get absolute bitcoind log dir") + + err = ensureLogDir(absLogDir) + require.NoError(t, err, "unable to create bitcoind log dir") + + // Reserve ports in a stable order so diagnostics are easier to read when a + // setup step fails and reports one of these endpoints. + zmqBlockPort := port.NextAvailablePort() + zmqTxPort := port.NextAvailablePort() + rpcPort := port.NextAvailablePort() + p2pPort := port.NextAvailablePort() + + zmqBlockHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqBlockPort) + zmqTxHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqTxPort) + + return &BitcoindBackend{ + binary: bitcoindBinary, + logDir: absLogDir, + rpcPort: rpcPort, + p2pPort: p2pPort, + zmqBlockHost: zmqBlockHost, + zmqTxHost: zmqTxHost, + stdoutPath: filepath.Join(absLogDir, "bitcoind.stdout.log"), + stderrPath: filepath.Join(absLogDir, "bitcoind.stderr.log"), + } +} + +// Name returns the identifier of the backend. +func (b *BitcoindBackend) Name() string { + return backendBitcoind +} + +// Start launches the backend daemon. +func (b *BitcoindBackend) Start() error { + // Startup sequence overview: + // 1. Validate harness wiring (miner address + process limits). + // 2. Build daemon arguments (regtest, rpc, zmq, resource limits). + // 3. Redirect stdout/stderr to harness-managed log files. + // 4. Start the daemon process and retain file handles. + // 5. Probe RPC until the node is responsive and chain-synced. + // + // If any step fails we return an error that points to the collected logs so + // CI failures can be diagnosed from artifacts. + if b.minerAddr == "" { + return fmt.Errorf("bitcoind: %w", errMissingMinerAddr) + } + + // Best-effort attempt to increase the file descriptor limit before starting + // bitcoind. This helps avoid startup failures on systems with a low default + // RLIMIT_NOFILE. + _ = raiseNoFileLimit() + + args := []string{ + // Core regtest + connectivity setup. + "-datadir=" + b.logDir, + "-regtest", + "-connect=" + b.minerAddr, + + // Enable wallet-required indexing and RPC auth. + "-txindex", + "-disablewallet", + "-rpcauth=" + bitcoindRPCAuthorization, + fmt.Sprintf("-rpcport=%d", b.rpcPort), + fmt.Sprintf("-port=%d", b.p2pPort), + + // Use ZMQ notifications (blocks + txs) for low-latency chain updates. + "-zmqpubrawblock=" + b.zmqBlockHost, + "-zmqpubrawtx=" + b.zmqTxHost, + "-blockfilterindex=1", + + // Reduce resource usage for test environments. + // + // NOTE: bitcoind performs a file descriptor sanity check on startup. + // Keeping the connection count low reduces the required number of file + // descriptors. + fmt.Sprintf("-maxconnections=%d", bitcoindMaxConnections), + fmt.Sprintf("-maxmempool=%d", bitcoindMaxMempoolMB), + } + + cmdCtx, cmdCancel := context.WithCancel(context.Background()) + b.cmdCancel = cmdCancel + + // #nosec G204 -- b.binary is looked up from PATH and args are controlled. + cmd := exec.CommandContext(cmdCtx, b.binary, args...) + + // #nosec G304 -- b.stdoutPath is created by the test harness. + stdout, err := os.OpenFile( + b.stdoutPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, bitcoindLogFilePerm, + ) + if err != nil { + return fmt.Errorf("open bitcoind stdout log: %w", err) + } + + // #nosec G304 -- b.stderrPath is created by the test harness. + stderr, err := os.OpenFile( + b.stderrPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, bitcoindLogFilePerm, + ) + if err != nil { + _ = stdout.Close() + return fmt.Errorf("open bitcoind stderr log: %w", err) + } + + cmd.Stdout = stdout + cmd.Stderr = stderr + + err = cmd.Start() + if err != nil { + _ = stdout.Close() + _ = stderr.Close() + + return fmt.Errorf("start bitcoind: %w", err) + } + + // Keep handles alive for the duration of the process. + b.cmd = cmd + b.stdoutFile = stdout + b.stderrFile = stderr + + // Wait until bitcoind is ready to serve RPC calls. + // + // This readiness check also verifies that bitcoind has synced to the + // pre-mined harness chain height. + host := fmt.Sprintf("127.0.0.1:%d", b.rpcPort) + clientCfg := &rpcclient.ConnConfig{ + Host: host, + User: bitcoindRPCUser, + Pass: bitcoindRPCPass, + DisableAutoReconnect: false, + DisableConnectOnNew: true, + DisableTLS: true, + HTTPPostMode: true, + } + + err = wait.NoError(func() error { + // Construct a short-lived RPC client for readiness probing. + client, err := rpcclient.New(clientCfg, nil) + if err != nil { + return fmt.Errorf("create bitcoind rpc client: %w", err) + } + + defer func() { + client.Shutdown() + client.WaitForShutdown() + }() + + _, err = client.GetBlockChainInfo() + if err != nil { + return fmt.Errorf("get blockchain info: %w", err) + } + + count, err := client.GetBlockCount() + if err != nil { + return fmt.Errorf("get block count: %w", err) + } + + if count < int64(minMatureBlocks) { + return fmt.Errorf("%w (height=%d)", errBitcoindNotSynced, + count) + } + + return nil + }, defaultTestTimeout) + if err != nil { + _ = b.Stop() + + const errFmt = "bitcoind not ready: %w; logs: %s %s" + + return fmt.Errorf(errFmt, err, b.stdoutPath, b.stderrPath) + } + + return nil +} + +// Stop shuts down the backend daemon. +func (b *BitcoindBackend) Stop() error { + if b.cmdCancel != nil { + b.cmdCancel() + b.cmdCancel = nil + } + + if b.cmd != nil && b.cmd.Process != nil { + _ = b.cmd.Process.Kill() + _ = b.cmd.Wait() + } + + // Mark the process handle as stopped so repeated Stop calls are no-ops. + b.cmd = nil + + if b.stdoutFile != nil { + _ = b.stdoutFile.Close() + b.stdoutFile = nil + } + + if b.stderrFile != nil { + _ = b.stderrFile.Close() + b.stderrFile = nil + } + + return nil +} + +// ConnectMiner records the miner address for later use. +func (b *BitcoindBackend) ConnectMiner(minerAddr string) error { + b.minerAddr = minerAddr + + return nil +} + +// NewChainClient creates a new bitcoind-backed chain.Interface connected to +// this backend. +// +// For each subtest, we create a fresh BitcoindConn and client pair so test +// teardown can fully dispose chain resources without affecting other subtests. +// Startup order matters: +// 1. Construct BitcoindConn. +// 2. Start the connection so RPC + ZMQ subscriptions become active. +// 3. Construct and start the chain client that wallets will use. +// +// Cleanup runs in reverse order to avoid races between client shutdown and +// connection teardown. +func (b *BitcoindBackend) NewChainClient(ctx context.Context) (chain.Interface, + func(), error) { + + // Create a fresh chain connection for each subtest. + host := fmt.Sprintf("127.0.0.1:%d", b.rpcPort) + cfg := &chain.BitcoindConfig{ + ChainParams: harnessNetParams, + Host: host, + User: bitcoindRPCUser, + Pass: bitcoindRPCPass, + Dialer: nil, + PrunedModeMaxPeers: 0, + // ZMQ endpoints are passed in the same block/tx order used by the + // daemon startup flags above. + ZMQConfig: &chain.ZMQConfig{ + ZMQBlockHost: b.zmqBlockHost, + ZMQTxHost: b.zmqTxHost, + ZMQReadDeadline: bitcoindZMQReadDeadline, + MempoolPollingInterval: bitcoindMempoolPollingInterval, + }, + } + + var ( + conn *chain.BitcoindConn + err error + ) + + err = wait.NoError(func() error { + conn, err = chain.NewBitcoindConn(cfg) + if err != nil { + return fmt.Errorf("create bitcoind conn: %w", err) + } + + return nil + }, defaultTestTimeout) + if err != nil { + return nil, nil, fmt.Errorf("create bitcoind conn: %w", err) + } + + err = conn.Start() + if err != nil { + return nil, nil, fmt.Errorf("start bitcoind conn: %w", err) + } + + client, err := conn.NewBitcoindClient() + if err != nil { + conn.Stop() + return nil, nil, fmt.Errorf("create bitcoind client: %w", err) + } + + err = client.Start(ctx) + if err != nil { + conn.Stop() + return nil, nil, fmt.Errorf("start bitcoind client: %w", err) + } + + cleanup := func() { + client.Stop() + conn.Stop() + } + + return client, cleanup, nil +} + +// LogDir returns the directory where bitcoind wrote its logs for this run. +func (b *BitcoindBackend) LogDir() string { + return b.logDir +} + +var _ ChainBackend = (*BitcoindBackend)(nil) diff --git a/bwtest/logs_finalize.go b/bwtest/logs_finalize.go index 813056e15f..6cb6d455fe 100644 --- a/bwtest/logs_finalize.go +++ b/bwtest/logs_finalize.go @@ -57,11 +57,88 @@ func (h *HarnessTest) finalizeLogs() { h.Logf("failed to flatten btcd backend logs: %v", err) } + case backendBitcoind: + err = flattenBitcoindLogs(h.T, chainLogDir, chainDst) + if err != nil { + h.Logf("failed to flatten bitcoind backend logs: %v", err) + } + default: // No backend logs to flatten. } } +// flattenBitcoindLogs concatenates bitcoind logs under srcDir into dstFile. +func flattenBitcoindLogs(t *testing.T, srcDir, dstFile string) error { + t.Helper() + + // Capture process stdout/stderr first, as fatal startup errors might not be + // present in debug.log. + prelude := []string{ + filepath.Join(srcDir, "bitcoind.stderr.log"), + filepath.Join(srcDir, "bitcoind.stdout.log"), + } + + pattern := filepath.Join(srcDir, "*", "debug.log*") + + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("glob bitcoind logs: %w", err) + } + + files := make([]string, 0, len(prelude)+len(matches)) + files = append(files, prelude...) + files = append(files, matches...) + + files = filterRegularFiles(files) + if len(files) == 0 { + return nil + } + + // bitcoind rotates debug.log.1, debug.log.2 etc but we don't try too hard + // ordering here. + sort.Strings(files) + + // #nosec G304 -- dstFile is created by the test harness. + f, err := os.OpenFile(dstFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + logFilePerm) + if err != nil { + return fmt.Errorf("open dst log: %w", err) + } + + defer func() { + _ = f.Close() + }() + + for i, p := range files { + // Keep a blank line between concatenated source files to make the + // merged output easier to scan when debugging CI failures. + if i > 0 { + _, _ = f.WriteString("\n") + } + + base := filepath.Base(p) + _, _ = f.WriteString("--- " + base + " ---\n") + + // #nosec G304 -- p is discovered under the harness-controlled log dir. + src, err := os.Open(p) + if err != nil { + return fmt.Errorf("open src log: %w", err) + } + + _, cpErr := io.Copy(f, src) + _ = src.Close() + + if cpErr != nil { + return fmt.Errorf("copy src log: %w", cpErr) + } + } + + _ = os.RemoveAll(srcDir) + + return nil +} + // flattenBtcdLogs concatenates btcd logs under srcDir into dstFile. func flattenBtcdLogs(t *testing.T, srcDir, dstFile string) error { t.Helper() diff --git a/bwtest/rlimit_other.go b/bwtest/rlimit_other.go new file mode 100644 index 0000000000..adc8986216 --- /dev/null +++ b/bwtest/rlimit_other.go @@ -0,0 +1,11 @@ +//go:build !(darwin || linux) + +package bwtest + +// raiseNoFileLimit attempts to increase the current process file descriptor +// limit. +// +// On platforms where this isn't supported, this is a no-op. +func raiseNoFileLimit() error { + return nil +} diff --git a/bwtest/rlimit_unix.go b/bwtest/rlimit_unix.go new file mode 100644 index 0000000000..ed0702ffea --- /dev/null +++ b/bwtest/rlimit_unix.go @@ -0,0 +1,83 @@ +//go:build darwin || linux + +package bwtest + +import ( + "fmt" + "syscall" +) + +const ( + // desiredNoFileLimit is the target soft descriptor limit for test runs that + // launch bitcoind. + desiredNoFileLimit = 4096 + + // assumedInfinityThreshold is the cutoff used to treat RLIMIT_NOFILE values + // as effectively infinite and normalize them to a finite limit. + assumedInfinityThreshold = 1 << 60 +) + +// raiseNoFileLimit attempts to increase the current process file descriptor +// limit. +// +// This is a best-effort helper intended for integration tests that launch +// external processes like bitcoind. Some systems have a low default soft limit, +// which can cause bitcoind to fail during startup. +// +// This helper is still needed because bitcoind validates RLIMIT_NOFILE on +// startup. Normalizing extreme/low values keeps CI environments predictable. +func raiseNoFileLimit() error { + var rlim syscall.Rlimit + + err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim) + if err != nil { + return fmt.Errorf("get rlimit: %w", err) + } + + newCur, ok := desiredNoFileCur(rlim) + if !ok { + return nil + } + + rlim.Cur = newCur + + err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rlim) + if err != nil { + return fmt.Errorf("set rlimit: %w", err) + } + + return nil +} + +// desiredNoFileCur computes the desired RLIMIT_NOFILE soft value and reports +// whether Setrlimit should be called. +func desiredNoFileCur(rlim syscall.Rlimit) (uint64, bool) { + // Some environments report RLIMIT_NOFILE as effectively infinite. Bitcoind + // fails fast if the value cannot be represented correctly. Normalizing this + // to a finite limit avoids startup failures. + if rlim.Cur >= assumedInfinityThreshold { + newCur := uint64(desiredNoFileLimit) + if rlim.Max > 0 && newCur > rlim.Max { + newCur = rlim.Max + } + + return newCur, true + } + + // Nothing to do if we're already above our desired limit. + if rlim.Cur >= desiredNoFileLimit { + return 0, false + } + + // Increase the soft limit, but don't exceed the hard limit. + newCur := uint64(desiredNoFileLimit) + if rlim.Max > 0 && newCur > rlim.Max { + newCur = rlim.Max + } + + if newCur <= rlim.Cur { + return 0, false + } + + return newCur, true +} From c2d3644b7af14bb89c6b265e5024426d66e407ea Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 7 Feb 2026 02:51:28 +0800 Subject: [PATCH 417/691] bwtest: add neutrino backend --- bwtest/backend.go | 26 ++-------- bwtest/neutrino.go | 119 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 23 deletions(-) create mode 100644 bwtest/neutrino.go diff --git a/bwtest/backend.go b/bwtest/backend.go index 2428d676a0..bc6667f5a4 100644 --- a/bwtest/backend.go +++ b/bwtest/backend.go @@ -58,32 +58,12 @@ func NewBackend(t *testing.T, backendType, logDir string) ChainBackend { return NewBitcoindBackend(t, logDir) case backendNeutrino: - t.Fatalf("chain backend %q is not implemented yet", backendType) - return nil + // Neutrino is an in-process backend and does not require a backend log + // directory. + return NewNeutrinoBackend(t, logDir) default: t.Fatalf("unknown chain backend %q", backendType) return nil } } - -// validateBackendType validates the chain backend identifier provided by test -// flags before backend construction starts. -func validateBackendType(t *testing.T, backendType string) { - t.Helper() - - switch backendType { - case backendBtcd: - return - - case backendBitcoind: - return - - case backendNeutrino: - t.Fatalf("chain backend %q is not implemented yet", backendType) - return - - default: - t.Fatalf("unknown chain backend %q", backendType) - } -} diff --git a/bwtest/neutrino.go b/bwtest/neutrino.go new file mode 100644 index 0000000000..5a81e24bdc --- /dev/null +++ b/bwtest/neutrino.go @@ -0,0 +1,119 @@ +package bwtest + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" // Register bdb walletdb driver. + "github.com/lightninglabs/neutrino" +) + +const neutrinoDBTimeout = 5 * time.Second + +// NeutrinoBackend is a ChainBackend that uses an in-process neutrino chain +// service connected to the shared miner. +type NeutrinoBackend struct { + minerAddr string +} + +// NewNeutrinoBackend creates a new NeutrinoBackend. +// +// Neutrino is an in-process backend and does not write process logs into the +// passed logDir. +func NewNeutrinoBackend(t *testing.T, _ string) *NeutrinoBackend { + t.Helper() + + return &NeutrinoBackend{} +} + +// Name returns the identifier of the backend. +func (n *NeutrinoBackend) Name() string { + return backendNeutrino +} + +// Start is a no-op for neutrino. +func (n *NeutrinoBackend) Start() error { + return nil +} + +// Stop is a no-op for neutrino. +func (n *NeutrinoBackend) Stop() error { + return nil +} + +// ConnectMiner records the miner address for later use. +func (n *NeutrinoBackend) ConnectMiner(minerAddr string) error { + n.minerAddr = minerAddr + return nil +} + +// NewChainClient creates a new neutrino-backed chain.Interface connected to +// the shared miner. +func (n *NeutrinoBackend) NewChainClient(ctx context.Context) (chain.Interface, + func(), error) { + + if n.minerAddr == "" { + return nil, nil, fmt.Errorf("neutrino: %w", errMissingMinerAddr) + } + + dataDir, err := os.MkdirTemp("", "btcwallet-neutrino-") + if err != nil { + return nil, nil, fmt.Errorf("create neutrino temp dir: %w", err) + } + + spvdb, err := walletdb.Create( + "bdb", filepath.Join(dataDir, "neutrino.db"), true, + neutrinoDBTimeout, false, + ) + if err != nil { + _ = os.RemoveAll(dataDir) + return nil, nil, fmt.Errorf("create neutrino db: %w", err) + } + + chainService, err := neutrino.NewChainService(neutrino.Config{ + DataDir: dataDir, + Database: spvdb, + ChainParams: *harnessNetParams, + ConnectPeers: []string{n.minerAddr}, + }) + if err != nil { + _ = spvdb.Close() + _ = os.RemoveAll(dataDir) + + return nil, nil, fmt.Errorf("create neutrino chain service: %w", err) + } + + client := chain.NewNeutrinoClient(harnessNetParams, chainService) + + err = client.Start(ctx) + if err != nil { + _ = spvdb.Close() + _ = os.RemoveAll(dataDir) + + return nil, nil, fmt.Errorf("start neutrino client: %w", err) + } + + cleanup := func() { + client.Stop() + client.WaitForShutdown() + + _ = spvdb.Close() + _ = os.RemoveAll(dataDir) + } + + return client, cleanup, nil +} + +// LogDir returns an empty string because neutrino has no backend daemon. +func (n *NeutrinoBackend) LogDir() string { + // Neutrino runs in-process, so there is no backend daemon log directory. + return "" +} + +var _ ChainBackend = (*NeutrinoBackend)(nil) From fe2df3c582be6d72e383e54a6405c5bf8ace1f94 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:14:42 +0800 Subject: [PATCH 418/691] bwtest: add wallet helper methods --- bwtest/harness_wallet.go | 120 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 bwtest/harness_wallet.go diff --git a/bwtest/harness_wallet.go b/bwtest/harness_wallet.go new file mode 100644 index 0000000000..f9c34fe980 --- /dev/null +++ b/bwtest/harness_wallet.go @@ -0,0 +1,120 @@ +package bwtest + +import ( + "context" + "strings" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet" + "github.com/stretchr/testify/require" +) + +const ( + // defaultPubPass is the standard public passphrase used by test wallets. + defaultPubPass = "public" + + // defaultPrivPass is the standard private passphrase used by test wallets. + defaultPrivPass = "private" + + // defaultWalletRecoveryWindow keeps enough look-ahead addresses for test + // cases that derive multiple addresses while scanning historical blocks. + defaultWalletRecoveryWindow = 20 + + // defaultWalletSyncRetryInterval controls how often wallet sync retries + // when the chain backend is temporarily unavailable during startup. + defaultWalletSyncRetryInterval = 500 * time.Millisecond +) + +// CreateEmptyWallet creates, starts, and registers a new wallet instance. +// +// This is intended for non-manager integration tests that want a ready-to-use +// wallet without repeating boilerplate. +func (h *HarnessTest) CreateEmptyWallet() *wallet.Wallet { + h.Helper() + + name := "itest-" + strings.ReplaceAll(h.Name(), "/", "_") + + cfg := wallet.Config{ + // Use the subtest-scoped DB and chain client prepared by the harness. + DB: h.WalletDB, + Chain: h.ChainClient, + + // Keep network and startup behavior deterministic across tests. + ChainParams: h.NetParams(), + RecoveryWindow: defaultWalletRecoveryWindow, + WalletSyncRetryInterval: defaultWalletSyncRetryInterval, + + // Use a unique wallet name per test to avoid collisions in logs. + Name: name, + PubPassphrase: []byte(defaultPubPass), + } + + params := wallet.CreateWalletParams{ + // Generate a fresh seed for each test wallet. + Mode: wallet.ModeGenSeed, + PubPassphrase: []byte(defaultPubPass), + PrivatePassphrase: []byte(defaultPrivPass), + + // Use an old birthday to ensure the wallet can discover historical + // blocks when used in tests that pre-mine chain state. + Birthday: time.Now().Add(-1 * time.Hour), + } + + manager := wallet.NewManager() + w, err := manager.Create(cfg, params) + require.NoError(h, err, "failed to create wallet") + + err = w.Start(h.Context()) + require.NoError(h, err, "failed to start wallet") + + h.Cleanup(func() { + // We use a background context here because the test context might be + // canceled by the time cleanup runs. + _ = w.Stop(context.Background()) + }) + + // Register the wallet so harness helpers can assert global invariants. + h.RegisterWallet(w) + + return w +} + +// CreateFundedWallet creates an empty wallet and funds it with 10 BTC. +// +// This is intended for future integration tests that need spendable funds. +func (h *HarnessTest) CreateFundedWallet() *wallet.Wallet { + h.Helper() + + w := h.CreateEmptyWallet() + + err := w.Unlock(h.Context(), wallet.UnlockRequest{ + Passphrase: []byte(defaultPrivPass), + }) + require.NoError(h, err, "failed to unlock wallet") + + addr, err := w.NewAddress( + h.Context(), waddrmgr.DefaultAccountName, + waddrmgr.WitnessPubKey, false, + ) + require.NoError(h, err, "failed to create address") + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(h, err, "failed to create pkscript") + + const tenBTC = 10 * btcutil.SatoshiPerBitcoin + + output := &wire.TxOut{Value: int64(tenBTC), PkScript: pkScript} + + // Use a minimal fee rate for regtest. + h.SendOutput(output, btcutil.Amount(1)) + + // Confirm and wait for sync. + h.MineBlocks(1) + h.AssertWalletSynced(w) + + return w +} From 4562549fdcdb62880872b0181699587ecd85ac07 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:14:59 +0800 Subject: [PATCH 419/691] docs: add bwtest and itest readmes --- bwtest/README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++ itest/README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 bwtest/README.md create mode 100644 itest/README.md diff --git a/bwtest/README.md b/bwtest/README.md new file mode 100644 index 0000000000..a4fb7e29a7 --- /dev/null +++ b/bwtest/README.md @@ -0,0 +1,60 @@ +# bwtest + +`bwtest` contains the integration test harness used by `itest`. + +## Overview + +The harness provides: + +- A shared miner (btcd) that produces blocks for all test cases. +- A configurable chain backend under test (`btcd`, `bitcoind`, `neutrino`). +- Per-subtest resources: + - A fresh `chain.Interface` instance. + - A fresh wallet database instance. +- Cleanup that keeps tests isolated: + - Stops wallets created by the test. + - Requires the miner mempool to be empty on success. + +## Logs + +Each test run creates a per-run log directory under `itest/test-logs`. + +- Backend logs are flattened into `miner.log` and `chain_backend.log`. +- Wallet logs are written per test case as `wallet-.log`. + +## Backends + +Chain backends are implemented in separate files: + +- `bwtest/btcd.go` +- `bwtest/bitcoind.go` +- `bwtest/neutrino.go` + +The `bitcoind` backend uses ZMQ for block/tx notifications. + +## Wallet Helpers + +`bwtest` includes convenience helpers for tests that do not want to directly +exercise the wallet manager: + +- `(*HarnessTest).CreateEmptyWallet` +- `(*HarnessTest).CreateFundedWallet` + +Example usage: + +```go +func testFoo(t *bwtest.HarnessTest) { + t.CreateEmptyWallet() + + // Now add tests that need a started wallet instance. +} + +func testBar(t *bwtest.HarnessTest) { + t.CreateFundedWallet() + + // Now add tests that need a wallet with spendable funds. +} +``` + +Manager-focused tests should continue to create wallets through the manager API +directly. diff --git a/itest/README.md b/itest/README.md new file mode 100644 index 0000000000..1c1e9e4adc --- /dev/null +++ b/itest/README.md @@ -0,0 +1,57 @@ +# itest + +`itest` contains end-to-end integration tests for `btcwallet` using the harness +in `bwtest`. + +## Running Tests + +Common invocations: + +```bash +make itest + +# Select a chain backend. +make itest chain=btcd +make itest chain=bitcoind + +# Select a wallet database backend. +make itest db=kvdb + +# Filter cases by regex. +make itest icase=manager +``` + +The `chain` and `db` variables are forwarded into the test binary as flags. + +## Test Case Naming + +Integration test case names must follow: + +``` +component action +``` + +For example: + +``` +manager create wallet +``` + +This is validated by `itest/main_test.go`. + +## Logs + +Each test run creates a per-run log directory under: + +`itest/test-logs/log---YYYYMMDD-HHMMSS/` + +The harness flattens backend logs into: + +- `miner.log` +- `chain_backend.log` + +Wallet logs are created per test case: + +- `wallet-.log` + +The log directory path is printed when it is created. From 3123ca7f4049af297ff837890b72b835c152ddbd Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 19:15:14 +0800 Subject: [PATCH 420/691] CI: add itest jobs and log artifacts --- .github/workflows/main.yml | 160 +++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 45b53ae614..3731ae6724 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,8 @@ env: BITCOIND_VERSION: '22.0' BITCOIND_IMAGE: 'lightninglabs/bitcoin-core' + BTCD_VERSION_LATEST: v0.25.0 + jobs: ######################## # Format, compileation and lint check @@ -213,6 +215,164 @@ jobs: format: golang parallel: true + ######################## + # Run bwtest integration tests against each supported chain backend. + # + # These jobs currently use kvdb only. Database expansion is planned via a + # separate matrix in a future change. + ######################## + itest-btcd: + name: itest btcd + runs-on: ubuntu-latest + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: add go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: install btcd ${{ env.BTCD_VERSION_LATEST }} + run: go install -v github.com/btcsuite/btcd@${{ env.BTCD_VERSION_LATEST }} + + - name: check btcd version + run: btcd --version + + # The btcd backend job runs btcd for both the shared miner and the chain + # backend under test. + - name: run itest (btcd, kvdb) + run: make itest chain=btcd db=kvdb + + - name: upload itest logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: itest-logs-btcd + path: itest/test-logs + retention-days: 5 + + ######################## + # Run bwtest integration tests with the neutrino backend. + # + # Job flow: + # - Install btcd `${{ env.BTCD_VERSION_LATEST }}`. + # - Use btcd as the shared miner for the suite. + # - Run `make itest chain=neutrino db=kvdb` so wallets use the in-process + # neutrino backend while blocks/peers come from btcd. + ######################## + itest-neutrino: + name: itest neutrino + runs-on: ubuntu-latest + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: add go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: install btcd ${{ env.BTCD_VERSION_LATEST }} + run: go install -v github.com/btcsuite/btcd@${{ env.BTCD_VERSION_LATEST }} + + - name: check btcd version + run: btcd --version + + # Neutrino runs in-process, but it still relies on the shared btcd miner + # for chain data and peer connectivity. + - name: run itest (neutrino, kvdb) + run: make itest chain=neutrino db=kvdb + + - name: upload itest logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: itest-logs-neutrino + path: itest/test-logs + retention-days: 5 + + ######################## + # Run bwtest integration tests with the bitcoind backend. + # + # Job flow: + # - Install btcd `${{ env.BTCD_VERSION_LATEST }}` as the shared miner. + # - Run a matrix over bitcoind versions `30` (latest) and `28` (older). + # - Extract each matrix binary from the docker image and run + # `make itest chain=bitcoind db=kvdb`. + ######################## + itest-bitcoind: + name: itest bitcoind (v${{ matrix.bitcoind_version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + bitcoind_version: ['30', '28'] + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: add go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: install btcd ${{ env.BTCD_VERSION_LATEST }} + run: go install -v github.com/btcsuite/btcd@${{ env.BTCD_VERSION_LATEST }} + + - name: check btcd version + run: btcd --version + + # For bitcoind backend tests, btcd remains the shared miner while the + # matrix covers both bitcoind v30 (latest) and v28 (older). + - name: extract bitcoind from docker image (${{ matrix.bitcoind_version }}) + run: |- + docker pull ${{ env.BITCOIND_IMAGE }}:${{ matrix.bitcoind_version }} + CONTAINER_ID=$(docker create ${{ env.BITCOIND_IMAGE }}:${{ matrix.bitcoind_version }}) + + # Different upstream image tags use slightly different install paths. + # Try common variants in order until one succeeds. + for BIN_PATH in \ + "/opt/bitcoin-${{ matrix.bitcoind_version }}/bin/bitcoind" \ + "/opt/bitcoin-${{ matrix.bitcoind_version }}.0/bin/bitcoind" \ + "/opt/bitcoin-${{ matrix.bitcoind_version }}.0.0/bin/bitcoind"; do + if sudo docker cp $CONTAINER_ID:${BIN_PATH} /usr/local/bin/bitcoind; then + break + fi + done + + docker rm $CONTAINER_ID + bitcoind --version + + - name: run itest (bitcoind, kvdb) + run: make itest chain=bitcoind db=kvdb + + - name: upload itest logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: itest-logs-bitcoind-${{ matrix.bitcoind_version }} + path: itest/test-logs + retention-days: 5 + ######################## # Complete parallel coverage uploads ######################## From 5a22be1a8857f1ae3f72a7005f18f75f767e7e5f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 12 Feb 2026 23:13:40 +0800 Subject: [PATCH 421/691] bwtest: use `waddrmgr.FastScryptOptions` --- bwtest/README.md | 7 +++++++ bwtest/harness_wallet.go | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/bwtest/README.md b/bwtest/README.md index a4fb7e29a7..880fb66083 100644 --- a/bwtest/README.md +++ b/bwtest/README.md @@ -58,3 +58,10 @@ func testBar(t *bwtest.HarnessTest) { Manager-focused tests should continue to create wallets through the manager API directly. + +## Fast Scrypt + +`bwtest` sets `waddrmgr.DefaultScryptOptions` to `waddrmgr.FastScryptOptions` via +an `init()` function. Any package that imports `bwtest` (including `itest`) +automatically benefits from faster key derivation, avoiding CPU exhaustion and +timeouts — especially when running with `-race`. diff --git a/bwtest/harness_wallet.go b/bwtest/harness_wallet.go index f9c34fe980..9245ac635c 100644 --- a/bwtest/harness_wallet.go +++ b/bwtest/harness_wallet.go @@ -118,3 +118,9 @@ func (h *HarnessTest) CreateFundedWallet() *wallet.Wallet { return w } + +func init() { + // Use fast scrypt options for tests to avoid CPU exhaustion and + // timeouts, especially when running with -race. + waddrmgr.DefaultScryptOptions = waddrmgr.FastScryptOptions +} From b97b4cb5f138135e142df67b3613f7b949d0f6f8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 12 Feb 2026 23:30:58 +0800 Subject: [PATCH 422/691] CI: remove unnecessary caching --- .github/workflows/main.yml | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3731ae6724..6c70739ec3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -134,17 +134,6 @@ jobs: - name: Clean up runner space uses: ./.github/actions/cleanup-space - - name: go cache - uses: actions/cache@v4 - with: - path: /home/runner/work/go - key: btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - restore-keys: | - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}- - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}- - btcwallet-${{ runner.os }}-go- - - name: setup go ${{ env.GO_VERSION }} uses: actions/setup-go@v5 with: @@ -167,7 +156,7 @@ jobs: # run integration tests (SQLite + Postgres) ######################## itest-db: - name: integration tests (${{ matrix.db }}, ${{ matrix.race && 'race' || 'cover' }}) + name: ${{ matrix.db }} itest (${{ matrix.race && 'race' || 'cover' }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -181,17 +170,6 @@ jobs: - name: Clean up runner space uses: ./.github/actions/cleanup-space - - name: go cache - uses: actions/cache@v4 - with: - path: /home/runner/work/go - key: btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - restore-keys: | - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}- - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}- - btcwallet-${{ runner.os }}-go- - - name: setup go ${{ env.GO_VERSION }} uses: actions/setup-go@v5 with: From ed590d412c43c49a5e219fef22d9dc690a162abb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:55:40 +0800 Subject: [PATCH 423/691] wallet: introduce db.Store interface This is a transitional abstraction while the wallet remains monolithic. --- wallet/internal/db/interface.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 858bdac669..c8ab13f4df 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -74,6 +74,28 @@ var ( ErrMaxAddressIndexReached = errors.New("max address index reached") ) +// Store defines the set of database operations used by the wallet. +// +// NOTE: Ideally each wallet component/manager should depend on a small, +// purpose-built interface (for example, the UtxoManager should only depend on +// UTXOStore). However, the wallet is still a monolithic struct and its managers +// are currently only separated by files, all implemented as methods on Wallet. +// Until we break the wallet into independent components, we use this monolithic +// Store abstraction as a transitional step. +// +// For this PR, Store only includes UTXOStore. Over time it is expected to grow +// to include WalletStore, AccountStore, AddressStore, and TxStore as those +// callers migrate to the new internal db interfaces. +// +// TODO(yy): Break down wallet managers into independent components. +// +// TODO(yy): Remove the linter ignore once Store grows beyond UTXOStore. +// +//nolint:iface // Transitional alias until Store grows beyond UTXOStore. +type Store interface { + UTXOStore +} + // WalletStore defines the methods for wallet-level operations. type WalletStore interface { // CreateWallet creates a new wallet in the database with the provided @@ -251,6 +273,8 @@ type TxStore interface { } // UTXOStore defines the database actions for managing the UTXO set. +// +//nolint:iface // Store is a transitional wrapper over UTXOStore. type UTXOStore interface { // GetUtxo retrieves a single unspent transaction output (UTXO) by its // outpoint. It returns a UtxoInfo struct containing the UTXO's details From cbf01740945901a00dfc923ce315d5b4f1885cbf Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:55:52 +0800 Subject: [PATCH 424/691] wallet: add kvdb Store skeleton Introduce kvdb-backed Store implementation scaffolding. --- wallet/internal/db/kvdb/driver.go | 28 ++++++++ wallet/internal/db/kvdb/utxo.go | 106 ++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 wallet/internal/db/kvdb/driver.go create mode 100644 wallet/internal/db/kvdb/utxo.go diff --git a/wallet/internal/db/kvdb/driver.go b/wallet/internal/db/kvdb/driver.go new file mode 100644 index 0000000000..0eb5b35eaf --- /dev/null +++ b/wallet/internal/db/kvdb/driver.go @@ -0,0 +1,28 @@ +package kvdb + +import ( + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" +) + +// Store is the kvdb (walletdb) implementation of the db.Store interface. +// +// NOTE: This is a partial implementation that will be expanded as the wallet +// UTXO manager migrates to the new db interfaces. +type Store struct { + db walletdb.DB + txStore wtxmgr.TxStore +} + +// A compile-time assertion to ensure that Store implements the db.Store +// interface. +var _ db.Store = (*Store)(nil) + +// NewStore creates a new kvdb-backed UTXO store. +func NewStore(dbConn walletdb.DB, txStore wtxmgr.TxStore) *Store { + return &Store{ + db: dbConn, + txStore: txStore, + } +} diff --git a/wallet/internal/db/kvdb/utxo.go b/wallet/internal/db/kvdb/utxo.go new file mode 100644 index 0000000000..52eb1912a5 --- /dev/null +++ b/wallet/internal/db/kvdb/utxo.go @@ -0,0 +1,106 @@ +// Package kvdb provides a walletdb (kvdb) backed implementation of the +// wallet/internal/db UTXO store interface. +package kvdb + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" +) + +var ( + // errNotImplemented is returned for unimplemented kvdb store methods. + errNotImplemented = errors.New("not implemented") + + // errMissingTxmgrNamespace is returned when the `wtxmgr` namespace bucket + // cannot be found in a walletdb transaction. + errMissingTxmgrNamespace = errors.New("missing wtxmgr namespace") + + // wtxmgrNamespaceKey is the walletdb top-level bucket key used by the + // transaction manager. + // + // NOTE: This must match the namespace used by the wallet package. + wtxmgrNamespaceKey = []byte("wtxmgr") +) + +func notImplemented(_ context.Context, method string) error { + return fmt.Errorf("kvdb.Store.%s: %w", method, errNotImplemented) +} + +// GetUtxo is not yet implemented for kvdb. +func (s *Store) GetUtxo(ctx context.Context, + _ db.GetUtxoQuery) (*db.UtxoInfo, error) { + + return nil, notImplemented(ctx, "GetUtxo") +} + +// ListUTXOs is not yet implemented for kvdb. +func (s *Store) ListUTXOs(ctx context.Context, + _ db.ListUtxosQuery) ([]db.UtxoInfo, error) { + + return nil, notImplemented(ctx, "ListUTXOs") +} + +// LeaseOutput is not yet implemented for kvdb. +func (s *Store) LeaseOutput(ctx context.Context, + _ db.LeaseOutputParams) (*db.LeasedOutput, error) { + + return nil, notImplemented(ctx, "LeaseOutput") +} + +// ReleaseOutput releases a previously leased output. +// +// How it works: +// The method executes a single walletdb update transaction that deletes the +// lock record associated with the specified outpoint. +// +// Database Actions: +// - Performs exactly one write transaction (walletdb.Update). +// - Writes to the `wtxmgr` namespace. +// +// NOTE: The legacy kvdb backend only supports a single wallet instance, so the +// WalletID field is ignored. +func (s *Store) ReleaseOutput(_ context.Context, + params db.ReleaseOutputParams) error { + + lockID := wtxmgr.LockID(params.ID) + op := params.OutPoint + + err := walletdb.Update(s.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + if ns == nil { + return errMissingTxmgrNamespace + } + + err := s.txStore.UnlockOutput(ns, lockID, op) + if err != nil { + return fmt.Errorf("unlock output: %w", err) + } + + return nil + }) + if err != nil { + return fmt.Errorf("kvdb.Store.ReleaseOutput: %w", err) + } + + return nil +} + +// ListLeasedOutputs is not yet implemented for kvdb. +func (s *Store) ListLeasedOutputs(ctx context.Context, + _ uint32) ([]db.LeasedOutput, error) { + + return nil, notImplemented(ctx, "ListLeasedOutputs") +} + +// Balance is not yet implemented for kvdb. +func (s *Store) Balance(ctx context.Context, + _ db.BalanceParams) (btcutil.Amount, error) { + + return 0, notImplemented(ctx, "Balance") +} From 2f993bc39830a3f0a7694e5eae9f1c1a6693ad5c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:56:17 +0800 Subject: [PATCH 425/691] wallet: add Store field to Wallet The wallet now carries a monolithic db.Store for manager access and initialize the wallet's Store using the kvdb backend. --- wallet/deprecated.go | 2 ++ wallet/manager.go | 2 ++ wallet/wallet.go | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 9c9ef15fce..d0b84d0e7c 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -27,6 +27,7 @@ import ( "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/internal/prompt" "github.com/btcsuite/btcwallet/waddrmgr" + kvdb "github.com/btcsuite/btcwallet/wallet/internal/db/kvdb" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" @@ -6714,6 +6715,7 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, w := &Wallet{ addrStore: addrMgr, + store: kvdb.NewStore(db, txMgr), txStore: txMgr, walletDeprecated: deprecated, } diff --git a/wallet/manager.go b/wallet/manager.go index 0487e6d7ce..cc3a86b56f 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcwallet/waddrmgr" + kvdb "github.com/btcsuite/btcwallet/wallet/internal/db/kvdb" ) var ( @@ -294,6 +295,7 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { w := &Wallet{ cfg: cfg, addrStore: addrMgr, + store: kvdb.NewStore(cfg.DB, txMgr), txStore: txMgr, requestChan: make(chan any), lifetimeCtx: lifetimeCtx, diff --git a/wallet/wallet.go b/wallet/wallet.go index 8106b22b13..f9f02b5996 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -24,6 +24,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -340,6 +341,11 @@ type Wallet struct { // querying the wallet's transaction history and unspent outputs. txStore wtxmgr.TxStore + // store provides access to database operations used by wallet managers. + // + // TODO(yy): Migrate UTXO-related callers behind db.UTXOStore. + store db.Store + // NtfnServer handles the delivery of wallet-related events (e.g., new // transactions, block connections) to connected clients. // From d131183f0eeac629f19fad8caac3f12a28a9f063 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:56:06 +0800 Subject: [PATCH 426/691] wallet: add mockStore for db.Store Use a single Store mock in tests to match the monolithic wallet store. --- wallet/common_test.go | 5 +++ wallet/mock_test.go | 88 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/wallet/common_test.go b/wallet/common_test.go index 08b266a5d5..80cc5d2fcd 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -94,6 +94,7 @@ func setupTestDB(t *testing.T) (walletdb.DB, func()) { // mockWalletDeps holds the mocked dependencies for the Wallet. type mockWalletDeps struct { addrStore *mockAddrStore + store *mockStore txStore *mockTxStore syncer *mockChainSyncer chain *mockChain @@ -113,6 +114,7 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { t.Cleanup(cleanup) mockAddrStore := &mockAddrStore{} + mockStore := &mockStore{} mockTxStore := &mockTxStore{} mockSyncer := &mockChainSyncer{} mockChain := &mockChain{} @@ -125,6 +127,7 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { w := &Wallet{ addrStore: mockAddrStore, + store: mockStore, txStore: mockTxStore, sync: mockSyncer, state: newWalletState(mockSyncer), @@ -147,6 +150,7 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { deps := &mockWalletDeps{ addrStore: mockAddrStore, + store: mockStore, txStore: mockTxStore, syncer: mockSyncer, chain: mockChain, @@ -158,6 +162,7 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { t.Cleanup(func() { mockAddrStore.AssertExpectations(t) + mockStore.AssertExpectations(t) mockTxStore.AssertExpectations(t) mockSyncer.AssertExpectations(t) mockChain.AssertExpectations(t) diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 5eccf20462..d2de4873d2 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -2,9 +2,8 @@ // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. -// This file contains a mock implementation of the wtxmgr.TxStore interface. -// It is used in various tests to isolate wallet logic from the underlying -// database. +// This file contains mock implementations of wallet dependencies used in +// tests to isolate wallet logic from underlying storage backends. package wallet @@ -22,6 +21,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/neutrino" @@ -30,6 +30,88 @@ import ( "github.com/stretchr/testify/mock" ) +// mockStore is a mock implementation of the db.Store interface. +// +// It is used to unit test wallet UTXO manager public methods without +// exercising a real database backend. +type mockStore struct { + mock.Mock +} + +// A compile-time assertion to ensure that mockStore implements the db.Store +// interface. +var _ db.Store = (*mockStore)(nil) + +// GetUtxo implements the db.UTXOStore interface. +func (m *mockStore) GetUtxo(ctx context.Context, + query db.GetUtxoQuery) (*db.UtxoInfo, error) { + + args := m.Called(ctx, query) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*db.UtxoInfo), args.Error(1) +} + +// ListUTXOs implements the db.UTXOStore interface. +func (m *mockStore) ListUTXOs(ctx context.Context, + query db.ListUtxosQuery) ([]db.UtxoInfo, error) { + + args := m.Called(ctx, query) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).([]db.UtxoInfo), args.Error(1) +} + +// LeaseOutput implements the db.UTXOStore interface. +func (m *mockStore) LeaseOutput(ctx context.Context, + params db.LeaseOutputParams) (*db.LeasedOutput, error) { + + args := m.Called(ctx, params) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*db.LeasedOutput), args.Error(1) +} + +// ReleaseOutput implements the db.UTXOStore interface. +func (m *mockStore) ReleaseOutput(ctx context.Context, + params db.ReleaseOutputParams) error { + + args := m.Called(ctx, params) + return args.Error(0) +} + +// ListLeasedOutputs implements the db.UTXOStore interface. +func (m *mockStore) ListLeasedOutputs(ctx context.Context, + walletID uint32) ([]db.LeasedOutput, error) { + + args := m.Called(ctx, walletID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).([]db.LeasedOutput), args.Error(1) +} + +// Balance implements the db.UTXOStore interface. +func (m *mockStore) Balance(ctx context.Context, + params db.BalanceParams) (btcutil.Amount, error) { + + args := m.Called(ctx, params) + + amount, ok := args.Get(0).(btcutil.Amount) + if !ok { + return 0, args.Error(1) + } + + return amount, args.Error(1) +} + // mockTxStore is a mock implementation of the wtxmgr.TxStore interface. type mockTxStore struct { mock.Mock From 9a0b7167fe5efc9afe86f7c9d3a7685fdcd6b064 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:57:55 +0800 Subject: [PATCH 427/691] wallet: migrate ReleaseOutput behind Store Route ReleaseOutput through the wallet Store abstraction. --- wallet/utxo_manager.go | 51 ++++++++++--------------------------- wallet/utxo_manager_test.go | 11 +++++--- 2 files changed, 20 insertions(+), 42 deletions(-) diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index c09267a907..d9d66fa4f8 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -19,6 +19,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -450,41 +451,10 @@ func (w *Wallet) LeaseOutput(_ context.Context, id wtxmgr.LockID, } // ReleaseOutput unlocks a previously leased output, making it available for -// use. +// coin selection again. // -// This method allows a caller to manually release a lock on a UTXO before its -// expiration time. This is useful when a transaction-building process is -// aborted and the reserved inputs need to be returned to the pool of available -// UTXOs. -// -// How it works: -// The method delegates the unlocking operation to the underlying transaction -// store (`wtxmgr`), which removes the lock record for the specified outpoint. -// -// Logical Steps: -// 1. Initiate a read-write database transaction. -// 2. Call the `wtxmgr.UnlockOutput` method with the provided `LockID` and -// outpoint. -// 3. The `wtxmgr` verifies that the output is indeed locked by the same -// `LockID` before removing the lock. -// -// Database Actions: -// - This method performs a single read-write database transaction -// (`walletdb.Update`). -// - It deletes from the `wtxmgr` namespace to remove the output lock. -// -// Time Complexity: -// - The complexity is O(1) as it involves a direct lookup and delete in the -// database. -// -// TODO(yy): The current `wtxmgr.UnlockOutput` implementation does not validate -// that the `LockID` matches the one that currently holds the lock. This could -// allow any caller to unlock an output, which could be a potential security -// risk in a multi-user environment. The implementation should be improved to -// perform this check. -// -// NOTE: This is part of the UtxoManager interface implementation. -func (w *Wallet) ReleaseOutput(_ context.Context, id wtxmgr.LockID, +// The lock is released by delegating to the wallet's db.Store implementation. +func (w *Wallet) ReleaseOutput(ctx context.Context, id wtxmgr.LockID, op wire.OutPoint) error { err := w.state.validateStarted() @@ -492,10 +462,15 @@ func (w *Wallet) ReleaseOutput(_ context.Context, id wtxmgr.LockID, return err } - return walletdb.Update(w.cfg.DB, func(tx walletdb.ReadWriteTx) error { - txmgrNs := tx.ReadWriteBucket(wtxmgrNamespaceKey) - return w.txStore.UnlockOutput(txmgrNs, id, op) - }) + params := db.ReleaseOutputParams{ + // TODO(yy): When multi-wallet support lands, plumb wallet ID into db + // calls instead of hard-coding 0. + WalletID: 0, + ID: [32]byte(id), + OutPoint: op, + } + + return w.store.ReleaseOutput(ctx, params) } // ListLeasedOutputs returns a list of all currently leased outputs. diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index 1205c187de..1afa0b0f87 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -319,10 +320,12 @@ func TestReleaseOutput(t *testing.T) { Index: 0, } - // Mock the UnlockOutput method to return nil. - mocks.txStore.On("UnlockOutput", - mock.Anything, mock.Anything, utxo, - ).Return(nil) + // Mock the UTXOStore ReleaseOutput method to return nil. + mocks.store.On("ReleaseOutput", mock.Anything, db.ReleaseOutputParams{ + WalletID: 0, + ID: [32]byte{1}, + OutPoint: utxo, + }).Return(nil) // Now, try to release the output. leaseID := wtxmgr.LockID{1} From 142cbf4c41916451fd44ba4771f275f57244c0c8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:58:08 +0800 Subject: [PATCH 428/691] wallet: add kvdb Store tests Add unit tests for the kvdb-backed Store implementation. --- wallet/internal/db/kvdb/utils_test.go | 65 +++++++++++++++ wallet/internal/db/kvdb/utxo_test.go | 114 ++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 wallet/internal/db/kvdb/utils_test.go create mode 100644 wallet/internal/db/kvdb/utxo_test.go diff --git a/wallet/internal/db/kvdb/utils_test.go b/wallet/internal/db/kvdb/utils_test.go new file mode 100644 index 0000000000..e0eb35119b --- /dev/null +++ b/wallet/internal/db/kvdb/utils_test.go @@ -0,0 +1,65 @@ +package kvdb + +import ( + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" +) + +const defaultDBTimeout = 10 * time.Second + +// newTestDB creates a temporary bdb walletdb for kvdb store tests. +// +// It returns the opened database and a cleanup function that must be called +// after the test completes. +func newTestDB(t *testing.T) (walletdb.DB, func()) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "wallet.db") + + dbConn, err := walletdb.Create( + "bdb", dbPath, true, defaultDBTimeout, false, + ) + require.NoError(t, err) + + cleanup := func() { + _ = dbConn.Close() + } + + return dbConn, cleanup +} + +// newTxStore initializes and opens a wtxmgr store in the test database. +// +// NOTE: The kvdb Store under test expects the walletdb top-level bucket key +// `wtxmgrNamespaceKey` to exist and contain a valid wtxmgr store. +func newTxStore(t *testing.T, dbConn walletdb.DB) *wtxmgr.Store { + t.Helper() + + var txStore *wtxmgr.Store + + err := walletdb.Update(dbConn, func(tx walletdb.ReadWriteTx) error { + ns, err := tx.CreateTopLevelBucket(wtxmgrNamespaceKey) + if err != nil { + return err + } + + err = wtxmgr.Create(ns) + if err != nil { + return err + } + + txStore, err = wtxmgr.Open(ns, &chaincfg.RegressionNetParams) + + return err + }) + require.NoError(t, err) + + return txStore +} diff --git a/wallet/internal/db/kvdb/utxo_test.go b/wallet/internal/db/kvdb/utxo_test.go new file mode 100644 index 0000000000..fc2b099c13 --- /dev/null +++ b/wallet/internal/db/kvdb/utxo_test.go @@ -0,0 +1,114 @@ +package kvdb + +import ( + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" +) + +// TestReleaseOutputSuccess verifies that kvdb.Store.ReleaseOutput removes an +// existing output lease from the underlying wtxmgr store. +func TestReleaseOutputSuccess(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + txStore := newTxStore(t, dbConn) + store := NewStore(dbConn, txStore) + + lockID := wtxmgr.LockID{1} + op := wire.OutPoint{Hash: [32]byte{1}, Index: 0} + + // Arrange: Create a lease so there is something to release. + err := walletdb.Update(dbConn, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(wtxmgrNamespaceKey) + require.NotNil(t, ns) + + // Create a mock transaction to satisfy the "known output" check in + // wtxmgr. + txMsg := &wire.MsgTx{ + Version: 1, + TxOut: []*wire.TxOut{{ + Value: 1000, + PkScript: []byte{0x00}, // OP_0 + }}, + } + + rec, err := wtxmgr.NewTxRecordFromMsgTx(txMsg, time.Now()) + if err != nil { + return fmt.Errorf("create tx record: %w", err) + } + + // Insert the transaction as mined. + block := &wtxmgr.BlockMeta{ + Block: wtxmgr.Block{Height: 1}, + Time: time.Now(), + } + + err = txStore.InsertTx(ns, rec, block) + if err != nil { + return fmt.Errorf("insert tx: %w", err) + } + + // Add the output as a credit so wtxmgr knows about it. + err = txStore.AddCredit(ns, rec, block, 0, false) + if err != nil { + return fmt.Errorf("add credit: %w", err) + } + + // Use the inserted transaction's hash for the outpoint. + op.Hash = rec.Hash + + _, err = txStore.LockOutput(ns, lockID, op, time.Hour) + + return err + }) + require.NoError(t, err) + + // Act: Release the lease through the kvdb store implementation. + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: 1, + ID: [32]byte(lockID), + OutPoint: op, + }) + require.NoError(t, err) + + // Assert: The lock set is now empty. + err = walletdb.View(dbConn, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(wtxmgrNamespaceKey) + require.NotNil(t, ns) + + locked, err := txStore.ListLockedOutputs(ns) + require.NoError(t, err) + require.Empty(t, locked) + + return nil + }) + require.NoError(t, err) +} + +// TestReleaseOutputMissingNamespace verifies a helpful error is returned when +// the `wtxmgr` namespace bucket is not present. +func TestReleaseOutputMissingNamespace(t *testing.T) { + t.Parallel() + + dbConn, cleanup := newTestDB(t) + t.Cleanup(cleanup) + + store := NewStore(dbConn, nil) + + err := store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: 0, + ID: [32]byte{1}, + OutPoint: wire.OutPoint{Hash: [32]byte{1}, Index: 0}, + }) + require.Error(t, err) + require.ErrorIs(t, err, errMissingTxmgrNamespace) +} From 052c547c5bbd2ca31b706b3b88f2ccad0c8ee9dc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 6 Feb 2026 03:59:41 +0800 Subject: [PATCH 429/691] wallet: require Store initialized in benchmarks Benchmarks should not lazily initialize the wallet Store. --- wallet/benchmark_helpers_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 57245a2d64..e03526104c 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -310,6 +310,8 @@ func setupBenchmarkWallet(tb testing.TB, w.sync = newSyncer(w.cfg, w.addrStore, w.txStore, w) } + require.NotNil(tb, w.store) + // Initialize controller channels and timer. if w.requestChan == nil { w.requestChan = make(chan any) From 17f8f02afd4b8b8bbf5b3721f77e9261f0f089ff Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 16:36:49 -0300 Subject: [PATCH 430/691] docs: add XChaCha20-Poly1305 Encryption ADR --- .../adr/0007-xchacha20-poly1305-encryption.md | 99 +++++++++++++++++++ docs/developer/adr/README.md | 2 + 2 files changed, 101 insertions(+) create mode 100644 docs/developer/adr/0007-xchacha20-poly1305-encryption.md diff --git a/docs/developer/adr/0007-xchacha20-poly1305-encryption.md b/docs/developer/adr/0007-xchacha20-poly1305-encryption.md new file mode 100644 index 0000000000..3e64dcf051 --- /dev/null +++ b/docs/developer/adr/0007-xchacha20-poly1305-encryption.md @@ -0,0 +1,99 @@ +# ADR 0007: XChaCha20-Poly1305 Encryption + +## 1. Context + +As part of the broader effort to simplify wallet encryption to a single +passphrase model, we are revisiting the cryptographic primitives used to +protect sensitive data. Today, `btcwallet` relies on NaCl secretbox, based on +XSalsa20-Poly1305, to encrypt private key material. + +While XSalsa20-Poly1305 remains secure, XChaCha20-Poly1305 is now the +preferred successor in modern systems. It preserves the large nonce model +while offering better library support and alignment with current AEAD +standards. + +Key motivations for this change include: + +* Broad adoption of XChaCha20-Poly1305 in modern cryptographic systems +* Strong performance on systems without AES-NI, including mobile and embedded + environments +* Simpler and more uniform AEAD construction, reducing implementation risk +* Easier auditing due to reduced complexity +* Production-proven usage in the Lightning Network's encrypted transport + protocol ([BOLT + 8](https://github.com/lightning/bolts/blob/master/08-transport.md)), + demonstrating its suitability for Bitcoin-related projects + +Like XSalsa20, XChaCha20-Poly1305 uses a 192-bit nonce, which allows random +nonce generation without practical collision risk and matches our existing +nonce strategy. + +```mermaid +flowchart TD + A[User Passphrase] --> B[scrypt KDF] + B --> C[Master Key] + C --> D[XChaCha20-Poly1305] + D --> E[Encrypted Private Data] +``` + +## 2. Decision + +We will migrate the encryption primitive from NaCl secretbox, based on +XSalsa20-Poly1305, to **XChaCha20-Poly1305**. + +### Key aspects + +1. **Encryption primitive** + Use XChaCha20-Poly1305 from `golang.org/x/crypto/chacha20poly1305`, + specifically the XChaCha variant with 192-bit nonces. + +2. **Key hierarchy** + Preserve the existing structure: scrypt-based KDF, master key derivation, + and per-use encryption keys. + +3. **Data separation** + Continue storing public data in plaintext while encrypting private or + sensitive material only. + +4. **Migration path** + Encrypted data will be re-encrypted during the SQL migration process. + +## 3. Consequences + +### Pros + +* **Performance** + Strong performance in pure software environments, especially where AES-NI + is unavailable. + +* **Modern AEAD** + Aligns the codebase with current best practice for authenticated encryption. + +* **Lower implementation risk** + Fewer edge cases compared to AES-GCM or custom constructions. + +* **Auditability** + Smaller and clearer code paths improve reviewability and long-term + maintenance. + +* **Nonce safety** + The 192-bit nonce space allows safe random nonce generation and preserves + compatibility with existing designs. + +### Cons + +* **Migration cost** + All encrypted data must be re-encrypted during migration. + +* **Temporary dual support** + XSalsa20-Poly1305 must remain supported until migration is complete. + +* **Testing overhead** + Both encryption paths require validation during the transition period. + +* **Passphrase dependency** + Re-encryption is only possible when the user passphrase is available. + +## 4. Status + +Proposed. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index bdd3aa154b..a79ff876a6 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -12,3 +12,5 @@ ADRs serve as a historical log of important design choices, providing context fo - [ADR 0004: Targeted Rescan vs. Global Rewind](./0004-targeted-rescan-vs-rewind.md) - Introduces "Targeted Rescans" to replace global "Rewinds" for more efficient transaction discovery. - [ADR 0005: Explicit Rescan on Import](./0005-no-auto-rescan-on-import.md) - Disables automatic blockchain scanning during import operations, requiring explicit user initiation. - [ADR 0006: Wallet Transaction Manager SQL Schema](./0006-wtxmgr-sql-schema.md) - Defines the relational SQL schema for the Wallet Transaction Manager (`wtxmgr`) migration. +- [ADR 0007: XChaCha20-Poly1305 Encryption](./0007-xchacha20-poly1305-encryption.md) - Replaces XSalsa20-Poly1305 with XChaCha20-Poly1305 for encrypting private key material. +- [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) - Defines a modular integration test framework for chain and database backend permutations. From 3935a0e93daf8d2bb6ee71e8f28decbd4162d79c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 14:30:48 -0300 Subject: [PATCH 431/691] docs: add Single-Passphrase Encryption Model ADR --- .../adr/0009-single-passphrase-encryption.md | 75 +++++++++++++++++++ docs/developer/adr/README.md | 1 + 2 files changed, 76 insertions(+) create mode 100644 docs/developer/adr/0009-single-passphrase-encryption.md diff --git a/docs/developer/adr/0009-single-passphrase-encryption.md b/docs/developer/adr/0009-single-passphrase-encryption.md new file mode 100644 index 0000000000..4bc2431205 --- /dev/null +++ b/docs/developer/adr/0009-single-passphrase-encryption.md @@ -0,0 +1,75 @@ +# ADR 0009: Single-Passphrase Encryption Model + +## 1. Context + +`btcwallet` is migrating from a key-value database (kvdb) to a SQL-based +backend. This transition requires a clean, consistent encryption model that +works across SQLite and PostgreSQL. + +The legacy system used a dual-passphrase hierarchy with separate public and +private encryption keys. In practice, downstream systems often set the public +passphrase to a known constant to allow auto-start, which negated the privacy +benefits while keeping the complexity. This model also diverges from the +industry standard set by Bitcoin Core and libraries like BDK. + +The design notes that guided this change are captured in +[Single-Passphrase Encryption Design Notes](https://gist.github.com/yyforyongyu/edfeee0f84cf79851735bcc3e740a871). + +## 2. Decision + +We will adopt a **Single-Passphrase Model** that encrypts **private data only** +and leaves public data in plaintext. + +```mermaid +flowchart TD + A[User Passphrase] --> B[scrypt KDF] + B --> C[Master Key] + C --> D[Encrypt Private Data] + E[Public Data] --> F[(Database)] + D --> F +``` + +### Key points + +1. **One master encryption key** derived from the user passphrase +2. **Private data encrypted** (private keys, HD seeds, xprivs, scripts) +3. **Public data plaintext** (addresses, pubkeys, transactions, balances) +4. **Simplified hierarchy** by removing `CryptoPubKey` and `CryptoScriptKey` + +## 3. Consequences + +### Pros + +* **Simplicity**: Eliminates dual passphrase logic. +* **Performance**: Read-only operations no longer require decrypting public + data, reducing query overhead. +* **Interoperability**: Aligns with Bitcoin Core and BDK conventions. +* **UX clarity**: Users only need a single passphrase. + +### Cons + +* **Privacy at rest**: Transaction history, balances, and public keys are + exposed if the database is compromised, consistent with Bitcoin Core tradeoffs. + If stronger protection is required, users can rely on full disk encryption such + as LUKS. +* **Migration effort**: Existing wallets must be migrated away from the legacy + dual passphrase model. This work will be done along with the SQL backend migration. + +## 4. Threat Model and Security Boundaries + +This model protects private key material at rest when an attacker can read the +database but cannot guess the wallet passphrase. + +This model does not protect metadata privacy if the database contents are +exfiltrated. Addresses, balances, and transaction history remain visible by +design. + +Operational mitigations required for this model: + +* Full disk encryption for database files. +* Host hardening (least privilege, patching, endpoint protection). +* Encrypted backups with independent access controls and key management. + +## 5. Status + +Accepted. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index a79ff876a6..e3a3577485 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -14,3 +14,4 @@ ADRs serve as a historical log of important design choices, providing context fo - [ADR 0006: Wallet Transaction Manager SQL Schema](./0006-wtxmgr-sql-schema.md) - Defines the relational SQL schema for the Wallet Transaction Manager (`wtxmgr`) migration. - [ADR 0007: XChaCha20-Poly1305 Encryption](./0007-xchacha20-poly1305-encryption.md) - Replaces XSalsa20-Poly1305 with XChaCha20-Poly1305 for encrypting private key material. - [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) - Defines a modular integration test framework for chain and database backend permutations. +- [ADR 0009: Single-Passphrase Encryption Model](./0009-single-passphrase-encryption.md) - Adopts a single-passphrase model that encrypts private data only while keeping public wallet metadata in plaintext. From 232c8c436f2703fd8e7db3a9775992ac217bbd52 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 14:33:04 -0300 Subject: [PATCH 432/691] README: align with Single-Passphrase Encryption Model --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b7cb2c5c60..ff6dc97811 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,13 @@ disk. btcwallet uses the HD path for all derived addresses, as described by [BIP0044](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki). -Due to the sensitive nature of public data in a BIP0032 wallet, -btcwallet provides the option of encrypting not just private keys, but -public data as well. This is intended to thwart privacy risks where a -wallet file is compromised without exposing all current and future -addresses (public keys) managed by the wallet. While access to this -information would not allow an attacker to spend or steal coins, it -does mean they could track all transactions involving your addresses -and therefore know your exact balance. In a future release, public data -encryption will extend to transactions as well. +btcwallet encrypts all private key material (private keys, HD seeds) +at rest using a single passphrase. Public data such as addresses, +transactions, and balances is stored in plaintext, consistent with +Bitcoin Core conventions. Users who require stronger privacy at rest +should use full disk encryption (e.g. LUKS). See +[ADR 0009](docs/developer/adr/0009-single-passphrase-encryption.md) +for design rationale. btcwallet is not an SPV client and requires connecting to a local or remote btcd instance for asynchronous blockchain queries and From 5bee5541acaaf790263078bd1fcda20fa675ba1b Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 12 Feb 2026 18:47:48 -0300 Subject: [PATCH 433/691] wallet: rename db types to match LND naming --- wallet/internal/db/accounts_pg.go | 24 +++++++++---------- wallet/internal/db/accounts_sqlite.go | 24 +++++++++---------- wallet/internal/db/address_types_pg.go | 8 +++---- wallet/internal/db/address_types_sqlite.go | 8 +++---- wallet/internal/db/addresses_pg.go | 24 +++++++++---------- wallet/internal/db/addresses_sqlite.go | 24 +++++++++---------- wallet/internal/db/db_connectors_test.go | 16 ++++++------- wallet/internal/db/itest/pg_test.go | 6 ++--- wallet/internal/db/itest/sqlite_test.go | 6 ++--- wallet/internal/db/pg.go | 12 +++++----- wallet/internal/db/sqlite.go | 12 +++++----- wallet/internal/db/tx.go | 2 +- wallet/internal/db/wallet_pg.go | 28 +++++++++++----------- wallet/internal/db/wallet_sqlite.go | 28 +++++++++++----------- 14 files changed, 111 insertions(+), 111 deletions(-) diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index 3aad53c15b..4019822067 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -8,25 +8,25 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -// Ensure PostgresWalletDB satisfies the AccountStore interface. -var _ AccountStore = (*PostgresWalletDB)(nil) +// Ensure PostgresStore satisfies the AccountStore interface. +var _ AccountStore = (*PostgresStore)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. -func (w *PostgresWalletDB) GetAccount(ctx context.Context, +func (s *PostgresStore) GetAccount(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - getQueries := pgAccountGetQueries{q: w.queries} + getQueries := pgAccountGetQueries{q: s.queries} return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. -func (w *PostgresWalletDB) ListAccounts(ctx context.Context, +func (s *PostgresStore) ListAccounts(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - listQueries := pgAccountListQueries{q: w.queries} + listQueries := pgAccountListQueries{q: s.queries} return listAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, @@ -35,10 +35,10 @@ func (w *PostgresWalletDB) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. -func (w *PostgresWalletDB) RenameAccount(ctx context.Context, +func (s *PostgresStore) RenameAccount(ctx context.Context, params RenameAccountParams) error { - renameQueries := pgAccountRenameQueries{q: w.queries} + renameQueries := pgAccountRenameQueries{q: s.queries} return renameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, @@ -48,7 +48,7 @@ func (w *PostgresWalletDB) RenameAccount(ctx context.Context, // CreateDerivedAccount creates a new derived account with the given name and // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. -func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, +func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, params CreateDerivedAccountParams) (*AccountInfo, error) { paramsErr := params.validate() @@ -58,7 +58,7 @@ func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, var info *AccountInfo - err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { scopeID, err := pgEnsureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -117,12 +117,12 @@ func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. -func (w *PostgresWalletDB) CreateImportedAccount(ctx context.Context, +func (s *PostgresStore) CreateImportedAccount(ctx context.Context, params CreateImportedAccountParams) (*AccountProperties, error) { var props *AccountProperties - err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { var err error props, err = createImportedAccount( diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index a746a3c6f3..19ce3ad573 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -8,25 +8,25 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -// Ensure SQLiteWalletDB satisfies the AccountStore interface. -var _ AccountStore = (*SQLiteWalletDB)(nil) +// Ensure SqliteStore satisfies the AccountStore interface. +var _ AccountStore = (*SqliteStore)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. -func (w *SQLiteWalletDB) GetAccount(ctx context.Context, +func (s *SqliteStore) GetAccount(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - getQueries := sqliteAccountGetQueries{q: w.queries} + getQueries := sqliteAccountGetQueries{q: s.queries} return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. -func (w *SQLiteWalletDB) ListAccounts(ctx context.Context, +func (s *SqliteStore) ListAccounts(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - listQueries := sqliteAccountListQueries{q: w.queries} + listQueries := sqliteAccountListQueries{q: s.queries} return listAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, @@ -35,10 +35,10 @@ func (w *SQLiteWalletDB) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. -func (w *SQLiteWalletDB) RenameAccount(ctx context.Context, +func (s *SqliteStore) RenameAccount(ctx context.Context, params RenameAccountParams) error { - renameQueries := sqliteAccountRenameQueries{q: w.queries} + renameQueries := sqliteAccountRenameQueries{q: s.queries} return renameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, @@ -48,7 +48,7 @@ func (w *SQLiteWalletDB) RenameAccount(ctx context.Context, // CreateDerivedAccount creates a new derived account with the given name and // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. -func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, +func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, params CreateDerivedAccountParams) (*AccountInfo, error) { paramsErr := params.validate() @@ -58,7 +58,7 @@ func (w *SQLiteWalletDB) CreateDerivedAccount(ctx context.Context, var info *AccountInfo - err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { scopeID, err := sqliteEnsureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -137,12 +137,12 @@ func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. -func (w *SQLiteWalletDB) CreateImportedAccount(ctx context.Context, +func (s *SqliteStore) CreateImportedAccount(ctx context.Context, params CreateImportedAccountParams) (*AccountProperties, error) { var props *AccountProperties - err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { var err error props, err = createImportedAccount( diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/address_types_pg.go index 242c121a6e..52ee3d6111 100644 --- a/wallet/internal/db/address_types_pg.go +++ b/wallet/internal/db/address_types_pg.go @@ -22,21 +22,21 @@ func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. -func (w *PostgresWalletDB) ListAddressTypes(ctx context.Context) ( +func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { return listAddressTypes( - ctx, w.queries.ListAddressTypes, pgAddressTypeRowToInfo, + ctx, s.queries.ListAddressTypes, pgAddressTypeRowToInfo, ) } // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. -func (w *PostgresWalletDB) GetAddressType(ctx context.Context, +func (s *PostgresStore) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { return getAddressTypeByID( - ctx, w.queries.GetAddressTypeByID, int16(id), id, + ctx, s.queries.GetAddressTypeByID, int16(id), id, pgAddressTypeRowToInfo, ) } diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/address_types_sqlite.go index ceda868208..7120c86cde 100644 --- a/wallet/internal/db/address_types_sqlite.go +++ b/wallet/internal/db/address_types_sqlite.go @@ -24,21 +24,21 @@ func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. -func (w *SQLiteWalletDB) ListAddressTypes(ctx context.Context) ( +func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { return listAddressTypes( - ctx, w.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, + ctx, s.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, ) } // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. -func (w *SQLiteWalletDB) GetAddressType(ctx context.Context, +func (s *SqliteStore) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { return getAddressTypeByID( - ctx, w.queries.GetAddressTypeByID, int64(id), id, + ctx, s.queries.GetAddressTypeByID, int64(id), id, sqliteAddressTypeRowToInfo, ) } diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 78ca288a0c..98c05d19e5 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -11,14 +11,14 @@ import ( // GetAddress retrieves information about a specific address, identified by // its script pubkey. -func (w *PostgresWalletDB) GetAddress(ctx context.Context, +func (s *PostgresStore) GetAddress(ctx context.Context, query GetAddressQuery) (*AddressInfo, error) { getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, error) { return getAddress( - ctx, w.queries.GetAddressByScriptPubKey, + ctx, s.queries.GetAddressByScriptPubKey, sqlcpg.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, WalletID: int64(q.WalletID), @@ -31,11 +31,11 @@ func (w *PostgresWalletDB) GetAddress(ctx context.Context, // ListAddresses returns a slice of AddressInfo for all addresses in a given // account. -func (w *PostgresWalletDB) ListAddresses(ctx context.Context, +func (s *PostgresStore) ListAddresses(ctx context.Context, query ListAddressesQuery) ([]AddressInfo, error) { return listAddresses( - ctx, w.queries.ListAddressesByAccount, + ctx, s.queries.ListAddressesByAccount, sqlcpg.ListAddressesByAccountParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), @@ -46,17 +46,17 @@ func (w *PostgresWalletDB) ListAddresses(ctx context.Context, } // GetAddressSecret retrieves the encrypted secret information for an address. -func (w *PostgresWalletDB) GetAddressSecret(ctx context.Context, +func (s *PostgresStore) GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, error) { return getAddressSecret( - ctx, w.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, + ctx, s.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, ) } // NewDerivedAddress creates a new address for a given account and key // scope. -func (w *PostgresWalletDB) NewDerivedAddress(ctx context.Context, +func (s *PostgresStore) NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, deriveFn AddressDerivationFunc) (*AddressInfo, error) { @@ -65,7 +65,7 @@ func (w *PostgresWalletDB) NewDerivedAddress(ctx context.Context, sqlcpg.GetAccountByWalletScopeAndNameRow, accountLookupKey, sqlcpg.CreateDerivedAddressRow]{ - getAccount: pgGetAccountFromKey(w.queries), + getAccount: pgGetAccountFromKey(s.queries), accountParams: accountKeyFromParams, getAccountID: newDerivedAddressGetAccountIDPg, getExtIndex: newDerivedAddressGetExtIndexPg, @@ -75,11 +75,11 @@ func (w *PostgresWalletDB) NewDerivedAddress(ctx context.Context, rowCreatedAt: newDerivedAddressRowCreatedAtPg, } - return newDerivedAddressWithTx(ctx, params, w.ExecuteTx, adapters, deriveFn) + return newDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. -func (w *PostgresWalletDB) NewImportedAddress(ctx context.Context, +func (s *PostgresStore) NewImportedAddress(ctx context.Context, params NewImportedAddressParams) (*AddressInfo, error) { adapters := importedAddressAdapters[ @@ -89,7 +89,7 @@ func (w *PostgresWalletDB) NewImportedAddress(ctx context.Context, sqlcpg.CreateImportedAddressParams, sqlcpg.CreateImportedAddressRow, sqlcpg.InsertAddressSecretParams]{ - getAccount: pgGetAccountFromKey(w.queries), + getAccount: pgGetAccountFromKey(s.queries), accountParams: accountKeyFromImportedParams, getAccountID: newImportedAddressGetAccountIDPg, createAddr: pgCreateImportedAddress, @@ -100,7 +100,7 @@ func (w *PostgresWalletDB) NewImportedAddress(ctx context.Context, rowCreatedAt: importedAddressRowCreatedAtPg, } - return newImportedAddressWithTx(ctx, params, w.ExecuteTx, adapters) + return newImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // pgGetAccountFromKey returns a helper to look up accounts by key. diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index 7a36d12434..c5eb7d353b 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -10,14 +10,14 @@ import ( // GetAddress retrieves information about a specific address, identified by // its script pubkey. -func (w *SQLiteWalletDB) GetAddress(ctx context.Context, +func (s *SqliteStore) GetAddress(ctx context.Context, query GetAddressQuery) (*AddressInfo, error) { getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, error) { return getAddress( - ctx, w.queries.GetAddressByScriptPubKey, + ctx, s.queries.GetAddressByScriptPubKey, sqlcsqlite.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), ScriptPubKey: q.ScriptPubKey, @@ -30,11 +30,11 @@ func (w *SQLiteWalletDB) GetAddress(ctx context.Context, // ListAddresses returns a slice of AddressInfo for all addresses in a given // account. -func (w *SQLiteWalletDB) ListAddresses(ctx context.Context, +func (s *SqliteStore) ListAddresses(ctx context.Context, query ListAddressesQuery) ([]AddressInfo, error) { return listAddresses( - ctx, w.queries.ListAddressesByAccount, + ctx, s.queries.ListAddressesByAccount, sqlcsqlite.ListAddressesByAccountParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), @@ -45,18 +45,18 @@ func (w *SQLiteWalletDB) ListAddresses(ctx context.Context, } // GetAddressSecret retrieves the encrypted secret information for an address. -func (w *SQLiteWalletDB) GetAddressSecret(ctx context.Context, +func (s *SqliteStore) GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, error) { return getAddressSecret( - ctx, w.queries.GetAddressSecret, addressID, + ctx, s.queries.GetAddressSecret, addressID, sqliteAddressSecretRowToSecret, ) } // NewDerivedAddress creates a new address for a given account and key // scope. -func (w *SQLiteWalletDB) NewDerivedAddress(ctx context.Context, +func (s *SqliteStore) NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, deriveFn AddressDerivationFunc) (*AddressInfo, error) { @@ -65,7 +65,7 @@ func (w *SQLiteWalletDB) NewDerivedAddress(ctx context.Context, sqlcsqlite.GetAccountByWalletScopeAndNameRow, accountLookupKey, sqlcsqlite.CreateDerivedAddressRow]{ - getAccount: sqliteGetAccountFromKey(w.queries), + getAccount: sqliteGetAccountFromKey(s.queries), accountParams: accountKeyFromParams, getAccountID: newDerivedAddressGetAccountIDSQLite, getExtIndex: newDerivedAddressGetExtIndexSQLite, @@ -75,11 +75,11 @@ func (w *SQLiteWalletDB) NewDerivedAddress(ctx context.Context, rowCreatedAt: newDerivedAddressRowCreatedAtSQLite, } - return newDerivedAddressWithTx(ctx, params, w.ExecuteTx, adapters, deriveFn) + return newDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. -func (w *SQLiteWalletDB) NewImportedAddress(ctx context.Context, +func (s *SqliteStore) NewImportedAddress(ctx context.Context, params NewImportedAddressParams) (*AddressInfo, error) { adapters := importedAddressAdapters[ @@ -89,7 +89,7 @@ func (w *SQLiteWalletDB) NewImportedAddress(ctx context.Context, sqlcsqlite.CreateImportedAddressParams, sqlcsqlite.CreateImportedAddressRow, sqlcsqlite.InsertAddressSecretParams]{ - getAccount: sqliteGetAccountFromKey(w.queries), + getAccount: sqliteGetAccountFromKey(s.queries), accountParams: accountKeyFromImportedParams, getAccountID: newImportedAddressGetAccountIDSQLite, createAddr: sqliteCreateImportedAddress, @@ -100,7 +100,7 @@ func (w *SQLiteWalletDB) NewImportedAddress(ctx context.Context, rowCreatedAt: importedAddressRowCreatedAtSQLite, } - return newImportedAddressWithTx(ctx, params, w.ExecuteTx, adapters) + return newImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // sqliteGetAccountFromKey returns a helper to look up accounts by key. diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index 48a4bdd47e..be9bf61508 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -42,16 +42,16 @@ func newMockedTestDB(t *testing.T) *sql.DB { return db } -// TestNewPostgresWalletDB checks that the PostgresWalletDB constructor +// TestNewPostgresStore checks that the PostgresStore constructor // properly guards against nil *sql.DB inputs and wires up the queries // correctly. -func TestNewPostgresWalletDB(t *testing.T) { +func TestNewPostgresStore(t *testing.T) { t.Parallel() t.Run("nil db", func(t *testing.T) { t.Parallel() - db, err := NewPostgresWalletDB(nil) + db, err := NewPostgresStore(nil) require.ErrorIs(t, err, ErrNilDB) require.Nil(t, db) }) @@ -61,7 +61,7 @@ func TestNewPostgresWalletDB(t *testing.T) { sqlDB := newMockedTestDB(t) - db, err := NewPostgresWalletDB(sqlDB) + db, err := NewPostgresStore(sqlDB) require.NoError(t, err) require.NotNil(t, db) require.Equal(t, sqlDB, db.db) @@ -69,16 +69,16 @@ func TestNewPostgresWalletDB(t *testing.T) { }) } -// TestNewSQLiteWalletDB checks that the SQLiteWalletDB constructor +// TestNewSqliteStore checks that the SqliteStore constructor // properly guards against nil *sql.DB inputs and wires up the queries // correctly. -func TestNewSQLiteWalletDB(t *testing.T) { +func TestNewSqliteStore(t *testing.T) { t.Parallel() t.Run("nil db", func(t *testing.T) { t.Parallel() - db, err := NewSQLiteWalletDB(nil) + db, err := NewSqliteStore(nil) require.ErrorIs(t, err, ErrNilDB) require.Nil(t, db) }) @@ -88,7 +88,7 @@ func TestNewSQLiteWalletDB(t *testing.T) { sqlDB := newMockedTestDB(t) - db, err := NewSQLiteWalletDB(sqlDB) + db, err := NewSqliteStore(sqlDB) require.NoError(t, err) require.NotNil(t, db) require.Equal(t, sqlDB, db.db) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index ac892a4b56..f07188959d 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -190,14 +190,14 @@ func NewPostgresDB(t *testing.T) *sql.DB { // NewTestStoreWithDB creates a PostgreSQL wallet store and also returns the // raw sql.DB for fixture-level direct SQL setup. -func NewTestStoreWithDB(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries, +func NewTestStoreWithDB(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries, *sql.DB) { t.Helper() dbConn := NewPostgresDB(t) - store, err := db.NewPostgresWalletDB(dbConn) + store, err := db.NewPostgresStore(dbConn) require.NoError(t, err, "failed to create wallet store") queries := sqlcpg.New(dbConn) @@ -206,7 +206,7 @@ func NewTestStoreWithDB(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries, } // NewTestStore creates a PostgreSQL wallet store and returns it with queries. -func NewTestStore(t *testing.T) (*db.PostgresWalletDB, *sqlcpg.Queries) { +func NewTestStore(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries) { t.Helper() store, queries, _ := NewTestStoreWithDB(t) diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 975872fd13..d29bc21087 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -52,14 +52,14 @@ func NewSQLiteDB(t *testing.T) *sql.DB { // NewTestStoreWithDB creates a SQLite wallet store and also returns the raw // sql.DB for fixture-level direct SQL setup. -func NewTestStoreWithDB(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries, +func NewTestStoreWithDB(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries, *sql.DB) { t.Helper() dbConn := NewSQLiteDB(t) - store, err := db.NewSQLiteWalletDB(dbConn) + store, err := db.NewSqliteStore(dbConn) require.NoError(t, err, "failed to create wallet store") queries := sqlcsqlite.New(dbConn) @@ -68,7 +68,7 @@ func NewTestStoreWithDB(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries, } // NewTestStore creates the SQLite wallet store and returns it with queries. -func NewTestStore(t *testing.T) (*db.SQLiteWalletDB, *sqlcsqlite.Queries) { +func NewTestStore(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries) { t.Helper() store, queries, _ := NewTestStoreWithDB(t) diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go index 1de6f34f9c..72e87bc8aa 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg.go @@ -7,20 +7,20 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -// PostgresWalletDB is the PostgreSQL implementation of the +// PostgresStore is the PostgreSQL implementation of the // WalletStore interface. -type PostgresWalletDB struct { +type PostgresStore struct { db *sql.DB queries *sqlcpg.Queries } -// NewPostgresWalletDB creates a new PostgreSQL-based WalletStore. -func NewPostgresWalletDB(db *sql.DB) (*PostgresWalletDB, error) { +// NewPostgresStore creates a new PostgreSQL-based WalletStore. +func NewPostgresStore(db *sql.DB) (*PostgresStore, error) { if db == nil { return nil, ErrNilDB } - return &PostgresWalletDB{ + return &PostgresStore{ db: db, queries: sqlcpg.New(db), }, nil @@ -30,7 +30,7 @@ func NewPostgresWalletDB(db *sql.DB) (*PostgresWalletDB, error) { // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (w *PostgresWalletDB) ExecuteTx(ctx context.Context, +func (w *PostgresStore) ExecuteTx(ctx context.Context, fn func(*sqlcpg.Queries) error) error { return execInTx(ctx, w.db, func(tx *sql.Tx) error { diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index 15b8409f8a..48326de7b6 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -7,19 +7,19 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -// SQLiteWalletDB is the SQLite implementation of the WalletStore interface. -type SQLiteWalletDB struct { +// SqliteStore is the SQLite implementation of the WalletStore interface. +type SqliteStore struct { db *sql.DB queries *sqlcsqlite.Queries } -// NewSQLiteWalletDB creates a new SQLite-based WalletStore. -func NewSQLiteWalletDB(db *sql.DB) (*SQLiteWalletDB, error) { +// NewSqliteStore creates a new SQLite-based WalletStore. +func NewSqliteStore(db *sql.DB) (*SqliteStore, error) { if db == nil { return nil, ErrNilDB } - return &SQLiteWalletDB{ + return &SqliteStore{ db: db, queries: sqlcsqlite.New(db), }, nil @@ -29,7 +29,7 @@ func NewSQLiteWalletDB(db *sql.DB) (*SQLiteWalletDB, error) { // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (w *SQLiteWalletDB) ExecuteTx(ctx context.Context, +func (w *SqliteStore) ExecuteTx(ctx context.Context, fn func(*sqlcsqlite.Queries) error) error { return execInTx(ctx, w.db, func(tx *sql.Tx) error { diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go index da65c17deb..a79ac45f31 100644 --- a/wallet/internal/db/tx.go +++ b/wallet/internal/db/tx.go @@ -10,7 +10,7 @@ import ( // the transaction lifecycle: begin, commit, and rollback on error. // // This is a helper function used by the public ExecuteTx methods on -// PostgresWalletDB and SQLiteWalletDB. It guarantees that the transaction +// PostgresStore and SqliteStore. It guarantees that the transaction // will be either committed (on success) or rolled back (on error or panic). func execInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index f84a291ce1..38f1bb7ce2 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -9,18 +9,18 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -// Ensure PostgresWalletDB satisfies the WalletStore interface. -var _ WalletStore = (*PostgresWalletDB)(nil) +// Ensure PostgresStore satisfies the WalletStore interface. +var _ WalletStore = (*PostgresStore)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. -func (w *PostgresWalletDB) CreateWallet(ctx context.Context, +func (s *PostgresStore) CreateWallet(ctx context.Context, params CreateWalletParams) (*WalletInfo, error) { var info *WalletInfo - err := w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { walletParams := sqlcpg.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, @@ -111,10 +111,10 @@ func (w *PostgresWalletDB) CreateWallet(ctx context.Context, // GetWallet retrieves information about a wallet given its name. It // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. -func (w *PostgresWalletDB) GetWallet(ctx context.Context, +func (s *PostgresStore) GetWallet(ctx context.Context, name string) (*WalletInfo, error) { - row, err := w.queries.GetWalletByName(ctx, name) + row, err := s.queries.GetWalletByName(ctx, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("wallet %q: %w", name, @@ -143,10 +143,10 @@ func (w *PostgresWalletDB) GetWallet(ctx context.Context, // ListWallets returns a slice of WalletInfo for all wallets stored in // the database. It returns an empty slice if no wallets are found, or // an error if the retrieval fails. -func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, +func (s *PostgresStore) ListWallets(ctx context.Context) ([]WalletInfo, error) { - rows, err := w.queries.ListWallets(ctx) + rows, err := s.queries.ListWallets(ctx) if err != nil { return nil, fmt.Errorf("list wallets: %w", err) } @@ -181,10 +181,10 @@ func (w *PostgresWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, // birthday, birthday block, or sync state. The specific fields to // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. -func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, +func (s *PostgresStore) UpdateWallet(ctx context.Context, params UpdateWalletParams) error { - return w.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExistsPg(ctx, qtx, params.SyncedTo) @@ -227,10 +227,10 @@ func (w *PostgresWalletDB) UpdateWallet(ctx context.Context, // Deterministic (HD) seed of the wallet. This seed is sensitive // information and is returned in its encrypted form. It returns the // encrypted seed as a byte slice or an error if the retrieval fails. -func (w *PostgresWalletDB) GetEncryptedHDSeed(ctx context.Context, +func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { - secrets, err := w.queries.GetWalletSecrets(ctx, int64(walletID)) + secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("secrets for wallet %d: %w", @@ -250,7 +250,7 @@ func (w *PostgresWalletDB) GetEncryptedHDSeed(ctx context.Context, } // UpdateWalletSecrets updates the secrets for the wallet. -func (w *PostgresWalletDB) UpdateWalletSecrets(ctx context.Context, +func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, params UpdateWalletSecretsParams) error { secretsParams := sqlcpg.UpdateWalletSecretsParams{ @@ -261,7 +261,7 @@ func (w *PostgresWalletDB) UpdateWalletSecrets(ctx context.Context, WalletID: int64(params.WalletID), } - rowsAffected, err := w.queries.UpdateWalletSecrets(ctx, secretsParams) + rowsAffected, err := s.queries.UpdateWalletSecrets(ctx, secretsParams) if err != nil { return fmt.Errorf("update wallet secrets: %w", err) } diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index e7d7ff45ff..dae1a75308 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -9,18 +9,18 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -// Ensure SQLiteWalletDB satisfies the WalletStore interface. -var _ WalletStore = (*SQLiteWalletDB)(nil) +// Ensure SqliteStore satisfies the WalletStore interface. +var _ WalletStore = (*SqliteStore)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. -func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, +func (s *SqliteStore) CreateWallet(ctx context.Context, params CreateWalletParams) (*WalletInfo, error) { var info *WalletInfo - err := w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { walletParams := sqlcsqlite.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, @@ -111,10 +111,10 @@ func (w *SQLiteWalletDB) CreateWallet(ctx context.Context, // GetWallet retrieves information about a wallet given its name. It // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. -func (w *SQLiteWalletDB) GetWallet(ctx context.Context, +func (s *SqliteStore) GetWallet(ctx context.Context, name string) (*WalletInfo, error) { - row, err := w.queries.GetWalletByName(ctx, name) + row, err := s.queries.GetWalletByName(ctx, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("wallet %q: %w", name, @@ -143,10 +143,10 @@ func (w *SQLiteWalletDB) GetWallet(ctx context.Context, // ListWallets returns a slice of WalletInfo for all wallets stored in // the database. It returns an empty slice if no wallets are found, or // an error if the retrieval fails. -func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, +func (s *SqliteStore) ListWallets(ctx context.Context) ([]WalletInfo, error) { - rows, err := w.queries.ListWallets(ctx) + rows, err := s.queries.ListWallets(ctx) if err != nil { return nil, fmt.Errorf("list wallets: %w", err) } @@ -181,10 +181,10 @@ func (w *SQLiteWalletDB) ListWallets(ctx context.Context) ([]WalletInfo, // birthday, birthday block, or sync state. The specific fields to // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. -func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, +func (s *SqliteStore) UpdateWallet(ctx context.Context, params UpdateWalletParams) error { - return w.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExistsSqlite( @@ -226,10 +226,10 @@ func (w *SQLiteWalletDB) UpdateWallet(ctx context.Context, // Deterministic (HD) seed of the wallet. This seed is sensitive // information and is returned in its encrypted form. It returns the // encrypted seed as a byte slice or an error if the retrieval fails. -func (w *SQLiteWalletDB) GetEncryptedHDSeed(ctx context.Context, +func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { - secrets, err := w.queries.GetWalletSecrets(ctx, int64(walletID)) + secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("secrets for wallet %d: %w", @@ -249,7 +249,7 @@ func (w *SQLiteWalletDB) GetEncryptedHDSeed(ctx context.Context, } // UpdateWalletSecrets updates the secrets for the wallet. -func (w *SQLiteWalletDB) UpdateWalletSecrets(ctx context.Context, +func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, params UpdateWalletSecretsParams) error { secretsParams := sqlcsqlite.UpdateWalletSecretsParams{ @@ -260,7 +260,7 @@ func (w *SQLiteWalletDB) UpdateWalletSecrets(ctx context.Context, WalletID: int64(params.WalletID), } - rowsAffected, err := w.queries.UpdateWalletSecrets(ctx, secretsParams) + rowsAffected, err := s.queries.UpdateWalletSecrets(ctx, secretsParams) if err != nil { return fmt.Errorf("update wallet secrets: %w", err) } From bcc3f07e427945d74a4bb4404fabe1801a82e127 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 12 Feb 2026 18:52:49 -0300 Subject: [PATCH 434/691] wallet: add config structs for sqlite and postgres --- wallet/internal/db/config.go | 90 +++++++++++++++++ wallet/internal/db/config_test.go | 162 ++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 wallet/internal/db/config.go create mode 100644 wallet/internal/db/config_test.go diff --git a/wallet/internal/db/config.go b/wallet/internal/db/config.go new file mode 100644 index 0000000000..0df15b22b9 --- /dev/null +++ b/wallet/internal/db/config.go @@ -0,0 +1,90 @@ +package db + +import ( + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + // DefaultMaxConnections is the default maximum number of permitted + // connections (both active and idle) to the database. We want to limit + // this so it isn't unlimited. The same value is used for the maximum + // number of idle connections, which can improve performance by avoiding + // the overhead of establishing a new connection for each query. + DefaultMaxConnections = 25 + + // DefaultConnIdleLifetime is the default amount of time a connection + // can be idle before being closed. + DefaultConnIdleLifetime = 5 * time.Minute + + // DefaultConnectionTimeout is the default timeout for establishing + // a new database connection. + DefaultConnectionTimeout = 5 * time.Second +) + +var ( + // ErrEmptyDBPath is returned when an empty database path is provided. + ErrEmptyDBPath = errors.New("database path cannot be empty") + + // ErrNegativeMaxConns is returned when MaxConnections is negative. + ErrNegativeMaxConns = errors.New("max connections must be non-negative") + + // ErrEmptyDSN is returned when the DSN string is empty. + ErrEmptyDSN = errors.New("DSN is required") +) + +// SqliteConfig holds the configuration for the SQLite database. +type SqliteConfig struct { + // DBPath is the filesystem path to the SQLite database file. + DBPath string + + // MaxConnections is the maximum number of open connections to the + // database. Set to zero to use DefaultMaxConnections. + MaxConnections int +} + +// Validate checks that the SqliteConfig values are valid. +func (c *SqliteConfig) Validate() error { + if c.DBPath == "" { + return ErrEmptyDBPath + } + + if c.MaxConnections < 0 { + return ErrNegativeMaxConns + } + + return nil +} + +// PostgresConfig holds the configuration for the PostgreSQL database. +type PostgresConfig struct { + // Dsn is the database connection string. + Dsn string + + // MaxConnections is the maximum number of open connections to the + // database. Set to zero to use DefaultMaxConnections. + MaxConnections int +} + +// Validate checks that the PostgresConfig values are valid. +func (c *PostgresConfig) Validate() error { + if c.Dsn == "" { + return ErrEmptyDSN + } + + // Parse the DSN using pgx to ensure it's a valid PostgreSQL + // connection string. + _, err := pgx.ParseConfig(c.Dsn) + if err != nil { + return fmt.Errorf("invalid DSN: %w", err) + } + + if c.MaxConnections < 0 { + return ErrNegativeMaxConns + } + + return nil +} diff --git a/wallet/internal/db/config_test.go b/wallet/internal/db/config_test.go new file mode 100644 index 0000000000..142f8925a8 --- /dev/null +++ b/wallet/internal/db/config_test.go @@ -0,0 +1,162 @@ +package db + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSqliteConfigValidateSuccess tests valid SqliteConfig scenarios. +func TestSqliteConfigValidateSuccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config SqliteConfig + }{ + { + name: "valid config with zero max connections", + config: SqliteConfig{ + DBPath: "/tmp/test.db", + MaxConnections: 0, + }, + }, + { + name: "valid config with positive max connections", + config: SqliteConfig{ + DBPath: "/tmp/test.db", + MaxConnections: 10, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.NoError(t, err) + }) + } +} + +// TestSqliteConfigValidateErrors tests SqliteConfig validation errors. +func TestSqliteConfigValidateErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config SqliteConfig + expectedErr error + }{ + { + name: "empty DB path", + config: SqliteConfig{ + DBPath: "", + MaxConnections: 0, + }, + expectedErr: ErrEmptyDBPath, + }, + { + name: "negative max connections", + config: SqliteConfig{ + DBPath: "/tmp/test.db", + MaxConnections: -1, + }, + expectedErr: ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +// TestPostgresConfigValidateSuccess tests valid PostgresConfig scenarios. +func TestPostgresConfigValidateSuccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config PostgresConfig + }{ + { + name: "valid config with all fields set", + config: PostgresConfig{ + Dsn: "postgres://user:pass@localhost/db", + MaxConnections: 25, + }, + }, + { + name: "valid config with zero max connections", + config: PostgresConfig{ + Dsn: "postgres://localhost/db", + MaxConnections: 0, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.NoError(t, err) + }) + } +} + +// TestPostgresConfigValidateErrors tests PostgresConfig validation errors. +func TestPostgresConfigValidateErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config PostgresConfig + expectedErr error + expectAnyError bool + }{ + { + name: "empty DSN", + config: PostgresConfig{ + Dsn: "", + MaxConnections: 10, + }, + expectedErr: ErrEmptyDSN, + }, + { + name: "invalid DSN format", + config: PostgresConfig{ + Dsn: "://invalid", + MaxConnections: 10, + }, + expectAnyError: true, + }, + { + name: "negative max connections", + config: PostgresConfig{ + Dsn: "postgres://localhost/db", + MaxConnections: -5, + }, + expectedErr: ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + if tc.expectAnyError { + require.Error(t, err) + } else { + require.ErrorIs(t, err, tc.expectedErr) + } + }) + } +} From 0ada57ca315a17b14b5bf5367ae72f5e5f1b205b Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 16 Feb 2026 18:34:17 -0300 Subject: [PATCH 435/691] wallet: refactor db constructors --- wallet/internal/db/db_connectors_test.go | 179 +++++++++++------------ wallet/internal/db/db_itest.go | 15 ++ wallet/internal/db/itest/pg_test.go | 30 ++-- wallet/internal/db/itest/sqlite_test.go | 39 ++--- wallet/internal/db/pg.go | 64 +++++++- wallet/internal/db/sqlite.go | 70 ++++++++- 6 files changed, 244 insertions(+), 153 deletions(-) create mode 100644 wallet/internal/db/db_itest.go diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index be9bf61508..8c9ec46d55 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -1,116 +1,113 @@ package db import ( - "database/sql" - "database/sql/driver" - "sync" + "path/filepath" "testing" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -const testDriverName = "wallet-test-driver" - -var ( - registerDriverOnce sync.Once - testDriver *mockDriver -) - -// newMockedTestDB returns a *sql.DB backed by a mock driver. It avoids any -// network or disk usage, so it works well for constructor tests that only -// need a non nil database handle. It should be used only in very simple -// scenarios, since it does not implement real behavior and cannot confirm -// that the issued queries works as expected. -func newMockedTestDB(t *testing.T) *sql.DB { - t.Helper() - - registerDriverOnce.Do(func() { - testDriver = &mockDriver{} - testDriver.On("Open", mock.Anything).Return(mockConn{}, nil) - - sql.Register(testDriverName, testDriver) - }) - - db, err := sql.Open(testDriverName, "") - require.NoError(t, err) - - t.Cleanup(func() { - _ = db.Close() - }) +func TestNewPostgresStoreValidateConfig(t *testing.T) { + t.Parallel() - return db + tests := []struct { + name string + cfg PostgresConfig + wantErr error + }{ + { + name: "empty DSN", + cfg: PostgresConfig{ + Dsn: "", + }, + wantErr: ErrEmptyDSN, + }, + { + name: "negative max connections", + cfg: PostgresConfig{ + Dsn: "postgres://test", + MaxConnections: -1, + }, + wantErr: ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store, err := NewPostgresStore(t.Context(), tc.cfg) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, store) + }) + } } -// TestNewPostgresStore checks that the PostgresStore constructor -// properly guards against nil *sql.DB inputs and wires up the queries -// correctly. -func TestNewPostgresStore(t *testing.T) { +func TestNewPostgresStoreConnectionFailure(t *testing.T) { t.Parallel() - t.Run("nil db", func(t *testing.T) { - t.Parallel() - - db, err := NewPostgresStore(nil) - require.ErrorIs(t, err, ErrNilDB) - require.Nil(t, db) - }) + // Valid config, but hits a connection failure. + cfg := PostgresConfig{ + Dsn: "postgres://localhost:1/testdb", + } - t.Run("valid db", func(t *testing.T) { - t.Parallel() + store, err := NewPostgresStore(t.Context(), cfg) + require.Error(t, err) + require.ErrorContains(t, err, "ping database") + require.NotErrorIs(t, err, ErrEmptyDSN) + require.NotErrorIs(t, err, ErrNegativeMaxConns) - sqlDB := newMockedTestDB(t) - - db, err := NewPostgresStore(sqlDB) - require.NoError(t, err) - require.NotNil(t, db) - require.Equal(t, sqlDB, db.db) - require.NotNil(t, db.queries) - }) + // We are asserting nil here because it's not an integration test, so we + // are not able to create a postgres database and connect to it. + require.Nil(t, store) } -// TestNewSqliteStore checks that the SqliteStore constructor -// properly guards against nil *sql.DB inputs and wires up the queries -// correctly. -func TestNewSqliteStore(t *testing.T) { +func TestNewSqliteStoreValidateConfig(t *testing.T) { t.Parallel() - t.Run("nil db", func(t *testing.T) { - t.Parallel() - - db, err := NewSqliteStore(nil) - require.ErrorIs(t, err, ErrNilDB) - require.Nil(t, db) - }) - - t.Run("valid db", func(t *testing.T) { - t.Parallel() - - sqlDB := newMockedTestDB(t) - - db, err := NewSqliteStore(sqlDB) - require.NoError(t, err) - require.NotNil(t, db) - require.Equal(t, sqlDB, db.db) - require.NotNil(t, db.queries) - }) + tests := []struct { + name string + cfg SqliteConfig + wantErr error + }{ + { + name: "empty DB path", + cfg: SqliteConfig{ + DBPath: "", + }, + wantErr: ErrEmptyDBPath, + }, + { + name: "negative max connections", + cfg: SqliteConfig{ + DBPath: "/tmp/test.db", + MaxConnections: -1, + }, + wantErr: ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store, err := NewSqliteStore(t.Context(), tc.cfg) + require.ErrorIs(t, err, tc.wantErr) + require.Nil(t, store) + }) + } } -// mockDriver implements a bare-bones SQL driver so tests can obtain a *sql.DB -// without depending on an external database. -type mockDriver struct { - mock.Mock -} +func TestNewSqliteStoreSuccess(t *testing.T) { + t.Parallel() -func (m *mockDriver) Open(name string) (driver.Conn, error) { - args := m.Called(name) - conn, _ := args.Get(0).(driver.Conn) + cfg := SqliteConfig{ + DBPath: filepath.Join(t.TempDir(), "wallet.db"), + } - return conn, args.Error(1) -} + store, err := NewSqliteStore(t.Context(), cfg) + require.NoError(t, err) + require.NotNil(t, store) -// mockConn is a mock implementation of a database connection. It does not -// implement any real behavior. Used to be returned by the mockDriver. -type mockConn struct { - mock.Mock + require.NoError(t, store.Close()) } diff --git a/wallet/internal/db/db_itest.go b/wallet/internal/db/db_itest.go new file mode 100644 index 0000000000..0b04012482 --- /dev/null +++ b/wallet/internal/db/db_itest.go @@ -0,0 +1,15 @@ +//go:build itest + +package db + +import "database/sql" + +// DB returns the underlying *sql.DB connection for integration testing. +func (s *SqliteStore) DB() *sql.DB { + return s.db +} + +// DB returns the underlying *sql.DB connection for integration testing. +func (s *PostgresStore) DB() *sql.DB { + return s.db +} diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index f07188959d..6af26cc2e5 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -133,7 +133,7 @@ func sanitizedPgDBName(t *testing.T) string { // NewPostgresDB creates a new PostgreSQL database connection with migrations // applied. Each test gets its own database for isolation. -func NewPostgresDB(t *testing.T) *sql.DB { +func NewPostgresDB(t *testing.T) *db.PostgresStore { t.Helper() ctx := t.Context() @@ -170,22 +170,19 @@ func NewPostgresDB(t *testing.T) *sql.DB { // Build the connection string for the test database. testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) - // TODO(gustavostingelin): replace with the real PostgreSQL database - // connection constructor when available. - dbConn, err := sql.Open("pgx", testConnStr) - require.NoError(t, err, "failed to open test database connection") - require.NotNil(t, dbConn, "test database connection is nil") + cfg := db.PostgresConfig{ + Dsn: testConnStr, + MaxConnections: 0, + } + + store, err := db.NewPostgresStore(t.Context(), cfg) + require.NoError(t, err, "failed to create postgres store") - // Close the connection to avoid leaking an idle connection during tests. - // The container is reused across all tests, so we explicitly clean this up. t.Cleanup(func() { - _ = dbConn.Close() + _ = store.Close() }) - err = db.ApplyPostgresMigrations(dbConn) - require.NoError(t, err, "failed to apply migrations") - - return dbConn + return store } // NewTestStoreWithDB creates a PostgreSQL wallet store and also returns the @@ -195,11 +192,8 @@ func NewTestStoreWithDB(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries, t.Helper() - dbConn := NewPostgresDB(t) - - store, err := db.NewPostgresStore(dbConn) - require.NoError(t, err, "failed to create wallet store") - + store := NewPostgresDB(t) + dbConn := store.DB() queries := sqlcpg.New(dbConn) return store, queries, dbConn diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index d29bc21087..2a4d0724a1 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -15,39 +15,25 @@ import ( // NewSQLiteDB creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewSQLiteDB(t *testing.T) *sql.DB { +func NewSQLiteDB(t *testing.T) *db.SqliteStore { t.Helper() tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") - // Enable foreign keys (required for proper constraint enforcement). - dsn := dbPath + "?_pragma=foreign_keys=on" + cfg := db.SqliteConfig{ + DBPath: dbPath, + MaxConnections: 0, + } - // Enable WAL mode for better concurrency. WAL allows multiple readers and - // reduces lock contention for concurrent writers. - dsn = dsn + "&_pragma=journal_mode=WAL" - - // Enable immediate transaction locking to avoid races. - dsn = dsn + "&_txlock=immediate" - - // Set busy timeout to 5 seconds. This makes SQLite retry acquiring locks - // instead of immediately returning SQLITE_BUSY errors. - dsn = dsn + "&_pragma=busy_timeout=5000" - - // TODO(gustavostingelin): replace with the real SQLite database - // connection constructor when available. - dbConn, err := sql.Open("sqlite", dsn) - require.NoError(t, err, "failed to open sqlite database") - - err = db.ApplySQLiteMigrations(dbConn) - require.NoError(t, err, "failed to apply migrations") + store, err := db.NewSqliteStore(t.Context(), cfg) + require.NoError(t, err, "failed to create sqlite store") t.Cleanup(func() { - _ = dbConn.Close() + _ = store.Close() }) - return dbConn + return store } // NewTestStoreWithDB creates a SQLite wallet store and also returns the raw @@ -57,11 +43,8 @@ func NewTestStoreWithDB(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries, t.Helper() - dbConn := NewSQLiteDB(t) - - store, err := db.NewSqliteStore(dbConn) - require.NoError(t, err, "failed to create wallet store") - + store := NewSQLiteDB(t) + dbConn := store.DB() queries := sqlcsqlite.New(dbConn) return store, queries, dbConn diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go index 72e87bc8aa..60ac84fd3e 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg.go @@ -3,8 +3,10 @@ package db import ( "context" "database/sql" + "fmt" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) // PostgresStore is the PostgreSQL implementation of the @@ -14,27 +16,73 @@ type PostgresStore struct { queries *sqlcpg.Queries } -// NewPostgresStore creates a new PostgreSQL-based WalletStore. -func NewPostgresStore(db *sql.DB) (*PostgresStore, error) { - if db == nil { - return nil, ErrNilDB +// NewPostgresStore creates a new PostgreSQL-based WalletStore. It handles +// the full connection setup including config validation, connection opening, +// health checks, connection pool configuration, and migration application. +func NewPostgresStore(ctx context.Context, cfg PostgresConfig) (*PostgresStore, + error) { + + err := cfg.Validate() + if err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + db, err := sql.Open("pgx", cfg.Dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + connCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout) + defer cancel() + + err = db.PingContext(connCtx) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + maxConns := DefaultMaxConnections + if cfg.MaxConnections > 0 { + maxConns = cfg.MaxConnections + } + + db.SetMaxOpenConns(maxConns) + db.SetMaxIdleConns(maxConns) + db.SetConnMaxIdleTime(DefaultConnIdleLifetime) + + queries := sqlcpg.New(db) + + err = ApplyPostgresMigrations(db) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("apply migrations: %w", err) } return &PostgresStore{ db: db, - queries: sqlcpg.New(db), + queries: queries, }, nil } +// Close closes the database connection. +func (s *PostgresStore) Close() error { + err := s.db.Close() + if err != nil { + return fmt.Errorf("close database: %w", err) + } + + return nil +} + // ExecuteTx executes a function within a database transaction. The function // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (w *PostgresStore) ExecuteTx(ctx context.Context, +func (s *PostgresStore) ExecuteTx(ctx context.Context, fn func(*sqlcpg.Queries) error) error { - return execInTx(ctx, w.db, func(tx *sql.Tx) error { - qtx := w.queries.WithTx(tx) + return execInTx(ctx, s.db, func(tx *sql.Tx) error { + qtx := s.queries.WithTx(tx) return fn(qtx) }) } diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index 48326de7b6..b6af1a94d1 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -3,8 +3,10 @@ package db import ( "context" "database/sql" + "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) // SqliteStore is the SQLite implementation of the WalletStore interface. @@ -13,27 +15,79 @@ type SqliteStore struct { queries *sqlcsqlite.Queries } -// NewSqliteStore creates a new SQLite-based WalletStore. -func NewSqliteStore(db *sql.DB) (*SqliteStore, error) { - if db == nil { - return nil, ErrNilDB +// NewSqliteStore creates a new SQLite-based WalletStore. It handles the full +// connection setup including DSN construction with pragmas, connection +// opening, health checks, connection pool configuration, and migration +// application. +func NewSqliteStore(ctx context.Context, cfg SqliteConfig) (*SqliteStore, + error) { + + err := cfg.Validate() + if err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + dsn := cfg.DBPath + "?_pragma=foreign_keys=on" + dsn += "&_pragma=journal_mode=WAL" + dsn += "&_txlock=immediate" + dsn += "&_pragma=busy_timeout=5000" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + connCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout) + defer cancel() + + err = db.PingContext(connCtx) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + maxConns := DefaultMaxConnections + if cfg.MaxConnections > 0 { + maxConns = cfg.MaxConnections + } + + db.SetMaxOpenConns(maxConns) + db.SetMaxIdleConns(maxConns) + db.SetConnMaxIdleTime(DefaultConnIdleLifetime) + + queries := sqlcsqlite.New(db) + + err = ApplySQLiteMigrations(db) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("apply migrations: %w", err) } return &SqliteStore{ db: db, - queries: sqlcsqlite.New(db), + queries: queries, }, nil } +// Close closes the database connection. +func (s *SqliteStore) Close() error { + err := s.db.Close() + if err != nil { + return fmt.Errorf("close database: %w", err) + } + + return nil +} + // ExecuteTx executes a function within a database transaction. The function // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (w *SqliteStore) ExecuteTx(ctx context.Context, +func (s *SqliteStore) ExecuteTx(ctx context.Context, fn func(*sqlcsqlite.Queries) error) error { - return execInTx(ctx, w.db, func(tx *sql.Tx) error { - qtx := w.queries.WithTx(tx) + return execInTx(ctx, s.db, func(tx *sql.Tx) error { + qtx := s.queries.WithTx(tx) return fn(qtx) }) } From 24157211dd146321fb35b39b95404311b63e0d67 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 17 Feb 2026 14:31:42 -0300 Subject: [PATCH 436/691] wallet: add itest accessor avoiding return tuple --- wallet/internal/db/db_itest.go | 17 ++++++- .../internal/db/itest/account_store_test.go | 35 +++++++------- .../internal/db/itest/address_store_test.go | 48 +++++++++++-------- .../db/itest/address_types_store_test.go | 4 +- wallet/internal/db/itest/fixtures_pg_test.go | 15 ++++++ .../internal/db/itest/fixtures_sqlite_test.go | 15 ++++++ wallet/internal/db/itest/pg_test.go | 29 +---------- wallet/internal/db/itest/sqlite_test.go | 30 +----------- wallet/internal/db/itest/wallet_store_test.go | 32 +++++++------ 9 files changed, 114 insertions(+), 111 deletions(-) diff --git a/wallet/internal/db/db_itest.go b/wallet/internal/db/db_itest.go index 0b04012482..85ca82369f 100644 --- a/wallet/internal/db/db_itest.go +++ b/wallet/internal/db/db_itest.go @@ -2,14 +2,29 @@ package db -import "database/sql" +import ( + "database/sql" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) // DB returns the underlying *sql.DB connection for integration testing. func (s *SqliteStore) DB() *sql.DB { return s.db } +// Queries returns the underlying sqlc queries for integration testing. +func (s *SqliteStore) Queries() *sqlcsqlite.Queries { + return s.queries +} + // DB returns the underlying *sql.DB connection for integration testing. func (s *PostgresStore) DB() *sql.DB { return s.db } + +// Queries returns the underlying sqlc queries for integration testing. +func (s *PostgresStore) Queries() *sqlcpg.Queries { + return s.queries +} diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 02789da0b8..98b39be250 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -21,7 +21,7 @@ import ( // across all standard key scopes between multiple wallets. func TestCreateAccounts(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) // Create 3 wallets to ensure wallet_id scoping works. for i := range 3 { @@ -51,7 +51,7 @@ func TestCreateAccounts(t *testing.T) { func TestCreateDerivedAccountErrors(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-create-derived-account-errors") @@ -96,7 +96,7 @@ func TestCreateDerivedAccountErrors(t *testing.T) { func TestCreateDerivedAccountDuplicateName(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "duplicate-name-wallet") @@ -120,7 +120,7 @@ func TestCreateDerivedAccountDuplicateName(t *testing.T) { func TestCreateDerivedAccountSameNameDifferentScopes(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "multi-scope-wallet") @@ -151,7 +151,7 @@ func TestCreateDerivedAccountSameNameDifferentScopes(t *testing.T) { func TestCreateDerivedAccountSequentialNumbers(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "sequential-wallet") @@ -177,7 +177,7 @@ func TestCreateDerivedAccountSequentialNumbers(t *testing.T) { func TestCreateDerivedAccountConcurrent(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "concurrent-wallet") @@ -222,7 +222,7 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { func TestCreateImportedAccountErrors(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-create-imported-account-errors") @@ -279,7 +279,7 @@ func TestCreateImportedAccountErrors(t *testing.T) { func TestCreateImportedAccountDuplicateName(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "imported-duplicate-name-wallet") @@ -306,7 +306,7 @@ func TestCreateImportedAccountDuplicateName(t *testing.T) { func TestGetAccount(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-get-account") @@ -345,7 +345,7 @@ func TestGetAccount(t *testing.T) { func TestGetAccountNotFound(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-get-account-not-found") @@ -380,7 +380,7 @@ func TestListAccounts(t *testing.T) { // Ensure that has at least 3 accounts to be tested. require.GreaterOrEqual(t, len(AllAccountCases), 3) - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-list-accounts") @@ -454,7 +454,7 @@ func TestListAccounts(t *testing.T) { func TestListAccountsOrdering(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-list-ordering") @@ -493,7 +493,7 @@ func TestListAccountsOrdering(t *testing.T) { func TestAccountCreatedAtTimestamp(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-created-at") @@ -534,7 +534,7 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { func TestRenameAccount(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-rename-account") @@ -595,7 +595,7 @@ func TestRenameAccount(t *testing.T) { func TestRenameAccountErrors(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-rename-account-errors") @@ -667,11 +667,10 @@ func TestRenameAccountErrors(t *testing.T) { func TestCreateDerivedAccountMaxAccountNumber(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-max-account") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "account-0") - scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) - CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, "account-near-max") + setupMaxAccountNumberTest(t, store, walletID) // This should succeed with account_number = MaxUint32. info, err := store.CreateDerivedAccount( diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index c95a4dab09..8e437f5e8c 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -88,7 +88,8 @@ func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, func TestNewImportedAddress(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) + queries := store.Queries() walletID := newWallet(t, store, "wallet-imported-addresses") createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") @@ -231,7 +232,8 @@ func TestNewImportedAddress(t *testing.T) { func TestNewImportedAddressWithEncryptedScript(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) + queries := store.Queries() walletID := newWallet(t, store, "wallet-encrypted-script") createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") createImportedAccount(t, store, walletID, db.KeyScopeBIP0049Plus, "imported") @@ -339,7 +341,8 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { func TestImportedAddressCounterInsertDelete(t *testing.T) { t.Parallel() - store, _, dbConn := NewTestStoreWithDB(t) + store := NewTestStore(t) + dbConn := store.DB() walletID := newWallet(t, store, "wallet-imported-counter") createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") @@ -386,7 +389,8 @@ func TestImportedAddressCounterInsertDelete(t *testing.T) { func TestImportedAddressCounterConcurrentInsert(t *testing.T) { t.Parallel() - store, _, dbConn := NewTestStoreWithDB(t) + store := NewTestStore(t) + dbConn := store.DB() walletID := newWallet(t, store, "wallet-imported-counter-concurrent") createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") @@ -468,7 +472,7 @@ func TestImportedAddressCounterConcurrentInsert(t *testing.T) { func TestNewImportedAddressDuplicate(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-duplicate-import") createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") @@ -501,7 +505,7 @@ func TestNewImportedAddressDuplicate(t *testing.T) { func TestGetAddressSecret(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-secrets") createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") @@ -568,7 +572,7 @@ func TestGetAddressSecret(t *testing.T) { func TestGetAddress(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-get-address") tests := []struct { @@ -654,7 +658,7 @@ func TestGetAddress(t *testing.T) { func TestListAddresses(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-list-addresses") tests := []struct { @@ -772,7 +776,7 @@ func TestListAddresses(t *testing.T) { func TestNewDerivedAddress(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-derived") // Create account in BIP44 scope. @@ -824,7 +828,7 @@ func TestNewDerivedAddress(t *testing.T) { func TestNewImportedAddress_NonExistentImportedAccount(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "test-wallet") // Attempt to import address when "imported" account doesn't exist. @@ -851,7 +855,7 @@ func TestNewImportedAddress_NonExistentImportedAccount(t *testing.T) { func TestGetAddressSecret_DerivedAddress(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "test-wallet") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "test-account") @@ -879,7 +883,7 @@ func TestGetAddressSecret_DerivedAddress(t *testing.T) { func TestNewDerivedAddressSequentialIndexes(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-sequential-indexes") // Create derived account for the test. @@ -901,7 +905,7 @@ func TestNewDerivedAddressSequentialIndexes(t *testing.T) { func TestListAddressesOrdering(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-list-ordering") createDerivedAccount( @@ -962,7 +966,7 @@ func TestListAddressesOrdering(t *testing.T) { func TestNewDerivedAddressErrors(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-new-derived-address-errors") tests := []struct { @@ -1021,7 +1025,7 @@ func TestNewDerivedAddressErrors(t *testing.T) { func TestNewDerivedAddressConcurrent(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "concurrent-wallet") accountName := "concurrent-account" @@ -1075,7 +1079,7 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { func TestNewDerivedAddressBranchIsolation(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-branch-isolation") // Create derived account for the test. @@ -1119,7 +1123,7 @@ func TestNewDerivedAddressBranchIsolation(t *testing.T) { func TestNewDerivedAddressAccountKeyCounts(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-account-key-counts") accountName := "counted-account" @@ -1145,7 +1149,7 @@ func TestNewDerivedAddressAccountKeyCounts(t *testing.T) { func TestNewDerivedAddressBranchCounters(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) walletID := newWallet(t, store, "wallet-branch-counters") accountName := "branch-counter-account" @@ -1178,7 +1182,9 @@ func TestNewDerivedAddressBranchCounters(t *testing.T) { func TestNewDerivedAddressMaxIndex(t *testing.T) { t.Parallel() - store, queries, dbConn := NewTestStoreWithDB(t) + store := NewTestStore(t) + queries := store.Queries() + dbConn := store.DB() walletID := newWallet(t, store, "wallet-max-index") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") @@ -1216,7 +1222,9 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { t.Parallel() - store, queries, dbConn := NewTestStoreWithDB(t) + store := NewTestStore(t) + queries := store.Queries() + dbConn := store.DB() walletID := newWallet(t, store, "wallet-max-index-internal") createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "max-acct") diff --git a/wallet/internal/db/itest/address_types_store_test.go b/wallet/internal/db/itest/address_types_store_test.go index 16586a8224..fec40e59e6 100644 --- a/wallet/internal/db/itest/address_types_store_test.go +++ b/wallet/internal/db/itest/address_types_store_test.go @@ -12,7 +12,7 @@ import ( func TestListAddressTypes(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) types, err := store.ListAddressTypes(t.Context()) require.NoError(t, err) @@ -33,7 +33,7 @@ func TestListAddressTypes(t *testing.T) { func TestGetAddressType(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) tests := []struct { id db.AddressType diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 83b77b09a4..d496f98c55 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "fmt" + "math" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -182,3 +183,17 @@ func deleteAddress(ctx context.Context, dbConn *sql.DB, return nil } + +func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, + walletID uint32) { + + t.Helper() + + require.IsType(t, &db.PostgresStore{}, store) + + pgStore := store.(*db.PostgresStore) + queries := pgStore.Queries() + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, + "account-near-max") +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 034c30abe3..8e3a9d568f 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "fmt" + "math" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" @@ -182,3 +183,17 @@ func deleteAddress(ctx context.Context, dbConn *sql.DB, return nil } + +func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, + walletID uint32) { + + t.Helper() + + require.IsType(t, &db.SqliteStore{}, store) + + sqliteStore := store.(*db.SqliteStore) + queries := sqliteStore.Queries() + scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) + CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, + "account-near-max") +} diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 6af26cc2e5..d8384d806a 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -14,8 +14,6 @@ import ( "time" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" - _ "github.com/jackc/pgx/v5/stdlib" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" @@ -131,9 +129,9 @@ func sanitizedPgDBName(t *testing.T) string { return dbName } -// NewPostgresDB creates a new PostgreSQL database connection with migrations +// NewTestStore creates a new PostgreSQL database connection with migrations // applied. Each test gets its own database for isolation. -func NewPostgresDB(t *testing.T) *db.PostgresStore { +func NewTestStore(t *testing.T) *db.PostgresStore { t.Helper() ctx := t.Context() @@ -184,26 +182,3 @@ func NewPostgresDB(t *testing.T) *db.PostgresStore { return store } - -// NewTestStoreWithDB creates a PostgreSQL wallet store and also returns the -// raw sql.DB for fixture-level direct SQL setup. -func NewTestStoreWithDB(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries, - *sql.DB) { - - t.Helper() - - store := NewPostgresDB(t) - dbConn := store.DB() - queries := sqlcpg.New(dbConn) - - return store, queries, dbConn -} - -// NewTestStore creates a PostgreSQL wallet store and returns it with queries. -func NewTestStore(t *testing.T) (*db.PostgresStore, *sqlcpg.Queries) { - t.Helper() - - store, queries, _ := NewTestStoreWithDB(t) - - return store, queries -} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 2a4d0724a1..30bc4f0797 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -3,19 +3,16 @@ package itest import ( - "database/sql" "path/filepath" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" - _ "modernc.org/sqlite" ) -// NewSQLiteDB creates a new SQLite database for testing with migrations +// NewTestStore creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewSQLiteDB(t *testing.T) *db.SqliteStore { +func NewTestStore(t *testing.T) *db.SqliteStore { t.Helper() tmpDir := t.TempDir() @@ -35,26 +32,3 @@ func NewSQLiteDB(t *testing.T) *db.SqliteStore { return store } - -// NewTestStoreWithDB creates a SQLite wallet store and also returns the raw -// sql.DB for fixture-level direct SQL setup. -func NewTestStoreWithDB(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries, - *sql.DB) { - - t.Helper() - - store := NewSQLiteDB(t) - dbConn := store.DB() - queries := sqlcsqlite.New(dbConn) - - return store, queries, dbConn -} - -// NewTestStore creates the SQLite wallet store and returns it with queries. -func NewTestStore(t *testing.T) (*db.SqliteStore, *sqlcsqlite.Queries) { - t.Helper() - - store, queries, _ := NewTestStoreWithDB(t) - - return store, queries -} diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index 5408b66bc5..fcc098d9b1 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -15,7 +15,7 @@ import ( func TestCreateWallet(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("test-wallet") info, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) @@ -37,7 +37,7 @@ func TestCreateWallet(t *testing.T) { func TestCreateWallet_WithBirthday(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("birthday-wallet") birthday := time.Now().UTC().Add(-30 * 24 * time.Hour) @@ -56,7 +56,7 @@ func TestCreateWallet_WithBirthday(t *testing.T) { func TestCreateWallet_DuplicateName(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("duplicate-wallet") _, err := store.CreateWallet(t.Context(), params) @@ -100,7 +100,7 @@ func TestCreateWallet_Variants(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := tc.params(tc.name) info, err := store.CreateWallet(t.Context(), params) @@ -116,7 +116,7 @@ func TestCreateWallet_Variants(t *testing.T) { func TestGetWallet(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("get-test-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -138,7 +138,7 @@ func TestGetWallet(t *testing.T) { func TestGetWallet_NotFound(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) _, err := store.GetWallet(t.Context(), "non-existent-wallet") require.Error(t, err) @@ -149,7 +149,7 @@ func TestGetWallet_NotFound(t *testing.T) { func TestListWallets(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) // Initially empty. wallets, err := store.ListWallets(t.Context()) @@ -181,7 +181,8 @@ func TestListWallets(t *testing.T) { func TestUpdateWallet_SyncedTo(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) + queries := store.Queries() params := CreateWalletParamsFixture("update-sync-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -215,7 +216,8 @@ func TestUpdateWallet_SyncedTo(t *testing.T) { func TestUpdateWallet_BirthdayBlock(t *testing.T) { t.Parallel() - store, queries := NewTestStore(t) + store := NewTestStore(t) + queries := store.Queries() params := CreateWalletParamsFixture("update-birthday-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -255,7 +257,7 @@ func TestUpdateWallet_BirthdayBlock(t *testing.T) { func TestUpdateWallet_Birthday(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("birthday-timestamp-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -288,7 +290,7 @@ func TestUpdateWallet_Birthday(t *testing.T) { func TestUpdateWallet_NotFound(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) updateParams := db.UpdateWalletParams{ WalletID: 99999, // Non-existent ID. @@ -303,7 +305,7 @@ func TestUpdateWallet_NotFound(t *testing.T) { func TestGetEncryptedHDSeed(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("seed-wallet") expectedSeed := params.EncryptedMasterPrivKey @@ -321,7 +323,7 @@ func TestGetEncryptedHDSeed(t *testing.T) { func TestGetEncryptedHDSeed_WatchOnly(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWatchOnlyWalletParams("watch-only-seed") created, err := store.CreateWallet(t.Context(), params) @@ -337,7 +339,7 @@ func TestGetEncryptedHDSeed_WatchOnly(t *testing.T) { func TestUpdateWalletSecrets(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("secrets-wallet") created, err := store.CreateWallet(t.Context(), params) @@ -364,7 +366,7 @@ func TestUpdateWalletSecrets(t *testing.T) { func TestUpdateWallet_AutoBlockInsertion(t *testing.T) { t.Parallel() - store, _ := NewTestStore(t) + store := NewTestStore(t) params := CreateWalletParamsFixture("auto-block-wallet") created, err := store.CreateWallet(t.Context(), params) From dbe264fbaefa3aef2b7e43fc6d4cd954edb77b70 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 3 Mar 2026 16:07:25 -0300 Subject: [PATCH 437/691] wallet: add itest for store creation --- wallet/internal/db/itest/store_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 wallet/internal/db/itest/store_test.go diff --git a/wallet/internal/db/itest/store_test.go b/wallet/internal/db/itest/store_test.go new file mode 100644 index 0000000000..6417ede983 --- /dev/null +++ b/wallet/internal/db/itest/store_test.go @@ -0,0 +1,24 @@ +//go:build itest + +package itest + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewTestStore(t *testing.T) { + t.Parallel() + + // This test store exercises the underlying database connector in a test + // environment, so we can verify that the store is created successfully with + // a valid database connection and later properly closed. Will test all + // backends (SQLite, PostgreSQL) based in the build tags. + store := NewTestStore(t) + + require.NotNil(t, store) + require.NotNil(t, store.DB()) + require.NotNil(t, store.Queries()) + require.NoError(t, store.Close()) +} From 21bf56cfe330382eb080c69aa17bd11d5c8e9f03 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 6 Feb 2026 19:16:32 -0300 Subject: [PATCH 438/691] docs: add keyvault ADR --- .../adr/0010-keyvault-encryption-layer.md | 108 ++++++++++++++++++ docs/developer/adr/README.md | 1 + 2 files changed, 109 insertions(+) create mode 100644 docs/developer/adr/0010-keyvault-encryption-layer.md diff --git a/docs/developer/adr/0010-keyvault-encryption-layer.md b/docs/developer/adr/0010-keyvault-encryption-layer.md new file mode 100644 index 0000000000..0b7f48fd86 --- /dev/null +++ b/docs/developer/adr/0010-keyvault-encryption-layer.md @@ -0,0 +1,108 @@ +# ADR 0010: Keyvault Encryption Layer + +## 1. Context + +The updated encryption model defined in ADR 0009 and the planned +cryptographic primitive migration proposed in ADR 0007 require a clear +boundary between domain logic and the SQL database layer. The +legacy `waddrmgr` design tightly couples storage, locking, and key derivation, +which complicates the SQL migration and makes encryption behavior hard to test +in isolation. + +To address this, we need a dedicated component that owns lock state, key +derivation, and encryption, while keeping the database layer strictly +encryption agnostic. + +The `db.Store` remains available to other callers for non-cryptographic +queries and updates. + +## 2. Decision + +We will introduce a dedicated **`wallet/internal/keyvault`** package that +defines the encryption boundary between domain code and the SQL store layer. +Keyvault accesses the database through `db.Store`, not by talking directly +to the SQL backend. + +```mermaid +flowchart TD + A[Domain Code] -->|crypto ops| B[keyvault] + A -->|non-crypto ops| E[db.Store] + B -->|derive| C[btcutil/hdkeychain] + B -->|decrypt/encrypt| F[XChaCha20Poly1305
XSalsa20Poly1305] + B -->|read/write| E + B -->|read/write| H[(memory cache)] + E --> D[(SQL DB)] + E --> G[(kvdb)] + classDef store fill:#f8f9fb,stroke:#9aa0a6,stroke-width:1px + class G,D store +``` + +### Responsibilities + +1. **Own lock state and key lifecycle** + Centralized management of unlock state, key derivation, key material + lifetime, and secure memory zeroing. + +2. **Expose typed domain interfaces** + Methods return `*btcec.PrivateKey`, `*btcec.PublicKey`, and + `btcutil.Address` instead of encrypted `[]byte` values. + +3. **Handle HD derivation** + Use `btcutil/hdkeychain` for BIP32 and BIP44 derivation and return or + persist derived keys as needed. + +4. **Maintain an in-memory cache** + Cache account level and derived keys to avoid repeated derivation and + database reads. + +5. **Support multi-wallet operation** + A single keyvault instance manages multiple wallets via `wallet_id` + parameters. + +6. **Track current and planned cryptographic primitives** + Encryption follows the accepted single-passphrase model in ADR 0009 and + adopts ADR 0007 once the XChaCha20-Poly1305 migration is implemented. + +7. **Coexist with `waddrmgr` during migration** + New code uses keyvault while legacy code continues to rely on + `waddrmgr` until the migration is complete. + +## 3. Consequences + +### Pros + +* **Separation of concerns** + Database code stores opaque bytes without knowledge of cryptography or lock + state. + +* **Type safety** + Callers work with strongly typed keys rather than raw blobs. + +* **Centralized lock management** + Prevents lock state divergence across components. + +* **Performance** + Caching reduces repeated derivation and database access. + +* **Testability** + Keyvault can be tested against mock databases, and domain logic can be + tested using mock keyvault implementations. + +* **Memory safety** + Secure memory handling and zeroing are centralized in one place. + +### Cons + +* **Additional abstraction** + Introduces a new package boundary that must be maintained. + +* **Migration cost** + Existing code paths must be refactored to use the new keyvault API. + +* **Temporary dual systems** + `waddrmgr` and keyvault will coexist during the transition period, + increasing short-term complexity. + +## 4. Status + +Accepted. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index e3a3577485..3b569e8123 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -15,3 +15,4 @@ ADRs serve as a historical log of important design choices, providing context fo - [ADR 0007: XChaCha20-Poly1305 Encryption](./0007-xchacha20-poly1305-encryption.md) - Replaces XSalsa20-Poly1305 with XChaCha20-Poly1305 for encrypting private key material. - [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) - Defines a modular integration test framework for chain and database backend permutations. - [ADR 0009: Single-Passphrase Encryption Model](./0009-single-passphrase-encryption.md) - Adopts a single-passphrase model that encrypts private data only while keeping public wallet metadata in plaintext. +- [ADR 0010: Keyvault Encryption Layer](./0010-keyvault-encryption-layer.md) - Defines an in-memory keyvault boundary for lock state, key lifecycle, and encryption orchestration between domain logic and SQL persistence. From 408e07d24a476e6bc0b4b3b70f0c46feed534b4f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 9 Mar 2026 21:28:18 +0800 Subject: [PATCH 439/691] wallet: add runtime wallet ID accessor --- wallet/manager_test.go | 9 +++++++++ wallet/wallet.go | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/wallet/manager_test.go b/wallet/manager_test.go index 669a12d275..3b3a5c3e46 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -10,6 +10,14 @@ import ( "github.com/stretchr/testify/require" ) +func TestWalletID(t *testing.T) { + t.Parallel() + + w := &Wallet{id: 42} + + require.Equal(t, uint32(42), w.ID()) +} + // TestManagerCreateSuccess verifies that a wallet can be successfully created // in various modes. It checks that the Manager correctly initializes the // wallet structure and registers it for tracking. @@ -465,6 +473,7 @@ func TestManagerLoadError(t *testing.T) { require.Error(t, err) require.Nil(t, w) }) + } // TestManagerString verifies that the String representation of the Manager diff --git a/wallet/wallet.go b/wallet/wallet.go index f9f02b5996..d4d61e97bc 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -360,6 +360,9 @@ type Wallet struct { // wallet was created or loaded. cfg Config + // id is the persistent database identifier assigned to this wallet. + id uint32 + // sync is the dedicated synchronization component that manages the // chain loop, scanning, and reorganization handling. sync chainSyncer @@ -399,6 +402,11 @@ type Wallet struct { birthdayBlock waddrmgr.BlockStamp } +// ID returns the persistent database identifier for the wallet. +func (w *Wallet) ID() uint32 { + return w.id +} + // RemoveDescendants attempts to remove any transaction from the wallet's tx // store (that may be unconfirmed) that spends outputs created by the passed // transaction. This remove propagates recursively down the chain of descendent From 35b6b97521623d5c4a3d278df5dea438beb734aa Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 9 Mar 2026 21:28:38 +0800 Subject: [PATCH 440/691] wallet: plumb runtime wallet ID through load --- wallet/manager.go | 6 ++++++ wallet/manager_test.go | 2 ++ 2 files changed, 8 insertions(+) diff --git a/wallet/manager.go b/wallet/manager.go index cc3a86b56f..ddfbe13bd0 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -277,6 +277,11 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { return nil, err } + // TODO(yy): Once the Store implementation is finalized, load the wallet + // ID from store.GetWallet instead of using the legacy single-wallet + // default. + walletID := uint32(0) + // Apply the safe default for auto-lock duration if not specified. if cfg.AutoLockDuration == 0 { cfg.AutoLockDuration = defaultLockDuration @@ -294,6 +299,7 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { w := &Wallet{ cfg: cfg, + id: walletID, addrStore: addrMgr, store: kvdb.NewStore(cfg.DB, txMgr), txStore: txMgr, diff --git a/wallet/manager_test.go b/wallet/manager_test.go index 3b3a5c3e46..5817f96592 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -124,6 +124,7 @@ func TestManagerCreateSuccess(t *testing.T) { // without error. require.NoError(t, err) require.NotNil(t, w) + require.Zero(t, w.ID()) // Verify internal state: Ensure the manager is tracking the // newly created wallet in its internal map, keyed by the @@ -394,6 +395,7 @@ func TestManagerLoadSuccess(t *testing.T) { m2.RUnlock() require.True(t, ok) require.Same(t, w, loadedW) + require.Zero(t, w.ID()) } // TestManagerLoad_ExistingWallet verifies that if Load is called for a wallet From 183637bdaab2cdf6ee6d7171967b298a036d4881 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 9 Mar 2026 21:28:43 +0800 Subject: [PATCH 441/691] wallet: use runtime wallet ID in output release --- wallet/common_test.go | 11 +++++++++++ wallet/manager.go | 6 +++--- wallet/manager_test.go | 2 +- wallet/utxo_manager.go | 4 +--- wallet/utxo_manager_test.go | 4 ++-- wallet/wallet.go | 7 +++++-- 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/wallet/common_test.go b/wallet/common_test.go index 80cc5d2fcd..2e86cf8197 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -180,7 +180,18 @@ func createTestWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { func createStartedWalletWithMocks(t *testing.T) (*Wallet, *mockWalletDeps) { t.Helper() + return createStartedWalletWithID(t, 0) +} + +// createStartedWalletWithID creates a fully started Wallet instance whose +// runtime wallet ID is set before startup. +func createStartedWalletWithID(t *testing.T, walletID uint32) (*Wallet, + *mockWalletDeps) { + + t.Helper() + w, deps := createTestWalletWithMocks(t) + w.id = walletID // Mock the birthday block to be present. deps.addrStore.On("BirthdayBlock", mock.Anything). diff --git a/wallet/manager.go b/wallet/manager.go index ddfbe13bd0..20e23717a1 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -277,9 +277,9 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { return nil, err } - // TODO(yy): Once the Store implementation is finalized, load the wallet - // ID from store.GetWallet instead of using the legacy single-wallet - // default. + // TODO(yy): Once Wallet.store includes wallet metadata accessors such as + // WalletStore.GetWallet, load the runtime wallet ID from that store + // instead of using the legacy single-wallet default. walletID := uint32(0) // Apply the safe default for auto-lock duration if not specified. diff --git a/wallet/manager_test.go b/wallet/manager_test.go index 5817f96592..5c79eb894a 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" ) +// TestWalletID verifies that Wallet.ID returns the cached runtime ID. func TestWalletID(t *testing.T) { t.Parallel() @@ -475,7 +476,6 @@ func TestManagerLoadError(t *testing.T) { require.Error(t, err) require.Nil(t, w) }) - } // TestManagerString verifies that the String representation of the Manager diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index d9d66fa4f8..91b14f9a8e 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -463,9 +463,7 @@ func (w *Wallet) ReleaseOutput(ctx context.Context, id wtxmgr.LockID, } params := db.ReleaseOutputParams{ - // TODO(yy): When multi-wallet support lands, plumb wallet ID into db - // calls instead of hard-coding 0. - WalletID: 0, + WalletID: w.id, ID: [32]byte(id), OutPoint: op, } diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index 1afa0b0f87..fc96b7788c 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -312,7 +312,7 @@ func TestReleaseOutput(t *testing.T) { t.Parallel() // Create a new test wallet with mocks. - w, mocks := createStartedWalletWithMocks(t) + w, mocks := createStartedWalletWithID(t, 7) // Create a UTXO. utxo := wire.OutPoint{ @@ -322,7 +322,7 @@ func TestReleaseOutput(t *testing.T) { // Mock the UTXOStore ReleaseOutput method to return nil. mocks.store.On("ReleaseOutput", mock.Anything, db.ReleaseOutputParams{ - WalletID: 0, + WalletID: 7, ID: [32]byte{1}, OutPoint: utxo, }).Return(nil) diff --git a/wallet/wallet.go b/wallet/wallet.go index d4d61e97bc..adac110654 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -360,7 +360,10 @@ type Wallet struct { // wallet was created or loaded. cfg Config - // id is the persistent database identifier assigned to this wallet. + // id is the runtime wallet identifier used by wallet-scoped DB calls. + // + // NOTE: Until the wallet store is wired into the manager load path, this + // field may remain the legacy zero-value for single-wallet setups. id uint32 // sync is the dedicated synchronization component that manages the @@ -402,7 +405,7 @@ type Wallet struct { birthdayBlock waddrmgr.BlockStamp } -// ID returns the persistent database identifier for the wallet. +// ID returns the runtime wallet identifier for the wallet. func (w *Wallet) ID() uint32 { return w.id } From 6bdb56ceaccdff7bbeefcfaf69d96efc9a1fa571 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 13 Mar 2026 22:38:37 +0800 Subject: [PATCH 442/691] build: fix tidy-module script Tidy the root and each real nested module while continuing across modules and failing at the end if any tidy run errors. This prevents silent submodule failures and avoids collapsing nested modules under a single parent path. --- scripts/tidy_modules.sh | 61 ++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/scripts/tidy_modules.sh b/scripts/tidy_modules.sh index 3fa5bfb252..4dc7e0d738 100755 --- a/scripts/tidy_modules.sh +++ b/scripts/tidy_modules.sh @@ -1,17 +1,58 @@ #!/bin/bash -SUBMODULES=$(find . -mindepth 2 -name "go.mod" | cut -d'/' -f2) +# Keep unset-variable and pipeline safety, but do NOT use `set -e` here. +# +# We want two properties at the same time: +# 1. `go mod tidy` should run for the root module and every nested module, so we +# still auto-fix any module that *can* be tidied in the current run. +# 2. A failure in one module must still make the overall script fail so CI does +# not silently pass over a broken submodule. +# +# Using `set -e` would stop on the first failing module and skip the remaining +# tidies. Instead, we run each module explicitly, collect failures, and exit +# non-zero at the end if any module failed. +set -uo pipefail +# Collect module directories whose `go mod tidy` invocation failed. +failures=() -# Run 'go mod tidy' for root. -go mod tidy +run_tidy() { + local module_dir="$1" -# Run 'go mod tidy' for each module. -for submodule in $SUBMODULES -do - pushd $submodule + # Run each tidy in the target module directory so the command behaves as if a + # developer had entered that module and run `go mod tidy` manually. + echo "Running 'go mod tidy' in ${module_dir}" - go mod tidy + if ! ( + # Turn off any parent go.work file. We want to validate each module as an + # independent module, because CI and release consumers will resolve module + # dependencies that way. + cd "$module_dir" + GOWORK=off go mod tidy + ); then + # Keep going so other modules still get tidied, but remember the failure so + # the script can fail loudly once every module has been attempted. + failures+=("$module_dir") + fi +} - popd -done +# Tidy the repository root module first. +run_tidy "." + +# Tidy every actual nested Go module. +# +# We intentionally discover module directories from real `go.mod` paths instead +# of truncating the path (for example, `wallet/txauthor`, `wallet/txrules`, and +# `wallet/txsizes` are distinct modules and must be tidied separately). +while IFS= read -r submodule; do + run_tidy "$submodule" +done < <(find . -mindepth 2 -name "go.mod" -exec dirname {} \; | sort -u) + +# Fail at the end if any module failed to tidy. This preserves the previous +# “tidy as much as possible” behavior while making module errors visible to CI. +if [ ${#failures[@]} -ne 0 ]; then + echo + echo "go mod tidy failed for the following modules:" + printf ' - %s\n' "${failures[@]}" + exit 1 +fi From d658b4953487a2c1c4b1791fc59aecfdfa685db4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 11 Mar 2026 03:09:52 +0800 Subject: [PATCH 443/691] multi: fix wtxmgr module tidy check `make tidy-module` runs `go mod tidy` in the repository root and in each nested Go module, including `./wtxmgr`. Since `wtxmgr` has its own `go.mod`, it must be tidy-able as an independent module. Before this change, `wtxmgr/log.go` imported `github.com/btcsuite/btcwallet/build`, which lives in the root module rather than the `wtxmgr` module. That creates a cross-module dependency: when `go mod tidy` runs inside `./wtxmgr`, it tries to resolve `github.com/btcsuite/btcwallet/build` as if it were available from the published parent module, and the tidy step fails. The logger initialization did not actually require the root `build` package here. The previous code called `build.NewSubLogger("TMGR", nil)`, and with a nil sublogger factory that helper falls back to `btclog.Disabled`. Initializing `wtxmgr` directly with `btclog.Disabled` therefore preserves the same default behavior: logging remains disabled until a parent package installs a logger explicitly via `wtxmgr.UseLogger`. This keeps `wtxmgr` self-contained for module tidy/build checks while preserving the package's runtime logging behavior. --- go.mod | 2 +- wtxmgr/log.go | 20 ++++++-------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index b43104eabf..bf00351c98 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 + github.com/docker/go-connections v0.6.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang/protobuf v1.5.4 github.com/jackc/pgx/v5 v5.5.4 @@ -54,7 +55,6 @@ require ( github.com/decred/dcrd/lru v1.1.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.1+incompatible // indirect - github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.4 // indirect diff --git a/wtxmgr/log.go b/wtxmgr/log.go index 9a2f0adfe9..ecb5a87925 100644 --- a/wtxmgr/log.go +++ b/wtxmgr/log.go @@ -4,20 +4,12 @@ package wtxmgr -import ( - "github.com/btcsuite/btclog" - "github.com/btcsuite/btcwallet/build" -) - -// log is a logger that is initialized with no output filters. This -// means the package will not perform any logging by default until the caller -// requests it. -var log btclog.Logger - -// The default amount of logging is none. -func init() { - UseLogger(build.NewSubLogger("TMGR", nil)) -} +import "github.com/btcsuite/btclog" + +// log is a logger that is initialized as disabled (no output). This means +// the package will not perform any logging by default until the caller +// configures a logger via UseLogger or SetLogWriter. +var log = btclog.Disabled // DisableLog disables all library log output. Logging output is disabled // by default until either UseLogger or SetLogWriter are called. From 2f8cb495da0f1fc633e70a38fe3a8d8cca0bee2c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 11 Mar 2026 03:09:51 +0800 Subject: [PATCH 444/691] wallet: fix flaky account itest timestamps --- .../internal/db/itest/account_store_test.go | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 98b39be250..0f8be143c9 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -499,11 +499,18 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { scope := db.KeyScopeBIP0084 + type createdAccount struct { + info db.AccountInfo + createdNear time.Time + } + // Create three accounts with slight delays to ensure different // timestamps. - var accounts []db.AccountInfo + var accounts []createdAccount for i := range 3 { time.Sleep(1 * time.Second) + + createdNear := time.Now() params := db.CreateDerivedAccountParams{ WalletID: walletID, Scope: scope, @@ -511,21 +518,24 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { } info, err := store.CreateDerivedAccount(t.Context(), params) require.NoError(t, err) - accounts = append(accounts, *info) + accounts = append(accounts, createdAccount{ + info: *info, + createdNear: createdNear, + }) } // Verify all accounts have CreatedAt populated. for i, acc := range accounts { - require.False(t, acc.CreatedAt.IsZero(), + require.False(t, acc.info.CreatedAt.IsZero(), "account %d should have CreatedAt set", i) - require.WithinDuration(t, time.Now(), acc.CreatedAt, 5*time.Second, - "account %d CreatedAt should be recent", i) + require.WithinDuration(t, acc.createdNear, acc.info.CreatedAt, + 5*time.Second, "account %d CreatedAt should track creation", i) } // Verify accounts are ordered by creation time. - require.True(t, accounts[0].CreatedAt.Before(accounts[1].CreatedAt), + require.True(t, accounts[0].info.CreatedAt.Before(accounts[1].info.CreatedAt), "account 0 should have CreatedAt before account 1") - require.True(t, accounts[1].CreatedAt.Before(accounts[2].CreatedAt), + require.True(t, accounts[1].info.CreatedAt.Before(accounts[2].info.CreatedAt), "account 1 should have CreatedAt before account 2") } @@ -800,10 +810,13 @@ func requireAccountMatches(t *testing.T, info *db.AccountInfo, require.Equal(t, tc.Origin, info.Origin) require.Equal(t, tc.IsWatchOnly, info.IsWatchOnly) - // Verify CreatedAt is populated and recent. + // Verify CreatedAt is populated and not in the future. The account may have + // been created several seconds earlier in the test when parallel database + // setup runs under the race detector, so a strict "recent" assertion here is + // unnecessarily flaky. require.False(t, info.CreatedAt.IsZero(), "CreatedAt should be set") - require.WithinDuration(t, time.Now(), info.CreatedAt, 5*time.Second, - "CreatedAt should be recent") + require.False(t, info.CreatedAt.After(time.Now().Add(5*time.Second)), + "CreatedAt should not be in the future") } // requireAccountPropertiesMatches asserts that the provided AccountProperties @@ -819,10 +832,12 @@ func requireAccountPropertiesMatches(t *testing.T, props *db.AccountProperties, require.Equal(t, tc.Origin, props.Origin) require.Equal(t, tc.IsWatchOnly, props.IsWatchOnly) - // Verify CreatedAt is populated and recent. + // Verify CreatedAt is populated and not in the future. Imported-account test + // fixtures can be created well before these assertions run under heavy CI + // contention, so only the forward-time invariant is stable here. require.False(t, props.CreatedAt.IsZero(), "CreatedAt should be set") - require.WithinDuration(t, time.Now(), props.CreatedAt, 5*time.Second, - "CreatedAt should be recent") + require.False(t, props.CreatedAt.After(time.Now().Add(5*time.Second)), + "CreatedAt should not be in the future") } // findAccountInList searches for an account in the provided list that matches From 52e39d9fe73f56f49b5309ba5ebc411d80c9a644 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 11 Mar 2026 22:42:23 +0800 Subject: [PATCH 445/691] wallet: add `_time_format` for sqlite SQLite does not have a dedicated native timestamp type, so consistent timestamp behavior depends on how the driver serializes Go `time.Time` values. The SQL wallet schema already mixes: - application-supplied `time.Time` values (for example birthday timestamps), - and SQLite-generated timestamp values such as `current_timestamp` for metadata fields like `created_at` and `updated_at`. Set `_time_format=sqlite` on the sqlite DSN so the driver encodes and decodes `time.Time` using SQLite's expected timestamp layout. This keeps round-tripping predictable and makes app-written timestamps consistent with SQLite-generated timestamp values across the schema. --- wallet/internal/db/sqlite.go | 1 + 1 file changed, 1 insertion(+) diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index b6af1a94d1..53e582f761 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -31,6 +31,7 @@ func NewSqliteStore(ctx context.Context, cfg SqliteConfig) (*SqliteStore, dsn += "&_pragma=journal_mode=WAL" dsn += "&_txlock=immediate" dsn += "&_pragma=busy_timeout=5000" + dsn += "&_time_format=sqlite" db, err := sql.Open("sqlite", dsn) if err != nil { From 9e88f7c1daa255f015c652afb5cd17d8c63fe50f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 03:22:00 +0800 Subject: [PATCH 446/691] wallet: fix postgres itest container readiness Postgres 18 can start listening on 5432 before it is ready to accept SQL queries, which makes the shared postgres itest container race with CREATE DATABASE during CI. Wait for a successful SQL round trip instead of a listening port so the postgres integration tests do not fail with connection resets or unexpected EOFs during setup. --- wallet/internal/db/itest/pg_test.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index d8384d806a..d8975fa329 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" @@ -96,13 +97,26 @@ func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, err pgContainerOnce.Do(func() { cfg := DefaultPostgresConfig() - pgContainer, pgContainerErr = postgres.RunContainer(ctx, - testcontainers.WithImage(cfg.Image), + // PostgreSQL 18 can begin listening on the TCP port before it is + // ready to handle client queries, so wait for a successful SQL round + // trip instead of only waiting for the port to open. + waitForSQL := wait.ForSQL( + "5432/tcp", "pgx", func(host string, port nat.Port) string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=disable", + cfg.Username, cfg.Password, host, port.Port(), + cfg.Database, + ) + }, + ).WithStartupTimeout(pgInitTimeout) + + pgContainer, pgContainerErr = postgres.Run(ctx, + cfg.Image, postgres.WithDatabase(cfg.Database), postgres.WithUsername(cfg.Username), postgres.WithPassword(cfg.Password), testcontainers.WithWaitStrategyAndDeadline( - pgInitTimeout, wait.ForListeningPort("5432/tcp"), + pgInitTimeout, waitForSQL, ), ) }) From 8e63058e71344eee4d03448802250d53d7587861 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 03:30:04 +0800 Subject: [PATCH 447/691] build: add consolidated sql make target Working on SQL migrations and queries currently requires separate sql-lint, sql-format, and sqlc invocations, and the linting steps can rewrite SQL after code generation. Add make sql to run linting, formatting, and generation in one place so local development normalizes the SQL first and only regenerates the Go files once from the final contents. --- Makefile | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Makefile b/Makefile index 5e6fe85144..f8ebe01f54 100644 --- a/Makefile +++ b/Makefile @@ -286,6 +286,20 @@ sql-lint-check: @$(call print, "Linting SQL files (sqlite queries).") $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) +#? sql: Lint, verify, format, and regenerate SQL code for local development +# First try auto-fixes. If that still fails, rerun lint in check mode so the +# remaining unfixable violations are printed before aborting the workflow. +# On success, verify the final SQL is clean before formatting and regeneration. +sql: + @if ! $(MAKE) --no-print-directory --silent sql-lint; then \ + $(MAKE) --no-print-directory --silent sql-lint-check; \ + exit 1; \ + fi + @$(MAKE) --no-print-directory --silent sql-lint-check + @$(MAKE) --no-print-directory --silent sql-format + @$(MAKE) --no-print-directory --silent sqlc + + .PHONY: all \ default \ build \ @@ -303,6 +317,7 @@ sql-lint-check: tidy-module \ tidy-module-check \ sql-parse \ + sql \ sqlc \ sqlc-check \ sql-format \ From 2969edcb09f79723c675638d1d3f1b64e8196b42 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:25 +0800 Subject: [PATCH 448/691] docs: add ADR 0006 implementation notes --- docs/developer/adr/0006-wtxmgr-sql-schema.md | 455 +++++++------------ docs/developer/utxo_data_model.md | 64 ++- 2 files changed, 195 insertions(+), 324 deletions(-) diff --git a/docs/developer/adr/0006-wtxmgr-sql-schema.md b/docs/developer/adr/0006-wtxmgr-sql-schema.md index 55778aca79..9fbf0c924b 100644 --- a/docs/developer/adr/0006-wtxmgr-sql-schema.md +++ b/docs/developer/adr/0006-wtxmgr-sql-schema.md @@ -18,9 +18,9 @@ We will adopt a **UTXO-Centered, Soft-Deletion Schema**. ### 2.1 Core Principles 1. **UTXO-Centered Operations:** The schema is optimized for `Balance()` and `CoinSelection()` queries, which focus on the `utxos` table. -2. **Transaction-Centered Integrity:** Validity flows from Parent to Child. A UTXO is only valid if its parent Transaction is valid (`status='published'`) or is explicitly allowed for chaining (`status='pending'`). +2. **Transaction-Centered Integrity:** Validity flows from Parent to Child. A UTXO is only valid if its parent Transaction is valid (`tx_status = 1`, `published`) or is explicitly allowed for chaining (`tx_status = 0`, `pending`). 3. **Immutable History (Soft Deletion):** We **NEVER** automatically `DELETE` rows. - * Failed/RBF'd transactions are marked with a `status` field (e.g., `replaced`, `failed`). + * Failed/RBF'd transactions are marked with a `tx_status` field (e.g., `replaced`, `failed`). * They remain in the database for audit history but are excluded from balance queries. * Foreign Keys use `ON DELETE RESTRICT` for creation relationships to prevent accidental data loss. 4. **Wallet-Scoped Rows:** All `wtxmgr` tables are scoped by `wallet_id` to support multiple wallets sharing a single database without row-level conflicts. @@ -40,19 +40,21 @@ This ADR chooses wallet-scoped tables for simplicity and implementability. A fut ### 2.1.2 Explicit Status vs. Derived Status Two designs were considered for tracking transaction validity: -* **Explicit `status` column (chosen):** - * The `status` field is a pre-computed materialization of the transaction's validity state, set atomically at write time when the invalidating event occurs (RBF, double-spend, reorg). - * Pros: Balance and coin-selection queries filter on a single status predicate (`status IN ('published', 'pending')`) with no joins (and can be indexed if profiling justifies it); cascading invalidation is performed once at write time and never re-derived; audit states (`failed`, `replaced`, `orphaned`) are directly queryable. +* **Explicit `tx_status` column (chosen):** + * The `tx_status` field is a pre-computed materialization of the transaction's validity state, set atomically at write time when the invalidating event occurs (RBF, double-spend, reorg). + * It is stored as a compact numeric code (`0 = pending`, `1 = published`, `2 = replaced`, `3 = failed`, `4 = orphaned`) so hot-path predicates and indexes do not pay the storage or comparison cost of repeated status strings. + * The schema intentionally does **not** use a separate status lookup table. The status set is tiny, closed, and application-owned, so a reference table would add foreign-key and seed-data complexity without adding meaningful flexibility. Keeping the code inline on the transaction row preserves hot-path simplicity, while the Go enum layer provides the human-readable names. + * Pros: Balance and coin-selection queries filter on a single status predicate (`tx_status IN (1, 0)`) with no joins (and can be indexed if profiling justifies it); cascading invalidation is performed once at write time and never re-derived; audit states (`failed`, `replaced`, `orphaned`) are directly queryable. * Cons: Introduces a field that could theoretically drift from the underlying facts. This is partially mitigated by `CHECK` constraints (`check_confirmed_published`, `check_coinbase_confirmation_state`) and coinbase reorg triggers; transition correctness for `failed`/`replaced` remains a write-path responsibility validated by tests. * **Derived status from other columns (alternative):** * At coarse granularity (pending vs active vs invalid), most states are derivable: `orphaned` = `is_coinbase AND block_height IS NULL`; `replaced` and `failed` (direct victim) = existence in `tx_replacements.replaced_tx_id`. * However, a full replacement requires preserving the distinct invalid states (`replaced`, `failed`, `orphaned`), which have different operational semantics: RBF (`replaced`) allows re-spending the same inputs with a new tx; `orphaned` coinbase can recover on reconfirmation; `failed` (double-spend) is permanent. Collapsing these into a single boolean loses information needed for recovery logic and user-facing audit. * A boolean decomposition (e.g., `is_broadcast` + `is_invalid`) also introduces problems: `is_broadcast` does not equal `published` — a tx can be valid/published because it was received from the network, not broadcast by this wallet; boolean pairs create ambiguous combinations (e.g., `is_broadcast=false, is_invalid=true`) requiring additional constraints. - * A fully-derived alternative that preserves the same information would require at least three columns (`is_broadcast`/source marker, `invalidation_reason` enum, optional `invalidated_by_tx_id`) — strictly more schema surface than a single `status` column. + * A fully-derived alternative that preserves the same information would require at least three columns (`is_broadcast`/source marker, `invalidation_reason` enum, optional `invalidated_by_tx_id`) — strictly more schema surface than a single `tx_status` column. * Cascading invalidation adds further complexity: downstream txs whose parent became invalid were never directly "replaced." Without a pre-computed status, every balance/coin-selection query would need a recursive CTE walking up the ancestor chain — O(depth) per tx on the hot path. -This ADR chooses explicit status as the minimal representation that captures all lifecycle states without ambiguity. The `status` column is a pre-computed materialization set once at write time; database constraints/triggers enforce key invariants (confirmation and coinbase semantics), while write-path logic is responsible for setting `failed`/`replaced` transitions correctly. +This ADR chooses explicit status as the minimal representation that captures all lifecycle states without ambiguity. The `tx_status` column is a pre-computed materialization set once at write time; database constraints/triggers enforce key invariants (confirmation and coinbase semantics), while write-path logic is responsible for setting `failed`/`replaced` transitions correctly. ### 2.2 Consistency & Concurrency Model This design assumes standard SQL ACID guarantees. @@ -71,316 +73,169 @@ Recommended operational defaults: * **Coinbase special case:** Coinbase transactions from the disconnected block are marked `orphaned` (they cannot exist outside the block that created them). * **Atomicity requirement:** The coinbase status update MUST occur atomically with the disconnect. * PostgreSQL evaluates `CHECK` constraints immediately, including updates performed by foreign-key actions such as `ON DELETE SET NULL`. - * If you enforce the coinbase invariant at the database level, a simple `DELETE FROM blocks ...` will fail unless the coinbase row's `status` is updated to `orphaned` as part of the same statement. - * Recommended: use a trigger to rewrite coinbase `status` during the `block_height -> NULL` update (see 3.6). - * **Reconfirmation:** If an orphaned coinbase transaction re-enters the best chain, restoring it requires setting `block_height` and `status='published'` atomically. + * If you enforce the coinbase invariant at the database level, a simple `DELETE FROM blocks ...` will fail unless the coinbase row's `tx_status` is updated to `orphaned` as part of the same statement. + * Recommended: use a trigger to rewrite coinbase `tx_status` during the `block_height -> NULL` update (see 3.5). + * **Reconfirmation:** If an orphaned coinbase transaction re-enters the best chain, restoring it requires setting `block_height` and `tx_status = 1` (`published`) atomically. * **RBF:** Handled by updating the `utxos.spent_by_tx_id` pointer to the new transaction and marking the old transaction as `replaced`. -## 3. Reference Schema - -### 3.1 Wallet: `transactions` -Stores the provenance of funds for a specific wallet. Acts as the source of truth for Validity (`status`) and Confirmation (`block_height`). - -```sql -CREATE TABLE transactions ( - wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, - id BIGSERIAL NOT NULL, - tx_hash BYTEA NOT NULL CHECK (length(tx_hash) = 32), - -- Raw transaction bytes. This is typically TOASTed in PostgreSQL. - -- Hot-path queries (balance/coin selection) SHOULD avoid selecting this column. - raw_tx BYTEA NOT NULL, - - -- Confirmation State: - -- NULL = Unconfirmed (Mempool) - -- INT = Confirmed (Mined) - -- ON DELETE SET NULL: If block is reorged, tx becomes unconfirmed. - block_height INTEGER REFERENCES blocks(block_height) ON DELETE SET NULL, - - -- Validity State (Soft Deletion): - -- pending: Created locally, not yet broadcast. - -- published: Active in mempool or blockchain (Valid); not necessarily broadcast by this wallet. - -- replaced: RBF'd by another transaction (Invalid). - -- failed: Double-spent by a competitor (Invalid). - -- orphaned: Coinbase tx that was reorged out (Invalid). - status VARCHAR(20) NOT NULL DEFAULT 'pending', - - -- Absolute wall clock time, stored in UTC. - received_time TIMESTAMPTZ NOT NULL, - is_coinbase BOOLEAN NOT NULL DEFAULT FALSE, - - -- Composite primary key is intentional: it allows foreign keys to enforce - -- wallet scoping (preventing cross-wallet references). Do not replace this - -- with a single-column PK on `id` unless all referencing FKs are also - -- reworked to maintain the same invariant. - CONSTRAINT pidx_transactions PRIMARY KEY (wallet_id, id), - CONSTRAINT uidx_transactions_hash UNIQUE (wallet_id, tx_hash), - CONSTRAINT valid_status CHECK (status IN ('pending', 'published', 'replaced', 'failed', 'orphaned')), - -- Invariant: If a transaction is confirmed (mined), it must be 'published'. - CONSTRAINT check_confirmed_published CHECK ( - block_height IS NULL OR status = 'published' - ), - -- Invariant: Coinbase transactions cannot exist in the mempool. - -- If a coinbase transaction loses its block via a reorg, it becomes orphaned. - CONSTRAINT check_coinbase_not_pending CHECK (NOT (is_coinbase AND status = 'pending')), - CONSTRAINT check_coinbase_confirmation_state CHECK ( - NOT is_coinbase OR - (block_height IS NOT NULL AND status = 'published') OR - (block_height IS NULL AND status = 'orphaned') - ) -); - --- Optimization for Mempool lookups -CREATE INDEX idx_transactions_unconfirmed -ON transactions (wallet_id, block_height) -WHERE block_height IS NULL; - --- Optimization for "all transactions in block X" queries -CREATE INDEX idx_transactions_by_block -ON transactions (wallet_id, block_height) -WHERE block_height IS NOT NULL; - --- Optimization for "latest transactions" queries -CREATE INDEX idx_transactions_by_received_time -ON transactions (wallet_id, received_time DESC); - --- Optimization for status-based filtering (non-hot path) --- Optional: profile before enabling (low cardinality) --- CREATE INDEX idx_transactions_by_status --- ON transactions (wallet_id, status); -``` - -### 3.2 Local: `utxos` -The **Single Source of Truth** for wallet balance. -Note: The table keeps the `utxos` name for consistency with wallet abstractions, even though it retains spent rows for audit history (`spent_by_tx_id` marks spent outputs). - -```sql -CREATE TABLE utxos ( - wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, - id BIGSERIAL NOT NULL, - - -- Creation (OutPoint): - -- ON DELETE RESTRICT: We NEVER delete confirmed transactions or their UTXOs. - -- To remove history, the user must explicitly Prune (manual op). - tx_id BIGINT NOT NULL, - output_index INTEGER NOT NULL CHECK (output_index >= 0), - - amount BIGINT NOT NULL CHECK (amount >= 0), - -- The output script is stored on the address record (see the address-manager - -- schema). `addresses.script_pub_key` is expected to be NOT NULL for all - -- addresses, including HD-derived addresses. - -- Optional address book reference for indexing and UX. - -- The `addresses` table is part of the address-manager schema (tracked - -- separately from this ADR's `wtxmgr` schema). - -- - -- Note: in the draft address schema (`wallet/internal/db/migrations/*/000006_addresses.up.sql`), - -- `addresses` is keyed by an `account_id` that is ultimately rooted in a - -- specific wallet (via key scopes). Implementations must ensure `address_id` - -- always refers to an address belonging to the same `wallet_id`. - address_id BIGINT NOT NULL REFERENCES addresses(id) ON DELETE RESTRICT, - - -- Spending (Input): - -- ON DELETE SET NULL: If the spending tx is manually pruned, the UTXO becomes unspent. - spent_by_tx_id BIGINT, - -- NULL when unspent; non-NULL when spent (enforced by pair constraint). - spent_input_index INTEGER CHECK (spent_input_index IS NULL OR spent_input_index >= 0), - - -- Composite primary key is intentional: it allows leases and other - -- references to enforce wallet scoping. - CONSTRAINT pidx_utxos PRIMARY KEY (wallet_id, id), - CONSTRAINT fkey_utxos_tx FOREIGN KEY (wallet_id, tx_id) - REFERENCES transactions(wallet_id, id) ON DELETE RESTRICT, - CONSTRAINT fkey_utxos_spent_by FOREIGN KEY (wallet_id, spent_by_tx_id) - REFERENCES transactions(wallet_id, id) ON DELETE SET NULL, - - CONSTRAINT check_spent_tx_and_index_pair CHECK ( - (spent_by_tx_id IS NULL AND spent_input_index IS NULL) OR - (spent_by_tx_id IS NOT NULL AND spent_input_index IS NOT NULL) - ), - - CONSTRAINT uidx_utxos_outpoint UNIQUE (wallet_id, tx_id, output_index) -); - --- Optimization for Balance Queries (Index-Only Scan) -CREATE INDEX idx_utxos_unspent ON utxos (address_id, amount) WHERE spent_by_tx_id IS NULL; - --- Optimization for listing all UTXOs for an address (including spent) -CREATE INDEX idx_utxos_by_address ON utxos (address_id); - --- Optimization for finding inputs (debits) of a transaction -CREATE INDEX idx_utxos_spent_by ON utxos(wallet_id, spent_by_tx_id); - --- Optimization for listing all outputs of a transaction -CREATE INDEX idx_utxos_by_tx ON utxos(wallet_id, tx_id); -``` - -Denormalization note: -* This schema is normalized: the canonical locking script is stored in the address book (`addresses.script_pub_key`). -* Joining through `address_id` adds work to the signing/reconstruction path, but keeps the hot UTXO table small and avoids duplicating script data. - -Cross-wallet integrity note: -* Ideally, the database should prevent a `utxos` row from referencing an `address_id` belonging to a different wallet. -* The strongest enforcement is a composite FK `FOREIGN KEY (wallet_id, address_id) REFERENCES addresses(wallet_id, id)`. -* If the address-manager schema does not expose `wallet_id` on `addresses`, this invariant must be enforced by application logic. - -### 3.3 Audit: `tx_replacements` -Tracks the history of RBF and Double-Spends. - -```sql -CREATE TABLE tx_replacements ( - wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, - id BIGSERIAL NOT NULL, - - -- ON DELETE CASCADE: Supports Manual Pruning. - -- If a transaction is physically deleted (to save space), - -- its audit history is automatically cleaned up to prevent FK violations. - replaced_tx_id BIGINT NOT NULL, - replacement_tx_id BIGINT NOT NULL, - - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- Composite primary key is intentional: it enforces wallet scoping. - CONSTRAINT pidx_tx_replacements PRIMARY KEY (wallet_id, id), - CONSTRAINT fkey_tx_replacements_replaced FOREIGN KEY (wallet_id, replaced_tx_id) - REFERENCES transactions(wallet_id, id) ON DELETE CASCADE, - CONSTRAINT fkey_tx_replacements_replacement FOREIGN KEY (wallet_id, replacement_tx_id) - REFERENCES transactions(wallet_id, id) ON DELETE CASCADE, - CONSTRAINT check_not_self_replacement CHECK (replaced_tx_id != replacement_tx_id), - CONSTRAINT uidx_tx_replacements_edge UNIQUE (wallet_id, replaced_tx_id, replacement_tx_id) -); -``` - -### 3.4 Local: `utxo_leases` -Transient application locks. - -```sql -CREATE TABLE utxo_leases ( - wallet_id BIGINT NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, - utxo_id BIGINT NOT NULL, - external_lock_id BYTEA NOT NULL CHECK (length(external_lock_id) = 32), - expires_at TIMESTAMPTZ NOT NULL, - - -- Composite primary key is intentional: it enforces wallet scoping. - CONSTRAINT pidx_utxo_leases PRIMARY KEY (wallet_id, utxo_id), - CONSTRAINT fkey_utxo_leases_utxo FOREIGN KEY (wallet_id, utxo_id) - REFERENCES utxos(wallet_id, id) ON DELETE CASCADE -); - --- Optimization for lease cleanup -CREATE INDEX idx_utxo_leases_expires_at ON utxo_leases(expires_at); - --- Lease cleanup is expected to be periodic: --- DELETE FROM utxo_leases WHERE expires_at <= CURRENT_TIMESTAMP; - --- Lease acquisition is expected to be a single atomic statement. --- Recommended pattern (PostgreSQL): acquire if absent, renew if same external_lock_id, --- or steal only if expired. --- Portability note: SQLite supports UPSERT, but uses different time functions. --- Prefer `CURRENT_TIMESTAMP` in cross-database examples. --- The `WHERE` clause in `ON CONFLICT DO UPDATE` is supported in SQLite 3.24.0+; verify compatibility with your target version. --- --- INSERT INTO utxo_leases (wallet_id, utxo_id, external_lock_id, expires_at) --- VALUES ($1, $2, $3, CURRENT_TIMESTAMP + $4) --- ON CONFLICT (wallet_id, utxo_id) DO UPDATE --- SET external_lock_id = EXCLUDED.external_lock_id, --- expires_at = EXCLUDED.expires_at --- WHERE utxo_leases.expires_at <= CURRENT_TIMESTAMP --- OR utxo_leases.external_lock_id = EXCLUDED.external_lock_id --- RETURNING expires_at; --- --- If no row is returned, the UTXO is currently leased by another external_lock_id. - -Deadlock avoidance note: -* When acquiring multiple leases in one transaction, acquire them in a stable order - (for example sorted by `(wallet_id, utxo_id)`) to reduce deadlock risk. -``` - -### 3.5 Convenience Views - -**`spendable_utxos` View:** -Encapsulates the logic of joining transactions to filter out invalid/failed parents. -Note: Filtering for maturity (Coinbase > 100 confs) is done at the application layer using the exposed `block_height` and `is_coinbase` columns, as Views cannot accept dynamic parameters like `current_height`. - -Suggested helper (parameterized query): -* Expose a query helper that takes `current_height` and filters out immature coinbase outputs: - * `WHERE (NOT is_coinbase) OR (current_height - block_height) >= 100` - -Important: The view includes `pending` parent transactions to enable zero-latency chaining. This is an advanced mode and should be opt-in for conservative spending policies. - -Note: Leases are time-based and depend on the database's current time function. For clarity, the view does not attempt to exclude leased UTXOs. Coin selection MUST exclude active leases (for example using a `NOT EXISTS` subquery against `utxo_leases` where `expires_at > CURRENT_TIMESTAMP`). - -```sql -CREATE VIEW spendable_utxos AS -SELECT - u.*, - t.block_height, - t.is_coinbase, - t.status as tx_status -FROM utxos u -JOIN transactions t ON t.wallet_id = u.wallet_id AND t.id = u.tx_id -WHERE u.spent_by_tx_id IS NULL - AND t.status IN ('published', 'pending'); -``` - -### 3.6 Triggers (PostgreSQL) - -To enforce the coinbase invariant under `ON DELETE SET NULL`, PostgreSQL needs a trigger because `CHECK` constraints are not deferrable. - -Recommended behavior: -* When `block_height` transitions from `NOT NULL` to `NULL`, and `is_coinbase = TRUE`, automatically rewrite `status = 'orphaned'`. - -This can be implemented with a `BEFORE UPDATE` trigger on `transactions`. Foreign-key actions execute as ordinary `UPDATE` statements, and triggers on the referencing table will fire. - -Portability note: -* PostgreSQL: Foreign-key actions (such as `ON DELETE SET NULL`) are performed via ordinary `UPDATE` statements on the referencing table, and triggers on that table will fire. -* SQLite: Foreign-key actions occur after the parent row operation. If you cannot rely on triggers to rewrite `status` during the FK action, enforce coinbase orphaning with an explicit application-side update in the same SQL transaction as the disconnect. - -**SQLite example:** To atomically disconnect a block and orphan its coinbase transactions, run the following within a single transaction: -```sql -BEGIN; -DELETE FROM blocks WHERE block_height = ?; -UPDATE transactions SET status = 'orphaned' WHERE is_coinbase AND block_height IS NULL; -COMMIT; -``` - -Example sketch: - -```sql -CREATE FUNCTION set_coinbase_orphaned_on_disconnect() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - IF NEW.block_height IS NULL AND OLD.block_height IS NOT NULL AND NEW.is_coinbase THEN - NEW.status := 'orphaned'; - END IF; - RETURN NEW; -END; -$$; - -CREATE TRIGGER trg_set_coinbase_orphaned_on_disconnect -BEFORE UPDATE OF block_height ON transactions -FOR EACH ROW -EXECUTE FUNCTION set_coinbase_orphaned_on_disconnect(); -``` +### 2.4 Implementation Notes + +This ADR includes a reference schema. The implementation keeps the same +invariants, but makes a few deliberate schema choices to match the existing +conventions in `wallet/internal/db/migrations/`. + +**Primary keys and wallet scoping** + +The reference schema uses composite primary keys (`(wallet_id, id)`) to make +wallet scoping enforceable with foreign keys. + +In this repository, SQLite tables follow the rowid-backed +`INTEGER PRIMARY KEY` pattern used by the existing wallet/account/address +schema. To preserve the wallet-scoping invariant while keeping that +convention, the implementation uses single-column primary keys on `id` and +adds `UNIQUE(wallet_id, id)` constraints on wallet-scoped tables. Child tables +then use composite foreign keys referencing `(wallet_id, id)`. + +**Manual pruning and `spent_by` semantics** + +The reference schema uses `ON DELETE SET NULL` for the +`(wallet_id, spent_by_tx_id)` foreign key so that physically deleting a +spending transaction can restore a UTXO to the unspent set. + +In a composite foreign key, `ON DELETE SET NULL` applies to *all* referencing +columns. With `utxos.wallet_id` being `NOT NULL`, the reference behavior cannot +be expressed directly as written. + +The implementation therefore uses `ON DELETE RESTRICT` for the spender foreign +key and defines manual pruning as an explicit, application-driven operation +that clears `utxos.spent_by_*` before deleting/pruning the spending +transaction, all within a single SQL transaction. + +**SQLite coinbase disconnect handling** + +PostgreSQL can rely on the transaction-row trigger alone when a block delete +causes `transactions.block_height` to become `NULL`. + +SQLite evaluates child-row checks before an `AFTER UPDATE` trigger on the child +table can normalize the row. To preserve the coinbase orphaning invariant, the +implementation adds a `BEFORE DELETE ON blocks` trigger that rewrites affected +transactions into their final disconnected state before the block row is +removed. + +**Transaction labels** + +User-facing labels are part of the internal store contract (`TxInfo.Label`). +The implementation stores them inline as `transactions.tx_label` instead of a +separate labels table to keep the hot read path simple. + +**Timestamps** + +Both PostgreSQL and SQLite now follow the same timestamp contract: + +- all persisted timestamps represent UTC instants +- PostgreSQL stores them as `TIMESTAMP` +- SQLite stores them as `TIMESTAMP`/`DATETIME` +- logic-sensitive comparisons (for example lease expiry) use caller-supplied UTC + values instead of relying on session-local database time semantics + +## 3. Implemented Schema Notes + +The SQL migrations under `wallet/internal/db/migrations/postgres` and +`wallet/internal/db/migrations/sqlite` are the source of truth for the concrete +schema. This section captures the important design decisions without duplicating +full reference DDL that can drift from the live migrations. + +### 3.1 Transactions + +- `transactions` remains wallet-scoped through `wallet_id` +- `tx_status` stores the wallet-relative validity state inline as a small, + closed numeric enum +- `tx_label` stores user-facing labels inline and keeps the hot read path simple +- `raw_tx` is retained because the schema does not store a fully normalized + input/output graph; callers still need to reconstruct `wire.MsgTx` for + transaction reads and dependency walks +- `UNIQUE (wallet_id, id)` remains so wallet-scoped child relations can use a + composite foreign-key target where needed + +### 3.2 UTXOs + +- `utxos.wallet_id` is intentionally not stored +- wallet ownership is derived from the creating transaction: + `utxos.tx_id -> transactions.wallet_id` +- the owning address is still recorded through `address_id` +- correctness requires the wallet derived from `tx_id` and the wallet derived + from `address_id` to match +- PostgreSQL and SQLite both enforce this invariant with triggers on `utxos` +- same-wallet spend edges are also enforced by trigger when `spent_by_tx_id` is + present + +This keeps the UTXO row normalized while preserving the important wallet-scoped +integrity checks. + +### 3.3 Replacement edges + +- `tx_replacements` remains wallet-scoped +- both endpoints reference wallet-scoped transaction rows through + `(wallet_id, id)` +- `created_at` is stored as a UTC `TIMESTAMP` + +### 3.4 UTXO leases + +- `utxo_leases` keeps `wallet_id` as a query helper for wallet-scoped lease + scans and cleanup +- the row is keyed by `utxo_id`, so one UTXO can have at most one lease row +- wallet consistency between `utxo_leases.wallet_id` and the leased UTXO is + enforced by trigger +- `expires_at` is stored as a UTC `TIMESTAMP` +- lease acquisition, renewal, and cleanup compare against explicit UTC values + supplied by the caller + +### 3.5 Triggers + +The implementation relies on triggers for the invariants that cannot be fully +expressed through ordinary foreign keys alone: + +- coinbase disconnect/orphan handling on `transactions` +- `wallet(tx_id) == wallet(address_id)` on `utxos` +- same-wallet `spent_by_tx_id` on `utxos` +- lease wallet consistency on `utxo_leases` + +This keeps the database responsible for protecting the critical wallet-scoping +rules rather than assuming the application layer is always correct. ## 4. Consequences ### 4.1. True Multi-Wallet Support -All `wtxmgr` tables are scoped by `wallet_id`. This allows multiple wallets to share the same database without conflicting unique constraints (for example, outpoints and transaction hashes are unique per wallet). +The transaction graph remains wallet-scoped, and every wallet-owned row is +either directly keyed by `wallet_id` or derives its wallet ownership through a +wallet-scoped parent transaction. This allows multiple wallets to share the +same database without conflicting unique constraints while still permitting the +same network outpoint or tx hash to appear in multiple wallets independently. Note: A separate, truly global `transactions` table shared across wallets is a different design. That approach would require a join table (e.g., `wallet_transactions`) to track per-wallet ownership and metadata. ### 4.2. Native SQL Efficiency -Balances are calculated using `SUM(amount)` on the `utxos` table (or `spendable_utxos` view), leveraging database optimizations. +Balances are calculated using `SUM(amount)` over the normalized `utxos` table +with transaction/account joins and database-side filtering, leveraging native +database optimizations. + +The schema intentionally stops short of declaring one canonical "spendable +balance" API because callers may disagree about pending chaining, lease +exclusion, confirmation thresholds, or coinbase maturity rules. ### 4.3. Audit Trail -By using "Soft Deletion" (`status='replaced'`), we maintain a complete history of user attempts, even those that failed. This is superior to previous designs that physically deleted failed transactions. +By using soft-deletion style transaction states (`tx_status = 2`, `replaced`), +we maintain a complete history of user attempts, even those that failed. This +is superior to previous designs that physically deleted failed transactions. ### 4.4. Complexity Trade-off We accept slightly more complexity in **Transaction Reconstruction** (joining inputs/outputs) in exchange for maximal performance in **Balance Calculation** and **Coin Selection**, which are the high-frequency operations. Additional operational consequences: -* **Pending-chaining is advanced:** The `spendable_utxos` view includes `pending` parents to enable zero-latency chaining. This increases operational risk (child transactions depend on parents being broadcast) and should be disabled for conservative policies. +* **Pending-chaining is advanced:** Zero-latency chaining should remain an + application-level choice. Callers that opt into `pending` parents take on + the operational risk that child transactions depend on parents being + broadcast successfully. * **Recursive invalidation is unbounded in theory:** Marking downstream transactions `failed` after an upstream double-spend can require recursive graph traversal. Implementations should assume bounded typical depth but plan for worst-case behavior (e.g., set a maximum recursion depth or iteration limit). ### 4.5 Operational Notes (Out of Scope for This ADR) diff --git a/docs/developer/utxo_data_model.md b/docs/developer/utxo_data_model.md index ed077c2390..5e99026ef2 100644 --- a/docs/developer/utxo_data_model.md +++ b/docs/developer/utxo_data_model.md @@ -124,15 +124,20 @@ We distinguish between a transaction's **Validity** (Status) and its **Confirmat **2. Validity (Source of Truth: `Status`)** -| Status | Meaning | Affects Balance? | +| Status Code | Meaning | Affects Balance? | | :--- | :--- | :--- | -| `pending` | Created locally, not yet broadcast. | **No** (Locked) | -| `published` | Active in mempool or blockchain. | **Yes** (If valid) | -| `replaced` | Replaced by a higher-fee transaction (RBF). | **No** | -| `failed` | Invalidated by a conflicting transaction (Double-Spend). | **No** | -| `orphaned` | Coinbase tx that was reorged out (Invalid). | **No** | +| `0` (`pending`) | Created locally, not yet broadcast. | **Yes** (Factual) | +| `1` (`published`) | Active in mempool or blockchain. | **Yes** (If valid) | +| `2` (`replaced`) | Replaced by a higher-fee transaction (RBF). | **No** | +| `3` (`failed`) | Invalidated by a conflicting transaction (Double-Spend). | **No** | +| `4` (`orphaned`) | Coinbase tx that was reorged out (Invalid). | **No** | -*Additional invariant: Coinbase transactions cannot exist in the mempool. A coinbase transaction is either confirmed (`BlockHeight IS NOT NULL` and `Status='published'`) or orphaned (`Status='orphaned'` and `BlockHeight IS NULL`).* +*Additional invariant: Coinbase transactions cannot exist in the mempool. A coinbase transaction is either confirmed (`BlockHeight IS NOT NULL` and `Status = 1`, `published`) or orphaned (`Status = 4`, `orphaned`, and `BlockHeight IS NULL`).* + +Factual-balance note: the SQL Store layer treats both `pending` and +`published` parent transactions as part of the current live UTXO set. More +conservative spendability policy (for example, excluding `pending` parents) is +applied by higher-level callers on top of that factual base. *Note: There is no "Abandoned" state. A broadcast transaction cannot be safely abandoned; it can only be invalidated by double-spending its inputs.* @@ -178,12 +183,12 @@ A UTXO does not store its own status field. Its status is calculated dynamically | UTXO State | Parent Tx Status | `BlockHeight` | `SpentBy` Pointer | Meaning | | :--- | :--- | :--- | :--- | :--- | -| **Confirmed** | `published` | `NOT NULL` | `NULL` | Available, mature funds. | -| **Unconfirmed** | `published` | `NULL` | `NULL` | Incoming funds, risk of RBF. | -| **Immature** | `published` (Coinbase) | `NOT NULL` | `NULL` | Mined coins, must wait 100 blocks. | -| **Spent** | `published` | `NOT NULL` | `NOT NULL` | History. We used this money. | -| **Dead (Permanent)** | `failed` / `replaced` | *Any* | *Any* | Permanently invalid output from a failed attempt. | -| **Dead (Recoverable)** | `orphaned` (coinbase) | `NULL` | *Any* | Orphaned coinbase output. Can become `Immature` if parent is reconfirmed. | +| **Confirmed** | `1` (`published`) | `NOT NULL` | `NULL` | Available, mature funds. | +| **Unconfirmed** | `1` (`published`) | `NULL` | `NULL` | Incoming funds, risk of RBF. | +| **Immature** | `1` (`published`) coinbase | `NOT NULL` | `NULL` | Mined coins, must wait 100 blocks. | +| **Spent** | `1` (`published`) | `NOT NULL` | `NOT NULL` | History. We used this money. | +| **Dead (Permanent)** | `3` / `2` (`failed` / `replaced`) | *Any* | *Any* | Permanently invalid output from a failed attempt. | +| **Dead (Recoverable)** | `4` (`orphaned`) coinbase | `NULL` | *Any* | Orphaned coinbase output. Can become `Immature` if parent is reconfirmed. | --- @@ -213,7 +218,7 @@ When the blockchain reorganizes (disconnects blocks): Implementation note: The SQL `blocks` table models the current best chain. During a disconnect, the best-chain association is removed (for example by deleting the `blocks` row at that height and inserting the new one). The coinbase status update MUST be atomic with clearing `BlockHeight` to avoid an invalid "unconfirmed coinbase" state. -Coinbase reconfirmation note: If an orphaned coinbase transaction re-enters the best chain, restoring it requires updating `BlockHeight` and `Status='published'` atomically (one SQL statement within a transaction) to satisfy coinbase invariants. +Coinbase reconfirmation note: If an orphaned coinbase transaction re-enters the best chain, restoring it requires updating `BlockHeight` and `Status = 1` (`published`) atomically (one SQL statement within a transaction) to satisfy coinbase invariants. Reconfirmation semantics: * The transaction keeps the same `tx_hash` and is associated with a new `block_height`. @@ -237,7 +242,7 @@ graph TD When the wallet detects a replacement transaction (Tx B) for an existing unconfirmed transaction (Tx A): 1. **Detection:** Tx B spends the same inputs as Tx A. 2. **Update:** The input UTXOs are updated to point to Tx B (`SpentBy = TxB`). -3. **Archive:** Tx A is marked as `status = 'replaced'`. It remains in the DB for history but is ignored by balance queries. +3. **Archive:** Tx A is marked as `status = 2` (`replaced`). It remains in the DB for history but is ignored by balance queries. ### 5.3 Upstream Double-Spend (Invalidation) @@ -260,7 +265,7 @@ When a transaction (Tx C) becomes invalid because *its input* was spent by a con 2. **Conflict:** We have Tx C (unconfirmed) which also spends UTXO X. 3. **Resolution:** * Update UTXO X: `SpentBy = TxD`. - * Mark Tx C: `Status = 'failed'`. + * Mark Tx C: `Status = 3` (`failed`). * **Cascade:** Any UTXOs created by Tx C are now effectively "Dead" (because their parent is `failed`). * Any transactions spending those dead UTXOs must also be marked `failed` (recursive invalidation). @@ -278,21 +283,32 @@ This can be implemented with an application-side loop (portable across databases ### 5.4 Coin Selection Coin selection queries focus purely on the UTXO set: 1. Filter by `SpentBy IS NULL`. -2. Join Parent Transaction to ensure `Status IN ('published', 'pending')`. - * *Note:* Including `pending` allows chaining unbroadcast transactions (Zero-Latency Chaining), but creates a strict dependency: the parent must be broadcast before the child. - * This is an advanced feature and should be opt-in for conservative policies. - * If confirmation is required (e.g., for safety), add `AND BlockHeight IS NOT NULL`. +2. Join the parent transaction and choose a caller-specific live-status + filter. + * Conservative callers can require `Status = 1` (`published`). + * Zero-latency chaining can opt into `Status IN (0, 1)` (`pending`, + `published`), but this creates a strict dependency: the parent must be + broadcast before the child. + * If confirmation is required (e.g., for safety), add + `AND BlockHeight IS NOT NULL`. 3. Filter out active `Leases`. -Important: The `spendable_utxos` view described in ADR 0006 does not exclude leases (because it depends on the database's current time function and requires a `NOT EXISTS` against `utxo_leases`). Coin selection must explicitly filter `utxo_leases` where `expires_at > CURRENT_TIMESTAMP`. +Important: There is no dedicated `spendable_utxos` view in the current branch. +Coin selection should build directly on factual UTXO queries and then apply its +own policy filters for pending parents, active leases, and immature coinbase +outputs. Active leases should be excluded using the caller's UTC `now` value, +not an implicit database-local timestamp. Transaction reconstruction note: This model optimizes the high-frequency UTXO read path. Reconstructing a full transaction view (inputs/outputs for UI/history) is inherently more join-heavy and should be treated as a lower-frequency, separately optimized read path. ### 5.5 Multi-Wallet Concurrency & Consistency -When multiple wallets share a single database, all `wtxmgr` rows are scoped by `wallet_id` (see ADR 0006). This avoids conflicts and makes correctness properties explicit. +When multiple wallets share a single database, the transaction graph remains +wallet-scoped through `transactions.wallet_id`, and other rows either carry +`wallet_id` directly or derive wallet ownership through that transaction graph. +This avoids conflicts and makes correctness properties explicit. * **Consistency model:** The system assumes ACID semantics from the database. Reorg handling, status transitions, and lease acquisition must be performed as explicit SQL transactions. * **Concurrency primitive:** UTXO leases are the wallet-level lock. Coin selection must exclude leased UTXOs, and lease acquisition should be treated as part of the same atomic unit as input selection. -* **Coinbase maturity:** Coinbase UTXOs require 100 confirmations. This rule should be enforced in a single, well-defined place (query helper/view + height parameter) to avoid accidental selection of immature funds. +* **Coinbase maturity:** Coinbase UTXOs require 100 confirmations. This rule should be enforced in a single, well-defined caller-side policy path to avoid accidental selection of immature funds. -Suggested enforcement: a parameterized query helper or SQL function that takes `current_height` and filters out coinbase UTXOs where `current_height - block_height < 100`. +Suggested enforcement: a parameterized caller-side query helper or composition layer that takes `current_height` and filters out coinbase UTXOs where `current_height - block_height < 100`. From 6826245691599035ac1e31be1892dfb0da64f565 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:17 +0800 Subject: [PATCH 449/691] wallet: prep db TxStore/UTXOStore interfaces --- wallet/internal/db/data_types.go | 196 ++++++++++++++++++++++++++++--- wallet/internal/db/interface.go | 106 +++++++++++++---- wallet/internal/db/kvdb/utxo.go | 5 +- wallet/mock_test.go | 8 +- wallet/tx_publisher.go | 7 +- 5 files changed, 269 insertions(+), 53 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 25b21d3c08..c745fb0968 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -4,6 +4,7 @@ package db import ( + "math" "time" "github.com/btcsuite/btcd/btcutil" @@ -11,6 +12,19 @@ import ( "github.com/btcsuite/btcd/wire" ) +const ( + // UnminedHeight is a sentinel value used in UtxoInfo.Height to indicate + // that the UTXO is unconfirmed. + // + // Database rows represent an unconfirmed creating transaction by setting + // `transactions.block_height` to NULL. The store layer maps that NULL to + // this sentinel value so callers can continue to treat UtxoInfo.Height + // as a non-nullable `uint32`. + // + // NOTE: This value must never overlap with a real block height. + UnminedHeight uint32 = math.MaxUint32 +) + // ============================================================================ // Data Types & Method Parameters // ============================================================================ @@ -702,6 +716,75 @@ type ListAddressesQuery struct { // TxStore Types // -------------------- +// TxStatus represents the wallet-relative validity state of a transaction. +// +// The value is stored in the `transactions.status` column as a compact numeric +// code so hot-path predicates and indexes do not pay the storage/index cost of +// repeated status strings. +// +// The enum values MUST match the numeric codes enforced by migration +// `000007_transactions` in both Postgres and SQLite. +type TxStatus uint8 + +const ( + // TxStatusPending indicates a locally-created transaction that has not yet + // been broadcast. + // + // Callers use this state when they need the store to retain a locally + // authored transaction before network publication. + TxStatusPending TxStatus = iota + + // TxStatusPublished indicates a transaction that is still considered + // valid by the wallet and is either unconfirmed in the mempool or + // confirmed in the current best chain. + // + // The two cases share one validity status because Block already tells the + // caller whether the transaction is mined. Keeping both under + // TxStatusPublished avoids contradictory combinations such as + // "confirmed but not published" and keeps this field focused on whether the + // wallet still treats the transaction as live. + TxStatusPublished + + // TxStatusReplaced indicates a transaction that was invalidated by a + // competing transaction spending the same inputs via RBF. + TxStatusReplaced + + // TxStatusFailed indicates a transaction that was invalidated by a + // competing transaction spending the same inputs (double-spend). + TxStatusFailed + + // TxStatusOrphaned indicates a coinbase transaction that was reorged out of + // the best chain. + // + // This state is reserved for coinbase transactions. Non-coinbase rows must + // use a different terminal state such as TxStatusFailed or + // TxStatusReplaced. + TxStatusOrphaned +) + +// String returns the human-readable name of one transaction status code. +func (s TxStatus) String() string { + switch s { + case TxStatusPending: + return "pending" + + case TxStatusPublished: + return "published" + + case TxStatusReplaced: + return "replaced" + + case TxStatusFailed: + return "failed" + + case TxStatusOrphaned: + return "orphaned" + + default: + return "unknown" + } +} + // TxInfo represents the details of a transaction relevant to the wallet. type TxInfo struct { // Hash is the transaction hash. @@ -718,6 +801,11 @@ type TxInfo struct { // and non-nil for mined (confirmed) transactions. Block *Block + // Status is the wallet-relative validity state of the transaction. + // + // For confirmed transactions, Status is always TxStatusPublished. + Status TxStatus + // Label is a user-defined label for the transaction. Label string } @@ -733,6 +821,40 @@ type CreateTxParams struct { // Tx is the transaction to record. Tx *wire.MsgTx + // Received is the timestamp when the wallet learned about the transaction. + // + // Callers supply this explicitly so import/recovery paths can preserve the + // wallet-observed time instead of defaulting to insertion time. + // + // This timestamp is stored in the database as UTC. + Received time.Time + + // Block optionally records the transaction as already confirmed in the + // provided block. When nil, the transaction is treated as unmined. + // + // The Store layer records factual transaction state. A non-nil Block means + // the caller is inserting a row already anchored to a specific block in + // wallet history; it does not ask the Store layer to infer publishing or + // confirmation policy on the caller's behalf. + // + // NOTE: Coinbase transactions cannot exist in the mempool. Callers MUST + // provide a non-nil Block when recording coinbase transactions. + Block *Block + + // Status is the initial wallet-relative validity state for the + // transaction. + // + // This Store-layer API inserts an already-constructed transaction row. It + // does not build, sign, publish, or infer higher-level wallet policy. + // Callers must therefore set Status explicitly instead of asking the Store + // to guess how an app-layer workflow intends to use the row. + // + // Unmined inserts choose between TxStatusPending and TxStatusPublished. + // Confirmed inserts (Block non-nil) must use TxStatusPublished to satisfy + // the transaction-state invariants. TxStatusOrphaned is reserved for + // coinbase rows and must not be used for ordinary transactions. + Status TxStatus + // Label is an optional label for the transaction. Label string @@ -752,12 +874,28 @@ type CreditData struct { Index uint32 // Address is the address that received the credit. + // + // NOTE: This field is for display only. The database layer should match the + // credit to an address row by the output's script_pub_key + // (`params.Tx.TxOut[Index].PkScript`), which is the canonical key + // used by the address schema. This is especially important for + // imported or script-based addresses, where wallet ownership is + // defined by the stored script material rather than by one canonical + // encoded address string. + // + // Examples: + // - A standard P2WPKH credit usually has one obvious bech32 address for UI + // display, but the wallet still keys ownership off the exact witness + // program bytes recorded in script_pub_key. + // - An imported script address (`waddrmgr.Script`, + // `waddrmgr.WitnessScript`, or `waddrmgr.TaprootScript`) is owned because + // the wallet imported the script material; the encoded address, if + // any, is only a presentation form of that script. Address btcutil.Address } -// UpdateTxParams contains the parameters for updating a transaction record. -// Fields are pointers to allow for partial updates. -type UpdateTxParams struct { +// UpdateTxLabelParams contains the parameters for updating a transaction label. +type UpdateTxLabelParams struct { // WalletID is the ID of the wallet containing the transaction. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -767,11 +905,10 @@ type UpdateTxParams struct { // Txid is the hash of the transaction to update. Txid chainhash.Hash - // Block is the new block metadata for the transaction. - Block *Block - // Label is the new label for the transaction. - Label *string + // + // The empty string is a valid value and clears any prior label. + Label string } // GetTxQuery contains the parameters for querying a transaction. While a @@ -848,6 +985,8 @@ type UtxoInfo struct { FromCoinBase bool // Height is the block height of the UTXO. + // + // Unconfirmed UTXOs use the sentinel value UnminedHeight. Height uint32 } @@ -871,17 +1010,16 @@ type ListUtxosQuery struct { // databases (signed 64-bit integers). WalletID uint32 - // Account is an optional filter to list UTXOs only for a specific - // account. + // Account is an optional BIP44 account-number filter. Account *uint32 - // MinConfs is the minimum number of confirmations for a UTXO to be - // included. - MinConfs int32 + // MinConfs optionally requires each returned UTXO to have at least this + // many confirmations. + MinConfs *int32 - // MaxConfs is the maximum number of confirmations for a UTXO to be - // included. - MaxConfs int32 + // MaxConfs optionally requires each returned UTXO to have at most this + // many confirmations. + MaxConfs *int32 } // LeaseOutputParams contains the parameters for leasing a UTXO. @@ -929,6 +1067,16 @@ type LeasedOutput struct { Expiration time.Time } +// BalanceResult represents one wallet-scoped balance view after applying the +// requested filters. +type BalanceResult struct { + // Total is the sum of every matching live UTXO, including leased outputs. + Total btcutil.Amount + + // Locked is the subset of Total currently covered by active output leases. + Locked btcutil.Amount +} + // BalanceParams contains the parameters for the Balance method. type BalanceParams struct { // WalletID is the ID of the wallet to query. @@ -937,9 +1085,21 @@ type BalanceParams struct { // databases (signed 64-bit integers). WalletID uint32 - // MinConfirms is the minimum number of confirmations a UTXO must have - // to be included in the balance calculation. - MinConfirms int32 + // Account optionally restricts the balance to one BIP44 account number. + Account *uint32 + + // MinConfs optionally requires each counted output to have at least + // this many confirmations. + MinConfs *int32 + + // MaxConfs optionally requires each counted output to have at most + // this many confirmations. + MaxConfs *int32 + + // CoinbaseMaturity optionally requires coinbase outputs to have at + // least this many confirmations before they count toward the returned + // balance result. Non-coinbase outputs ignore this filter. + CoinbaseMaturity *int32 } // LockID represents a unique context-specific ID assigned to an output lock. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index c8ab13f4df..d83a0afb54 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -3,8 +3,6 @@ package db import ( "context" "errors" - - "github.com/btcsuite/btcd/btcutil" ) var ( @@ -72,6 +70,19 @@ var ( // created within a branch because the address index counter has reached // its maximum representable value. ErrMaxAddressIndexReached = errors.New("max address index reached") + + // ErrTxNotFound is returned when a transaction is not found in the + // database. + ErrTxNotFound = errors.New("transaction not found") + + // ErrUtxoNotFound is returned when a UTXO is not found in the database. + ErrUtxoNotFound = errors.New("utxo not found") + + // ErrTxInputConflict is returned when CreateTx references a wallet-owned + // input that is already spent by another live wallet transaction. + ErrTxInputConflict = errors.New( + "transaction input conflicts with live wallet spend", + ) ) // Store defines the set of database operations used by the wallet. @@ -227,18 +238,36 @@ type AddressStore interface { // TxStore defines the database actions for managing transaction records. type TxStore interface { - // CreateTx atomically records a transaction and its associated credits - // in the database. This is a single atomic operation that also handles - // the corresponding UTXO state changes: it will delete any UTXOs spent - // by the new transaction's inputs and create new UTXOs for any of its - // outputs that are spendable by the wallet. This ensures that the - // transaction record and the UTXO set are always consistent. + // CreateTx atomically records a transaction row and its associated credits + // in the database. This Store-layer API persists already-constructed + // wallet history; it does not build or publish transactions on the + // caller's behalf. + // + // The same write also updates the corresponding UTXO state: it marks any + // wallet-owned outputs referenced by the new transaction's inputs as spent + // and creates new UTXOs for any outputs that are spendable by the wallet. + // This keeps the transaction record and UTXO set consistent. + // + // CreateTx is also responsible for recording transaction metadata + // required by higher-level wallet policy: + // - the received timestamp (stored in UTC), + // - the optional block assignment (confirmed vs unconfirmed), + // - the caller-selected initial transaction status. + // + // The create path does not infer whether an unmined transaction should + // start in TxStatusPending or TxStatusPublished; callers must provide that + // choice explicitly in CreateTxParams. CreateTx(ctx context.Context, params CreateTxParams) error - // UpdateTx updates an existing transaction record in the database. It - // takes a context and UpdateTxParams, returning an error if the + // UpdateTxLabel updates an existing transaction record in the database. It + // takes a context and UpdateTxLabelParams, returning an error if the // transaction cannot be found or updated. - UpdateTx(ctx context.Context, params UpdateTxParams) error + // + // UpdateTxLabel is intentionally narrow: it only updates the user-visible + // label. Block assignment, rollback, and status transitions belong to + // dedicated internal queries used by wallet synchronization and + // replacement handling. + UpdateTxLabel(ctx context.Context, params UpdateTxLabelParams) error // GetTx retrieves a transaction record by its hash. It takes a context // and GetTxQuery, returning a TxInfo struct or an error if the @@ -254,9 +283,15 @@ type TxStore interface { // returning a slice of TxInfo or an error if the retrieval fails. ListTxns(ctx context.Context, query ListTxnsQuery) ([]TxInfo, error) - // DeleteTx removes an unmined transaction from the store. It takes a - // context and DeleteTxParams, returning an error if the transaction is - // not found or the deletion fails. + // DeleteTx removes a live unconfirmed transaction from the store. It + // takes a context and DeleteTxParams, returning an error if the + // transaction is not found or the deletion fails. + // + // DeleteTx is intentionally narrower than a generic + // "delete any transaction" API. Orphaned, replaced, and failed rows remain + // part of the wallet's historical view for audit, reorg, and replacement + // handling and therefore must not be erased through the ordinary + // unconfirmed-deletion path. DeleteTx(ctx context.Context, params DeleteTxParams) error // RollbackToBlock removes all blocks at and after a given height, @@ -268,6 +303,10 @@ type TxStore interface { // approach guarantees that the rollback is either fully completed or // not at all. // + // NOTE: This method has no wallet ID parameter and is therefore a + // DB-wide operation affecting all wallets that share the same `blocks` + // table. + // // TODO(yy): explore performance improvement for this method. RollbackToBlock(ctx context.Context, height uint32) error } @@ -279,36 +318,53 @@ type UTXOStore interface { // GetUtxo retrieves a single unspent transaction output (UTXO) by its // outpoint. It returns a UtxoInfo struct containing the UTXO's details // or an error if the UTXO is not found or has been spent. + // + // GetUtxo treats outputs created by both live unconfirmed states + // (TxStatusPending and TxStatusPublished) as present in the wallet's + // UTXO set. GetUtxo(ctx context.Context, query GetUtxoQuery) (*UtxoInfo, error) // ListUTXOs returns a slice of all unspent transaction outputs (UTXOs) // that match the provided query parameters. This can be used to list // all UTXOs or filter them by account or confirmation status. + // + // ListUTXOs includes outputs from live unconfirmed parent transactions. + // Spendability policy such as coinbase maturity, lease state, or whether + // a caller wants to exclude TxStatusPending parents belongs in higher-level + // selection logic. ListUTXOs(ctx context.Context, query ListUtxosQuery) ([]UtxoInfo, error) // LeaseOutput locks a specific UTXO for a given duration, preventing // it from being used in coin selection. This is useful for reserving // UTXOs for a specific purpose, such as a pending transaction. The // method returns the full lease information, including its expiration - // time. + // time. If another active lease already exists with a different lock ID, + // the call returns ErrOutputAlreadyLeased. LeaseOutput(ctx context.Context, params LeaseOutputParams) ( *LeasedOutput, error) // ReleaseOutput unlocks a previously leased UTXO, making it available - // for coin selection again. The lock ID must match the one used to - // lease the output. + // for coin selection again. Unlocking an already-unlocked or expired lease + // is a no-op. If another active lease still exists for the output, the lock + // ID must match the one that holds it or the call returns + // ErrOutputUnlockNotAllowed. ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error - // ListLeasedOutputs returns a slice of all currently leased UTXOs. - // This can be used to inspect which outputs are currently locked and - // when their leases expire. + // ListLeasedOutputs returns a slice of all currently leased live UTXOs. + // This can be used to inspect which still-unspent outputs are currently + // locked and when their leases expire. ListLeasedOutputs(ctx context.Context, walletID uint32) ( []LeasedOutput, error) - // Balance returns the total spendable balance of the wallet, - // calculated from the UTXO set. The minConfirms parameter specifies - // the minimum number of confirmations a UTXO must have to be included - // in the balance calculation. + // Balance returns a wallet-scoped balance view for the current unspent UTXO + // set after applying any optional caller-supplied filters. + // + // The zero-value BalanceParams request the wallet's live factual balance. + // Callers may narrow that view by account, confirmation range, lease + // or coinbase maturity when they need a workflow-specific balance policy + // from the public store interface. The returned BalanceResult always uses + // the same filtered base set for both Total and Locked so callers can + // reason about lease state without issuing a second balance query. Balance(ctx context.Context, params BalanceParams) ( - btcutil.Amount, error) + BalanceResult, error) } diff --git a/wallet/internal/db/kvdb/utxo.go b/wallet/internal/db/kvdb/utxo.go index 52eb1912a5..295d9dd65f 100644 --- a/wallet/internal/db/kvdb/utxo.go +++ b/wallet/internal/db/kvdb/utxo.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" @@ -100,7 +99,7 @@ func (s *Store) ListLeasedOutputs(ctx context.Context, // Balance is not yet implemented for kvdb. func (s *Store) Balance(ctx context.Context, - _ db.BalanceParams) (btcutil.Amount, error) { + _ db.BalanceParams) (db.BalanceResult, error) { - return 0, notImplemented(ctx, "Balance") + return db.BalanceResult{}, notImplemented(ctx, "Balance") } diff --git a/wallet/mock_test.go b/wallet/mock_test.go index d2de4873d2..c5b95f7c85 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -100,16 +100,16 @@ func (m *mockStore) ListLeasedOutputs(ctx context.Context, // Balance implements the db.UTXOStore interface. func (m *mockStore) Balance(ctx context.Context, - params db.BalanceParams) (btcutil.Amount, error) { + params db.BalanceParams) (db.BalanceResult, error) { args := m.Called(ctx, params) - amount, ok := args.Get(0).(btcutil.Amount) + result, ok := args.Get(0).(db.BalanceResult) if !ok { - return 0, args.Error(1) + return db.BalanceResult{}, args.Error(1) } - return amount, args.Error(1) + return result, args.Error(1) } // mockTxStore is a mock implementation of the wtxmgr.TxStore interface. diff --git a/wallet/tx_publisher.go b/wallet/tx_publisher.go index ceb65e4aa5..8c932a39b8 100644 --- a/wallet/tx_publisher.go +++ b/wallet/tx_publisher.go @@ -185,9 +185,10 @@ func (w *Wallet) checkMempool(ctx context.Context, log.Infof("Tx %v already broadcasted", tx.TxHash()) - // TODO(yy): Add a new method UpdateTxLabel to allow updating - // the label of a tx. With this change, the label passed in - // will be ignored if the tx is already known. + // TODO(yy): Update the Store layer with the caller-supplied + // label when the transaction is already known. With this + // change, the label passed in will be ignored if the tx is + // already known. return errAlreadyBroadcasted // If the backend does not support the mempool acceptance test, we'll From 7db04b62b06fdce88062ede8ba1d8948c8618dec Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:33 +0800 Subject: [PATCH 450/691] wallet: add wtxmgr migrations (ADR 0006) --- .../postgres/000005_accounts.up.sql | 5 +- .../postgres/000006_addresses.up.sql | 5 +- .../postgres/000007_transactions.down.sql | 6 + .../postgres/000007_transactions.up.sql | 167 +++++++++++++++++ .../migrations/postgres/000008_utxos.down.sql | 7 + .../migrations/postgres/000008_utxos.up.sql | 116 ++++++++++++ .../postgres/000009_tx_replacements.down.sql | 4 + .../postgres/000009_tx_replacements.up.sql | 52 ++++++ .../postgres/000010_utxo_leases.down.sql | 7 + .../postgres/000010_utxo_leases.up.sql | 54 ++++++ .../sqlite/000007_transactions.down.sql | 5 + .../sqlite/000007_transactions.up.sql | 170 ++++++++++++++++++ .../migrations/sqlite/000008_utxos.down.sql | 4 + .../db/migrations/sqlite/000008_utxos.up.sql | 126 +++++++++++++ .../sqlite/000009_tx_replacements.down.sql | 4 + .../sqlite/000009_tx_replacements.up.sql | 55 ++++++ .../sqlite/000010_utxo_leases.down.sql | 4 + .../sqlite/000010_utxo_leases.up.sql | 53 ++++++ 18 files changed, 840 insertions(+), 4 deletions(-) create mode 100644 wallet/internal/db/migrations/postgres/000007_transactions.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000007_transactions.up.sql create mode 100644 wallet/internal/db/migrations/postgres/000008_utxos.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000008_utxos.up.sql create mode 100644 wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql create mode 100644 wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql create mode 100644 wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000007_transactions.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000007_transactions.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000008_utxos.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000008_utxos.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql create mode 100644 wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql create mode 100644 wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql index 5108396780..cea4682d93 100644 --- a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql +++ b/wallet/internal/db/migrations/postgres/000005_accounts.up.sql @@ -61,8 +61,9 @@ CREATE TABLE accounts ( -- Encrypted public key for the account. encrypted_public_key BYTEA, - -- Timestamp when the account was created. Automatically set by the database. - created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + -- Timestamp when the account was created. Automatically set by the database + -- in UTC. + created_at TIMESTAMP NOT NULL DEFAULT (current_timestamp AT TIME ZONE 'UTC'), -- Next index to use for external addresses (branch 0) next_external_index BIGINT NOT NULL DEFAULT 0, diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 3e97d62d35..2c13ad1a4b 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -37,8 +37,9 @@ CREATE TABLE addresses ( -- account key. pub_key BYTEA, - -- Timestamp when the address was created. Automatically set by the database. - created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + -- Timestamp when the address was created. Automatically set by the database + -- in UTC. + created_at TIMESTAMP NOT NULL DEFAULT (current_timestamp AT TIME ZONE 'UTC'), -- Branch and index are set together for HD-derived addresses and both -- NULL for imported addresses. diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.down.sql b/wallet/internal/db/migrations/postgres/000007_transactions.down.sql new file mode 100644 index 0000000000..e2b1d19e27 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000007_transactions.down.sql @@ -0,0 +1,6 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TRIGGER IF EXISTS trg_set_coinbase_orphaned_on_disconnect ON transactions; +DROP FUNCTION IF EXISTS set_coinbase_orphaned_on_disconnect(); +DROP TABLE IF EXISTS transactions; diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.up.sql b/wallet/internal/db/migrations/postgres/000007_transactions.up.sql new file mode 100644 index 0000000000..c7bd9b5f16 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000007_transactions.up.sql @@ -0,0 +1,167 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Transactions table stores wallet-scoped blockchain transactions and their +-- wallet-relative validity/confirmation state. +CREATE TABLE transactions ( + -- Reference to the wallet that owns this transaction row. + wallet_id BIGINT NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- DB ID of the transaction, primary key. + id BIGSERIAL PRIMARY KEY, + + -- Secondary unique constraint used as the referenced key for wallet-scoped + -- child relations such as utxos and tx_replacements. + CONSTRAINT uidx_transactions_wallet_id_id UNIQUE (wallet_id, id), + + -- Transaction hash (txid) (32 bytes). Unique per wallet. + tx_hash BYTEA NOT NULL CHECK (length(tx_hash) = 32), + + -- Raw serialized transaction bytes. + -- + -- The SQL schema does not store a fully normalized input/output graph for + -- every transaction. Persisting raw_tx lets read paths reconstruct the full + -- wire.MsgTx when callers need the serialized transaction or when rollback / + -- invalidation walks need to inspect transaction inputs. + -- + -- NOTE: Hot-path queries (balance/coin selection) SHOULD avoid selecting + -- this column. + raw_tx BYTEA NOT NULL, + + -- Confirmation state: + -- NULL = Unconfirmed (mempool) + -- INT = Confirmed (mined) + -- + -- ON DELETE SET NULL: If a block is reorged, the transaction becomes + -- unconfirmed. + block_height INTEGER REFERENCES blocks (block_height) ON DELETE SET NULL, + + -- Validity state (soft deletion). + -- + -- Store the status code inline instead of via a lookup table because this + -- enum is tiny, closed, and appears on hot-path predicates/indexes. + -- + -- Status codes: + -- 0 = pending + -- 1 = published + -- 2 = replaced + -- 3 = failed + -- 4 = orphaned + tx_status SMALLINT NOT NULL, + + -- Absolute wall clock time, supplied by the caller and stored in UTC + -- without timezone info. + -- + -- NOTE: There is intentionally no DEFAULT current_timestamp here because + -- import/recovery flows may need to preserve the wallet-observed receive + -- time instead of the row insertion time. + received_time TIMESTAMP NOT NULL, + + -- Whether this transaction is a coinbase transaction. + is_coinbase BOOLEAN NOT NULL DEFAULT FALSE, + + -- Optional user-provided label. Empty string means "no label". + -- + tx_label VARCHAR(500) NOT NULL DEFAULT '', + + -- Wallet-scoped uniqueness lets different wallets record the same network + -- txid independently while keeping every child lookup anchored to one + -- wallet. + CONSTRAINT uidx_transactions_hash UNIQUE (wallet_id, tx_hash), + + -- Keep the persisted validity state closed over the finite set of states + -- the store knows how to interpret and transition between. + CONSTRAINT valid_status CHECK ( + tx_status IN (0, 1, 2, 3, 4) + ), + + -- Non-coinbase transactions cannot enter the orphaned state. That state is + -- reserved for coinbase rows that were disconnected from the best chain. + CONSTRAINT check_orphaned_coinbase_only CHECK ( + tx_status != 4 OR is_coinbase + ), + + -- A transaction attached to a block is treated as confirmed wallet history. + -- For confirmed rows, the only valid status is `published`; every other + -- status represents either blockless local state or disconnected history. + CONSTRAINT check_confirmed_published CHECK ( + block_height IS NULL OR tx_status = 1 + ), + + -- Coinbase transactions cannot exist in the local-only pre-broadcast state + -- because they are created by mining, not by wallet authorship. + CONSTRAINT check_coinbase_not_pending CHECK ( + NOT (is_coinbase AND tx_status = 0) + ), + + -- Coinbase rows may only be recorded in their mined form or in the + -- orphaned form produced by a disconnect/reorg transition. + CONSTRAINT check_coinbase_confirmation_state CHECK ( + NOT is_coinbase + OR (block_height IS NOT NULL AND tx_status = 1) + OR (block_height IS NULL AND tx_status = 4) + ) +); + +-- Optimization for live unconfirmed transaction lookups. +CREATE INDEX idx_transactions_unconfirmed +ON transactions (wallet_id, block_height) +WHERE block_height IS NULL AND tx_status IN (0, 1); + +-- Optimization for wallet-scoped joins into the live transaction set. +CREATE INDEX idx_transactions_live_by_wallet +ON transactions (wallet_id, id) +WHERE tx_status IN (0, 1); + +-- Optimization for wallet-scoped blockless history reads ordered by newest +-- receive time first. +CREATE INDEX idx_transactions_blockless_history +ON transactions (wallet_id, received_time DESC, id DESC) +WHERE block_height IS NULL; + +-- Optimization for "all transactions in block X" queries. +CREATE INDEX idx_transactions_by_block +ON transactions (wallet_id, block_height) +WHERE block_height IS NOT NULL; + +-- Optimization for rollback/disconnect paths that only know the confirmed block +-- height and then fan out to affected wallet rows. +CREATE INDEX idx_transactions_by_confirmed_height +ON transactions (block_height, wallet_id, id) +WHERE block_height IS NOT NULL; + +-- Optimization for "latest transactions" queries. +CREATE INDEX idx_transactions_by_received_time +ON transactions (wallet_id, received_time DESC); + +-- Reorg handling for coinbase transactions. +-- +-- PostgreSQL checks CHECK constraints immediately. When a block is deleted, the +-- FK `ON DELETE SET NULL` action rewrites child `transactions.block_height` +-- values to NULL. Coinbase rows cannot stay in `published` once that happens, +-- because the schema requires: +-- - coinbase + confirmed block => status = 1 (`published`) +-- - coinbase + no block => status = 4 (`orphaned`) +-- +-- PostgreSQL can solve this on the child-row update path. The FK action causes +-- a real `UPDATE OF block_height ON transactions`, so a BEFORE UPDATE trigger +-- can rewrite the same child row from `(published, block)` to +-- `(orphaned, NULL block)` before the new version is checked. +CREATE FUNCTION set_coinbase_orphaned_on_disconnect() RETURNS TRIGGER AS $$ +BEGIN + -- Detect the disconnect transition caused by the FK action on block delete. + IF NEW.block_height IS NULL AND OLD.block_height IS NOT NULL + AND NEW.is_coinbase THEN + -- Only coinbase rows need rewriting here. Ordinary transactions may + -- become unconfirmed while keeping their existing non-orphaned status. + NEW.tx_status := 4; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_set_coinbase_orphaned_on_disconnect +BEFORE UPDATE OF block_height ON transactions +FOR EACH ROW +EXECUTE FUNCTION set_coinbase_orphaned_on_disconnect(); diff --git a/wallet/internal/db/migrations/postgres/000008_utxos.down.sql b/wallet/internal/db/migrations/postgres/000008_utxos.down.sql new file mode 100644 index 0000000000..ad811f4d6b --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000008_utxos.down.sql @@ -0,0 +1,7 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TRIGGER IF EXISTS trg_assert_utxo_wallet_consistency_insert ON utxos; +DROP TRIGGER IF EXISTS trg_assert_utxo_wallet_consistency_update ON utxos; +DROP FUNCTION IF EXISTS assert_utxo_wallet_consistency(); +DROP TABLE IF EXISTS utxos; diff --git a/wallet/internal/db/migrations/postgres/000008_utxos.up.sql b/wallet/internal/db/migrations/postgres/000008_utxos.up.sql new file mode 100644 index 0000000000..09d73e10ce --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000008_utxos.up.sql @@ -0,0 +1,116 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- UTXOs table stores wallet-owned credits (spent and unspent). +CREATE TABLE utxos ( + -- DB ID of the UTXO, primary key. + id BIGSERIAL PRIMARY KEY, + + -- Creation outpoint (tx_id + output_index). + tx_id BIGINT NOT NULL, + output_index INTEGER NOT NULL CHECK (output_index >= 0), + + -- Output amount in satoshis. + amount BIGINT NOT NULL CHECK (amount >= 0), + + -- Reference to the address record that owns the output. + -- + -- NOTE: The address-manager schema does not expose wallet_id on addresses, + -- so ownership is derived via addresses -> accounts -> key_scopes and + -- enforced by trigger below. + address_id BIGINT NOT NULL REFERENCES addresses (id) ON DELETE RESTRICT, + + -- Spending input (when spent). + spent_by_tx_id BIGINT, + spent_input_index INTEGER CHECK ( + spent_input_index IS NULL OR spent_input_index >= 0 + ), + + -- The creating transaction anchors the outpoint to one wallet-scoped + -- transaction history. + CONSTRAINT fkey_utxos_tx FOREIGN KEY (tx_id) + REFERENCES transactions (id) ON DELETE RESTRICT, + + -- Manual pruning note: + -- The reference ADR uses ON DELETE SET NULL here to restore spendability + -- when the spending transaction is physically deleted. This repository + -- uses ON DELETE RESTRICT and requires an explicit pruning operation that + -- clears spent_by_* first. + CONSTRAINT fkey_utxos_spent_by FOREIGN KEY (spent_by_tx_id) + REFERENCES transactions (id) ON DELETE RESTRICT, + + -- spent_by_tx_id and spent_input_index together model one logical pointer + -- to the spending input, so they must transition between NULL and non-NULL + -- as a pair. + CONSTRAINT check_spent_tx_and_index_pair CHECK ( + (spent_by_tx_id IS NULL AND spent_input_index IS NULL) + OR (spent_by_tx_id IS NOT NULL AND spent_input_index IS NOT NULL) + ), + + -- Each wallet-local transaction records a given network outpoint at most + -- once, which keeps credit insertion idempotent and lets outpoint lookups + -- resolve to one row. + CONSTRAINT uidx_utxos_outpoint UNIQUE (tx_id, output_index) +); + +-- Optimization for balance queries (index-only scan). +CREATE INDEX idx_utxos_unspent +ON utxos (tx_id, amount, output_index) +WHERE spent_by_tx_id IS NULL; + +-- Optimization for listing all UTXOs for an address (including spent). +CREATE INDEX idx_utxos_by_address ON utxos (address_id); + +-- Optimization for finding inputs (debits) of a transaction. +CREATE INDEX idx_utxos_spent_by ON utxos (spent_by_tx_id); + +-- Optimization for listing all outputs of a transaction. +CREATE INDEX idx_utxos_by_tx ON utxos (tx_id); + +CREATE FUNCTION assert_utxo_wallet_consistency() RETURNS TRIGGER AS $$ +DECLARE + creating_wallet_id BIGINT; + address_wallet_id BIGINT; + spending_wallet_id BIGINT; +BEGIN + SELECT t.wallet_id INTO creating_wallet_id + FROM transactions AS t + WHERE t.id = NEW.tx_id; + + SELECT ks.wallet_id INTO address_wallet_id + FROM addresses AS a + INNER JOIN accounts AS acc ON a.account_id = acc.id + INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id + WHERE a.id = NEW.address_id; + + IF creating_wallet_id IS NOT NULL + AND address_wallet_id IS NOT NULL + AND creating_wallet_id != address_wallet_id THEN + RAISE EXCEPTION 'utxo creating tx wallet and address wallet must match'; + END IF; + + IF NEW.spent_by_tx_id IS NOT NULL THEN + SELECT t.wallet_id INTO spending_wallet_id + FROM transactions AS t + WHERE t.id = NEW.spent_by_tx_id; + + IF creating_wallet_id IS NOT NULL + AND spending_wallet_id IS NOT NULL + AND creating_wallet_id != spending_wallet_id THEN + RAISE EXCEPTION 'utxo spending tx wallet must match creating tx wallet'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_assert_utxo_wallet_consistency_insert +BEFORE INSERT ON utxos +FOR EACH ROW +EXECUTE FUNCTION assert_utxo_wallet_consistency(); + +CREATE TRIGGER trg_assert_utxo_wallet_consistency_update +BEFORE UPDATE OF tx_id, address_id, spent_by_tx_id ON utxos +FOR EACH ROW +EXECUTE FUNCTION assert_utxo_wallet_consistency(); diff --git a/wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql b/wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql new file mode 100644 index 0000000000..19f69ebbc9 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TABLE IF EXISTS tx_replacements; diff --git a/wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql b/wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql new file mode 100644 index 0000000000..0f4ba63f67 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql @@ -0,0 +1,52 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- tx_replacements stores the audit edges between replaced and replacement +-- wallet-scoped transactions. +CREATE TABLE tx_replacements ( + -- Reference to the wallet that owns this replacement edge. + wallet_id BIGINT NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- DB ID of the replacement edge. + id BIGSERIAL NOT NULL, + + -- The direct victim transaction in the replacement pair. + replaced_tx_id BIGINT NOT NULL, + + -- The direct winner transaction in the replacement pair. + replacement_tx_id BIGINT NOT NULL, + + -- Creation timestamp used for replacement-edge traversal ordering. + created_at TIMESTAMP NOT NULL DEFAULT (current_timestamp AT TIME ZONE 'UTC'), + + -- Composite primary key is intentional: it keeps the audit row wallet- + -- scoped and leaves room for wallet-scoped foreign keys if they are needed + -- later. + CONSTRAINT pidx_tx_replacements PRIMARY KEY (wallet_id, id), + + -- The audit edge must stay inside one wallet-scoped transaction graph. + CONSTRAINT fkey_tx_replacements_replaced FOREIGN KEY ( + wallet_id, replaced_tx_id + ) REFERENCES transactions (wallet_id, id) ON DELETE CASCADE, + CONSTRAINT fkey_tx_replacements_replacement FOREIGN KEY ( + wallet_id, replacement_tx_id + ) REFERENCES transactions (wallet_id, id) ON DELETE CASCADE, + + -- A transaction cannot replace itself. + CONSTRAINT check_not_self_replacement CHECK ( + replaced_tx_id != replacement_tx_id + ), + + -- One directed replacement edge may only be recorded once. + CONSTRAINT uidx_tx_replacements_edge UNIQUE ( + wallet_id, replaced_tx_id, replacement_tx_id + ) +); + +-- Optimization for traversing direct victims from a winner. +CREATE INDEX idx_tx_replacements_by_replacement +ON tx_replacements (wallet_id, replacement_tx_id, created_at, id); + +-- Optimization for traversing direct winners from a victim. +CREATE INDEX idx_tx_replacements_by_replaced +ON tx_replacements (wallet_id, replaced_tx_id, created_at, id); diff --git a/wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql b/wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql new file mode 100644 index 0000000000..4b6734c6e2 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql @@ -0,0 +1,7 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TRIGGER IF EXISTS trg_assert_utxo_lease_wallet_consistency_insert ON utxo_leases; +DROP TRIGGER IF EXISTS trg_assert_utxo_lease_wallet_consistency_update ON utxo_leases; +DROP FUNCTION IF EXISTS assert_utxo_lease_wallet_consistency(); +DROP TABLE IF EXISTS utxo_leases; diff --git a/wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql b/wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql new file mode 100644 index 0000000000..eac87ab666 --- /dev/null +++ b/wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql @@ -0,0 +1,54 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- utxo_leases stores transient application-level locks over wallet-owned UTXOs. +CREATE TABLE utxo_leases ( + -- Reference to the wallet that owns the leased UTXO. + wallet_id BIGINT NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- The leased UTXO row. + utxo_id BIGINT PRIMARY KEY, + + -- Caller-provided lock ID. It must stay fixed-width so lease ownership can + -- be compared without decoding application-specific payloads. + lock_id BYTEA NOT NULL CHECK (length(lock_id) = 32), + + -- UTC-normalized lease expiration timestamp. + expires_at TIMESTAMP NOT NULL, + + -- The leased output must exist in the UTXO set. Wallet consistency is + -- enforced by trigger below. + CONSTRAINT fkey_utxo_leases_utxo FOREIGN KEY (utxo_id) + REFERENCES utxos (id) ON DELETE CASCADE +); + +-- Optimization for wallet-scoped lease cleanup and active-lease scans. +CREATE INDEX idx_utxo_leases_wallet_expires_at +ON utxo_leases (wallet_id, expires_at); + +CREATE FUNCTION assert_utxo_lease_wallet_consistency() RETURNS TRIGGER AS $$ +DECLARE + utxo_wallet_id BIGINT; +BEGIN + SELECT t.wallet_id INTO utxo_wallet_id + FROM utxos AS u + INNER JOIN transactions AS t ON u.tx_id = t.id + WHERE u.id = NEW.utxo_id; + + IF utxo_wallet_id IS NOT NULL AND NEW.wallet_id != utxo_wallet_id THEN + RAISE EXCEPTION 'utxo lease wallet must match leased utxo wallet'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_assert_utxo_lease_wallet_consistency_insert +BEFORE INSERT ON utxo_leases +FOR EACH ROW +EXECUTE FUNCTION assert_utxo_lease_wallet_consistency(); + +CREATE TRIGGER trg_assert_utxo_lease_wallet_consistency_update +BEFORE UPDATE OF wallet_id, utxo_id ON utxo_leases +FOR EACH ROW +EXECUTE FUNCTION assert_utxo_lease_wallet_consistency(); diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.down.sql b/wallet/internal/db/migrations/sqlite/000007_transactions.down.sql new file mode 100644 index 0000000000..3def684c18 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000007_transactions.down.sql @@ -0,0 +1,5 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TRIGGER IF EXISTS trg_disconnect_transactions_before_block_delete; +DROP TABLE IF EXISTS transactions; diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql b/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql new file mode 100644 index 0000000000..e6c9194b82 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql @@ -0,0 +1,170 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- Transactions table stores wallet-scoped blockchain transactions and their +-- wallet-relative validity/confirmation state. +CREATE TABLE transactions ( + -- Reference to the wallet that owns this transaction row. + wallet_id INTEGER NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- DB ID of the transaction, primary key (rowid-backed). + id INTEGER PRIMARY KEY, + + -- Transaction hash (txid) (32 bytes). Unique per wallet. + tx_hash BLOB NOT NULL CHECK (length(tx_hash) = 32), + + -- Raw serialized transaction bytes. + -- + -- NOTE: Hot-path queries (balance/coin selection) SHOULD avoid selecting + -- this column. + raw_tx BLOB NOT NULL, + + -- Confirmation state: + -- NULL = Unconfirmed (mempool) + -- INT = Confirmed (mined) + -- + -- ON DELETE SET NULL: If a block is reorged, the transaction becomes + -- unconfirmed. + block_height INTEGER REFERENCES blocks (block_height) ON DELETE SET NULL, + + -- Validity state (soft deletion). + -- + -- Store the status code inline instead of via a lookup table because this + -- enum is tiny, closed, and appears on hot-path predicates/indexes. + -- + -- Status codes: + -- 0 = pending + -- 1 = published + -- 2 = replaced + -- 3 = failed + -- 4 = orphaned + tx_status INTEGER NOT NULL, + + -- Absolute wall clock time, supplied by the caller and stored in UTC + -- without timezone info. + -- + -- NOTE: There is intentionally no DEFAULT current_timestamp here because + -- import/recovery flows may need to preserve the wallet-observed receive + -- time instead of the row insertion time. + received_time DATETIME NOT NULL, + + -- Whether this transaction is a coinbase transaction. + is_coinbase BOOLEAN NOT NULL DEFAULT FALSE, + + -- Optional user-provided label. Empty string means "no label". + -- SQLite treats VARCHAR(N) like TEXT, so we use TEXT plus an explicit + -- CHECK to enforce the shared 500-character limit. + tx_label TEXT NOT NULL DEFAULT '' + CHECK (length(tx_label) <= 500), + + -- Secondary unique constraint used as the referenced key for wallet-scoped + -- child relations such as utxos and tx_replacements. + CONSTRAINT uidx_transactions_wallet_id_id UNIQUE (wallet_id, id), + + -- Wallet-scoped uniqueness lets different wallets record the same network + -- txid independently while keeping every child lookup anchored to one + -- wallet. + CONSTRAINT uidx_transactions_hash UNIQUE (wallet_id, tx_hash), + + -- Keep the persisted validity state closed over the finite set of states + -- the store knows how to interpret and transition between. + CONSTRAINT valid_status CHECK ( + tx_status IN (0, 1, 2, 3, 4) + ), + + -- Non-coinbase transactions cannot enter the orphaned state. That state is + -- reserved for coinbase rows that were disconnected from the best chain. + CONSTRAINT check_orphaned_coinbase_only CHECK ( + tx_status != 4 OR is_coinbase + ), + + -- A transaction attached to a block is treated as confirmed wallet history. + -- For confirmed rows, the only valid status is `published`; every other + -- status represents either blockless local state or disconnected history. + CONSTRAINT check_confirmed_published CHECK ( + block_height IS NULL OR tx_status = 1 + ), + + -- Coinbase transactions cannot exist in the local-only pre-broadcast state + -- because they are created by mining, not by wallet authorship. + CONSTRAINT check_coinbase_not_pending CHECK ( + NOT (is_coinbase AND tx_status = 0) + ), + + -- Coinbase rows may only be recorded in their mined form or in the + -- orphaned form produced by a disconnect/reorg transition. + CONSTRAINT check_coinbase_confirmation_state CHECK ( + NOT is_coinbase + OR (block_height IS NOT NULL AND tx_status = 1) + OR (block_height IS NULL AND tx_status = 4) + ) +); + +-- Optimization for live unconfirmed transaction lookups. +CREATE INDEX idx_transactions_unconfirmed +ON transactions (wallet_id, block_height) +WHERE block_height IS NULL AND tx_status IN (0, 1); + +-- Optimization for wallet-scoped joins into the live transaction set. +CREATE INDEX idx_transactions_live_by_wallet +ON transactions (wallet_id, id) +WHERE tx_status IN (0, 1); + +-- Optimization for wallet-scoped blockless history reads ordered by newest +-- receive time first. +CREATE INDEX idx_transactions_blockless_history +ON transactions (wallet_id, received_time DESC, id DESC) +WHERE block_height IS NULL; + +-- Optimization for "all transactions in block X" queries. +CREATE INDEX idx_transactions_by_block +ON transactions (wallet_id, block_height) +WHERE block_height IS NOT NULL; + +-- Optimization for rollback/disconnect paths that only know the confirmed block +-- height and then fan out to affected wallet rows. +CREATE INDEX idx_transactions_by_confirmed_height +ON transactions (block_height, wallet_id, id) +WHERE block_height IS NOT NULL; + +-- Optimization for "latest transactions" queries. +CREATE INDEX idx_transactions_by_received_time +ON transactions (wallet_id, received_time DESC); + +-- Reorg handling for coinbase transactions. +-- +-- SQLite evaluates this disconnect path differently from PostgreSQL. The block +-- delete would clear child `transactions.block_height` values through the FK +-- action, but SQLite checks row validity before a child-side AFTER UPDATE hook +-- can repair coinbase status. We therefore rewrite affected child rows from the +-- parent-table delete path instead. +-- +-- The invariant is the same as PostgreSQL: +-- - coinbase + confirmed block => status = 1 (`published`) +-- - coinbase + no block => status = 4 (`orphaned`) +-- +-- The BEFORE DELETE trigger on blocks updates child rows into their final +-- disconnected shape before the parent block row is removed. +-- +-- NOTE: Generic per-row block disconnects are intentionally unsupported here. +-- Wallet code should use dedicated rollback/rewind operations instead of +-- `UPDATE transactions SET block_height = NULL`. +CREATE TRIGGER trg_disconnect_transactions_before_block_delete +BEFORE DELETE ON blocks +FOR EACH ROW +BEGIN + -- Rewrite every child transaction that points at the deleted block height. + UPDATE transactions + SET + -- Coinbase rows must become orphaned in the same UPDATE that clears + -- block_height so the CHECK constraints never observe an impossible + -- "coinbase + NULL block + published" combination. + tx_status = CASE + WHEN is_coinbase THEN 4 + -- Ordinary transactions just become blockless; their non-orphaned + -- status is preserved for later rollback/invalidation handling. + ELSE tx_status + END, + block_height = NULL + WHERE block_height = old.block_height; +END; diff --git a/wallet/internal/db/migrations/sqlite/000008_utxos.down.sql b/wallet/internal/db/migrations/sqlite/000008_utxos.down.sql new file mode 100644 index 0000000000..3803684116 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000008_utxos.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TABLE IF EXISTS utxos; diff --git a/wallet/internal/db/migrations/sqlite/000008_utxos.up.sql b/wallet/internal/db/migrations/sqlite/000008_utxos.up.sql new file mode 100644 index 0000000000..4b78130819 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000008_utxos.up.sql @@ -0,0 +1,126 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- UTXOs table stores wallet-owned credits (spent and unspent). +CREATE TABLE utxos ( + -- DB ID of the UTXO, primary key (rowid-backed). + id INTEGER PRIMARY KEY, + + -- Creation outpoint (tx_id + output_index). + tx_id INTEGER NOT NULL, + output_index INTEGER NOT NULL CHECK (output_index >= 0), + + -- Output amount in satoshis. + amount INTEGER NOT NULL CHECK (amount >= 0), + + -- Reference to the address record that owns the output. + -- + -- NOTE: The address-manager schema does not expose wallet_id on addresses, + -- so ownership is derived via addresses -> accounts -> key_scopes and + -- enforced by trigger below. + address_id INTEGER NOT NULL REFERENCES addresses (id) ON DELETE RESTRICT, + + -- Spending input (when spent). + spent_by_tx_id INTEGER, + spent_input_index INTEGER CHECK ( + spent_input_index IS NULL OR spent_input_index >= 0 + ), + + -- The creating transaction anchors the outpoint to one wallet-scoped + -- transaction history. + CONSTRAINT fkey_utxos_tx FOREIGN KEY (tx_id) + REFERENCES transactions (id) ON DELETE RESTRICT, + + -- Manual pruning note: + -- The reference ADR uses ON DELETE SET NULL here to restore spendability + -- when the spending transaction is physically deleted. This repository + -- uses ON DELETE RESTRICT and requires an explicit pruning operation that + -- clears spent_by_* first. + CONSTRAINT fkey_utxos_spent_by FOREIGN KEY (spent_by_tx_id) + REFERENCES transactions (id) ON DELETE RESTRICT, + + -- spent_by_tx_id and spent_input_index together model one logical pointer + -- to the spending input, so they must transition between NULL and non-NULL + -- as a pair. + CONSTRAINT check_spent_tx_and_index_pair CHECK ( + (spent_by_tx_id IS NULL AND spent_input_index IS NULL) + OR (spent_by_tx_id IS NOT NULL AND spent_input_index IS NOT NULL) + ), + + -- Each wallet-local transaction records a given network outpoint at most + -- once, which keeps credit insertion idempotent and lets outpoint lookups + -- resolve to one row. + CONSTRAINT uidx_utxos_outpoint UNIQUE (tx_id, output_index) +); + +-- Optimization for balance queries (index-only scan). +CREATE INDEX idx_utxos_unspent +ON utxos (tx_id, amount, output_index) +WHERE spent_by_tx_id IS NULL; + +-- Optimization for listing all UTXOs for an address (including spent). +CREATE INDEX idx_utxos_by_address ON utxos (address_id); + +-- Optimization for finding inputs (debits) of a transaction. +CREATE INDEX idx_utxos_spent_by ON utxos (spent_by_tx_id); + +-- Optimization for listing all outputs of a transaction. +CREATE INDEX idx_utxos_by_tx ON utxos (tx_id); + +CREATE TRIGGER trg_assert_utxo_wallet_consistency_insert +BEFORE INSERT ON utxos +FOR EACH ROW +BEGIN + SELECT raise(ABORT, 'utxo creating tx wallet and address wallet must match') + WHERE ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.tx_id + ) != ( + SELECT ks.wallet_id + FROM addresses AS a + INNER JOIN accounts AS acc ON a.account_id = acc.id + INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id + WHERE a.id = new.address_id + ); + + SELECT raise(ABORT, 'utxo spending tx wallet must match creating tx wallet') + WHERE new.spent_by_tx_id IS NOT NULL AND ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.spent_by_tx_id + ) != ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.tx_id + ); +END; + +CREATE TRIGGER trg_assert_utxo_wallet_consistency_update +BEFORE UPDATE OF tx_id, address_id, spent_by_tx_id ON utxos +FOR EACH ROW +BEGIN + SELECT raise(ABORT, 'utxo creating tx wallet and address wallet must match') + WHERE ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.tx_id + ) != ( + SELECT ks.wallet_id + FROM addresses AS a + INNER JOIN accounts AS acc ON a.account_id = acc.id + INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id + WHERE a.id = new.address_id + ); + + SELECT raise(ABORT, 'utxo spending tx wallet must match creating tx wallet') + WHERE new.spent_by_tx_id IS NOT NULL AND ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.spent_by_tx_id + ) != ( + SELECT t.wallet_id + FROM transactions AS t + WHERE t.id = new.tx_id + ); +END; diff --git a/wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql b/wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql new file mode 100644 index 0000000000..19f69ebbc9 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TABLE IF EXISTS tx_replacements; diff --git a/wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql b/wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql new file mode 100644 index 0000000000..c074b43d45 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql @@ -0,0 +1,55 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- tx_replacements stores the audit edges between replaced and replacement +-- wallet-scoped transactions. +CREATE TABLE tx_replacements ( + -- Reference to the wallet that owns this replacement edge. + wallet_id INTEGER NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- DB ID of the replacement edge. + -- + -- SQLite only auto-generates row IDs for a single-column INTEGER PRIMARY + -- KEY, so this branch uses a rowid-backed key here and keeps the + -- wallet-scoped `(wallet_id, id)` pair unique through a separate + -- constraint. + id INTEGER PRIMARY KEY, + + -- The direct victim transaction in the replacement pair. + replaced_tx_id INTEGER NOT NULL, + + -- The direct winner transaction in the replacement pair. + replacement_tx_id INTEGER NOT NULL, + + -- Creation timestamp used for replacement-edge traversal ordering. + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, + + -- Secondary unique constraint used for wallet-scoped foreign keys. + CONSTRAINT uidx_tx_replacements_wallet_id_id UNIQUE (wallet_id, id), + + -- The audit edge must stay inside one wallet-scoped transaction graph. + CONSTRAINT fkey_tx_replacements_replaced FOREIGN KEY ( + wallet_id, replaced_tx_id + ) REFERENCES transactions (wallet_id, id) ON DELETE CASCADE, + CONSTRAINT fkey_tx_replacements_replacement FOREIGN KEY ( + wallet_id, replacement_tx_id + ) REFERENCES transactions (wallet_id, id) ON DELETE CASCADE, + + -- A transaction cannot replace itself. + CONSTRAINT check_not_self_replacement CHECK ( + replaced_tx_id != replacement_tx_id + ), + + -- One directed replacement edge may only be recorded once. + CONSTRAINT uidx_tx_replacements_edge UNIQUE ( + wallet_id, replaced_tx_id, replacement_tx_id + ) +); + +-- Optimization for traversing direct victims from a winner. +CREATE INDEX idx_tx_replacements_by_replacement +ON tx_replacements (wallet_id, replacement_tx_id, created_at, id); + +-- Optimization for traversing direct winners from a victim. +CREATE INDEX idx_tx_replacements_by_replaced +ON tx_replacements (wallet_id, replaced_tx_id, created_at, id); diff --git a/wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql b/wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql new file mode 100644 index 0000000000..9752486ade --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql @@ -0,0 +1,4 @@ +-- Rollback note: Idempotent by design (using "IF EXISTS"). +-- Must succeed even if objects are already dropped or database is in an +-- unexpected state. +DROP TABLE IF EXISTS utxo_leases; diff --git a/wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql b/wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql new file mode 100644 index 0000000000..2917ef2905 --- /dev/null +++ b/wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql @@ -0,0 +1,53 @@ +-- Migration note: Intentionally NOT idempotent (no "IF NOT EXISTS"). +-- This ensures migration tracking stays accurate and fails loudly if run twice. + +-- utxo_leases stores transient application-level locks over wallet-owned UTXOs. +CREATE TABLE utxo_leases ( + -- Reference to the wallet that owns the leased UTXO. + wallet_id INTEGER NOT NULL REFERENCES wallets (id) ON DELETE RESTRICT, + + -- The leased UTXO row. + utxo_id INTEGER PRIMARY KEY, + + -- Caller-provided lock ID. It must stay fixed-width so lease ownership can + -- be compared without decoding application-specific payloads. + lock_id BLOB NOT NULL CHECK (length(lock_id) = 32), + + -- UTC-normalized lease expiration timestamp. + expires_at TIMESTAMP NOT NULL, + + -- The leased output must exist in the UTXO set. Wallet consistency is + -- enforced by trigger below. + CONSTRAINT fkey_utxo_leases_utxo FOREIGN KEY (utxo_id) + REFERENCES utxos (id) ON DELETE CASCADE +); + +-- Optimization for wallet-scoped lease cleanup and active-lease scans. +CREATE INDEX idx_utxo_leases_wallet_expires_at +ON utxo_leases (wallet_id, expires_at); + +CREATE TRIGGER trg_assert_utxo_lease_wallet_consistency_insert +BEFORE INSERT ON utxo_leases +FOR EACH ROW +BEGIN + SELECT raise(ABORT, 'utxo lease wallet must match leased utxo wallet') + WHERE ( + SELECT t.wallet_id + FROM utxos AS u + INNER JOIN transactions AS t ON u.tx_id = t.id + WHERE u.id = new.utxo_id + ) != new.wallet_id; +END; + +CREATE TRIGGER trg_assert_utxo_lease_wallet_consistency_update +BEFORE UPDATE OF wallet_id, utxo_id ON utxo_leases +FOR EACH ROW +BEGIN + SELECT raise(ABORT, 'utxo lease wallet must match leased utxo wallet') + WHERE ( + SELECT t.wallet_id + FROM utxos AS u + INNER JOIN transactions AS t ON u.tx_id = t.id + WHERE u.id = new.utxo_id + ) != new.wallet_id; +END; From 4adab47bf63154ecf49eab2938f3fe63dbf6d42c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:43 +0800 Subject: [PATCH 451/691] wallet: add sqlc queries for transactions --- .../db/queries/postgres/transactions.sql | 274 ++++++++++++++++++ .../internal/db/queries/postgres/wallets.sql | 6 +- .../db/queries/sqlite/transactions.sql | 273 +++++++++++++++++ 3 files changed, 550 insertions(+), 3 deletions(-) create mode 100644 wallet/internal/db/queries/postgres/transactions.sql create mode 100644 wallet/internal/db/queries/sqlite/transactions.sql diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/db/queries/postgres/transactions.sql new file mode 100644 index 0000000000..0f41958652 --- /dev/null +++ b/wallet/internal/db/queries/postgres/transactions.sql @@ -0,0 +1,274 @@ +-- name: InsertTransaction :one +-- Inserts a wallet-scoped transaction row and returns its database ID. +-- +-- How: +-- - Writes only the transactions table. +-- - Expects the caller to have already resolved wallet scope and any optional +-- block reference. +-- - Expects the caller to supply the initial status explicitly so unmined rows +-- do not have to guess between `pending` and `published`. +-- Performance: +-- - Single-row insert. The cost is dominated by the wallet/hash uniqueness +-- checks and any optional block foreign-key validation. +INSERT INTO transactions ( + wallet_id, + tx_hash, + raw_tx, + block_height, + tx_status, + received_time, + is_coinbase, + tx_label +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +RETURNING id; + +-- name: GetTransactionMetaByHash :one +-- Retrieves the primary key and lightweight transaction metadata. +-- +-- How: +-- - Reads only the transactions table because callers only need row identity +-- plus lightweight status/label fields. +-- Performance: +-- - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. +SELECT + id, + block_height, + is_coinbase, + tx_status, + tx_label +FROM transactions +WHERE wallet_id = $1 AND tx_hash = $2; + +-- name: GetTransactionByHash :one +-- Retrieves the full transaction row along with optional block metadata. +-- +-- How: +-- - Looks up the transaction by `(wallet_id, tx_hash)`. +-- - LEFT JOINs blocks on `block_height` so the same query handles mined and +-- unmined rows. +-- Performance: +-- - The unique transaction lookup limits the join fanout to at most one block +-- row. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON t.block_height = b.block_height +WHERE t.wallet_id = $1 AND t.tx_hash = $2; + +-- name: ListUnminedTransactions :many +-- Lists all unconfirmed transactions for a wallet. +-- +-- How: +-- - Reads from transactions only and filters on blockless rows that are still +-- in a live unconfirmed state (`pending` or `published`). +-- - Excludes orphaned/replaced/failed history so rollback-produced coinbase +-- rows do not reappear as mempool transactions. +-- - Returns typed NULL block metadata explicitly because live unmined rows have +-- no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +-- keep the unmined row shape aligned with the confirmed query below. +-- Performance: +-- - Matches the dedicated blockless-history index while the more selective +-- live-only partial index stays available for conflict paths. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + NULL::BYTEA AS block_hash, + NULL::BIGINT AS block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.block_height IS NULL + AND t.tx_status IN (0, 1) +ORDER BY t.received_time DESC, t.id DESC; + +-- name: ListTransactionsByHeightRange :many +-- Lists all confirmed transactions for a wallet in the provided height range. +-- +-- How: +-- - Reads transactions in a wallet-scoped block-height range. +-- - INNER JOINs blocks on the natural `block_height` key to hydrate block hash +-- and timestamp for confirmed rows. +-- Performance: +-- - The `(wallet_id, block_height)` index bounds the scan before the single-row +-- block join. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +INNER JOIN blocks AS b ON t.block_height = b.block_height +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.block_height BETWEEN + sqlc.arg('start_height')::INTEGER + AND sqlc.arg('end_height')::INTEGER +ORDER BY t.block_height, t.id; + +-- name: UpdateTransactionLabelByHash :execrows +-- Updates only the user-visible transaction label. +-- +-- How: +-- - Leaves block assignment and status untouched. +-- - Exists for user-facing metadata edits only; wallet-internal state +-- transitions use dedicated helper queries. +-- Performance: +-- - Updates at most one row through the wallet-scoped unique tx-hash lookup. +UPDATE transactions +SET tx_label = sqlc.arg('label') +WHERE + wallet_id = sqlc.arg('wallet_id') + AND tx_hash = sqlc.arg('tx_hash'); + +-- name: ConfirmUnminedTransactionByHash :execrows +-- Attaches a confirming block to one existing live unmined transaction row. +-- +-- How: +-- - Updates only rows that are still blockless and live (`pending` or +-- `published`). +-- - Leaves user-visible metadata such as labels untouched so confirmation can +-- reuse the original transaction row instead of reinserting it. +-- Performance: +-- - Updates at most one row through the wallet-scoped unique tx-hash lookup. +UPDATE transactions +SET + block_height = sqlc.arg('block_height')::INTEGER, + tx_status = 1 +WHERE + wallet_id = sqlc.arg('wallet_id') + AND tx_hash = sqlc.arg('tx_hash') + AND block_height IS NULL + AND tx_status IN (0, 1); + +-- name: UpdateTransactionStatusByIDs :execrows +-- Updates the wallet-relative status for a set of transaction row IDs. +-- +-- How: +-- - Exists for wallet-internal replacement and invalidation flows after the +-- caller has already identified the affected rows. +-- - Leaves block assignment untouched; rollback/disconnect continues to use the +-- dedicated rewind helpers below. +-- Performance: +-- - Restricts by wallet scope first, then matches only the provided ID set. +UPDATE transactions +SET tx_status = sqlc.arg('status') +WHERE + wallet_id = sqlc.arg('wallet_id') + AND id = any(sqlc.arg('tx_ids')::BIGINT []); + +-- name: DeleteUnminedTransactionByHash :execrows +-- Deletes an unconfirmed transaction row. +-- +-- How: +-- - Deletes only rows whose `block_height` is still NULL and whose status is +-- still in a live unconfirmed state (`pending` or `published`). +-- - Preserves orphaned/replaced/failed history; those rows must remain visible +-- for audit/reorg handling instead of being treated as ordinary mempool data. +-- - The caller must delete or restore dependent UTXO rows first. +-- Performance: +-- - Targets at most one row by `(wallet_id, tx_hash)`. +DELETE FROM transactions +WHERE + wallet_id = $1 + AND tx_hash = $2 + AND block_height IS NULL + AND tx_status IN (0, 1); + +-- name: ListRollbackCoinbaseRoots :many +-- Lists wallet-scoped coinbase transaction hashes at or above the rollback +-- boundary that seed descendant invalidation. +-- +-- How: +-- - Reads only confirmed coinbase rows at or above the rollback boundary. +-- - Returns wallet scope alongside each tx hash so callers can treat these +-- coinbase transactions as rollback roots when invalidating now-dead +-- descendants inside the same rollback transaction. +-- - This is a rollback-specific helper, not a generic "coinbase txs from one +-- block" listing query. +-- Performance: +-- - Uses the block-height index to bound the scan to the rollback range. +SELECT + wallet_id, + tx_hash +FROM transactions +WHERE + block_height >= sqlc.arg('rollback_height')::INTEGER + AND is_coinbase +ORDER BY wallet_id, id; + +-- name: RewindWalletSyncStateHeightsForRollback :execrows +-- Rewrites wallet sync-state heights so they stop referencing blocks that are +-- about to be deleted during RollbackToBlock. +-- +-- How: +-- - Updates wallet_sync_states directly without joining other tables. +-- - Rewrites both synced_height and birthday_height in one statement so the +-- subsequent block delete does not violate `ON DELETE RESTRICT`. +-- - Example: if `rollback_height = 195`, then any `synced_height` or +-- `birthday_height` at 195 or above rewinds to `new_height = 194`. +-- - If rollback starts from height 0, callers pass `new_height = NULL` so the +-- sync state no longer points at any surviving block row. +-- Performance: +-- - Touches only wallet_sync_states rows whose heights are at or above the +-- rollback boundary. +UPDATE wallet_sync_states +SET + synced_height = CASE + WHEN + synced_height IS NOT NULL + AND synced_height >= sqlc.arg('rollback_height')::INTEGER + THEN sqlc.narg('new_height') + ELSE synced_height + END, + birthday_height = CASE + WHEN + birthday_height IS NOT NULL + AND birthday_height >= sqlc.arg('rollback_height')::INTEGER + THEN sqlc.narg('new_height') + ELSE birthday_height + END, + updated_at = current_timestamp AT TIME ZONE 'UTC' +WHERE + ( + synced_height IS NOT NULL + AND synced_height >= sqlc.arg('rollback_height')::INTEGER + ) + OR ( + birthday_height IS NOT NULL + AND birthday_height >= sqlc.arg('rollback_height')::INTEGER + ); + +-- name: DeleteBlocksAtOrAboveHeight :execrows +-- Deletes blocks at and after the provided height. +-- +-- How: +-- - Deletes directly from blocks by the natural height key. +-- - Relies on FK/trigger side effects to null transaction block references and +-- orphan coinbase rows. +-- Performance: +-- - Executes as a range delete over the block-height primary key. +DELETE FROM blocks +WHERE block_height >= $1; diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index b1033fc7b2..58b6126a6a 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -20,7 +20,7 @@ INSERT INTO wallet_sync_states ( birthday_timestamp, updated_at ) VALUES ( - $1, $2, $3, $4, current_timestamp + $1, $2, $3, $4, current_timestamp AT TIME ZONE 'UTC' ); -- name: UpdateWalletSyncState :execrows @@ -35,8 +35,8 @@ SET -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. birthday_timestamp = coalesce(sqlc.narg('birthday_timestamp'), birthday_timestamp), - -- Always update timestamp to current database time. - updated_at = current_timestamp + -- Always update timestamp to current database time in UTC. + updated_at = current_timestamp AT TIME ZONE 'UTC' WHERE wallet_id = $1; diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/db/queries/sqlite/transactions.sql new file mode 100644 index 0000000000..4ae01419b0 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/transactions.sql @@ -0,0 +1,273 @@ +-- name: InsertTransaction :one +-- Inserts a wallet-scoped transaction row and returns its database ID. +-- +-- How: +-- - Writes only the transactions table. +-- - Expects the caller to have already resolved wallet scope and any optional +-- block reference. +-- - Expects the caller to supply the initial status explicitly so unmined rows +-- do not have to guess between `pending` and `published`. +-- Performance: +-- - Single-row insert. The cost is dominated by the wallet/hash uniqueness +-- checks and any optional block foreign-key validation. +INSERT INTO transactions ( + wallet_id, + tx_hash, + raw_tx, + block_height, + tx_status, + received_time, + is_coinbase, + tx_label +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ? +) +RETURNING id; + +-- name: GetTransactionMetaByHash :one +-- Retrieves the primary key and lightweight transaction metadata. +-- +-- How: +-- - Reads only the transactions table because callers only need row identity +-- plus lightweight status/label fields. +-- Performance: +-- - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. +SELECT + id, + block_height, + is_coinbase, + tx_status, + tx_label +FROM transactions +WHERE wallet_id = ? AND tx_hash = ?; + +-- name: GetTransactionByHash :one +-- Retrieves the full transaction row along with optional block metadata. +-- +-- How: +-- - Looks up the transaction by `(wallet_id, tx_hash)`. +-- - LEFT JOINs blocks on `block_height` so the same query handles mined and +-- unmined rows. +-- Performance: +-- - The unique transaction lookup limits the join fanout to at most one block +-- row. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON t.block_height = b.block_height +WHERE t.wallet_id = ? AND t.tx_hash = ?; + +-- name: ListUnminedTransactions :many +-- Lists all unconfirmed transactions for a wallet. +-- +-- How: +-- - Reads from transactions only and filters on blockless rows that are still +-- in a live unconfirmed state (`pending` or `published`). +-- - Excludes orphaned/replaced/failed history so rollback-produced coinbase +-- rows do not reappear as mempool transactions. +-- - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` +-- so the unmined row shape stays aligned with the confirmed query below. +-- Performance: +-- - Matches the dedicated blockless-history index while the more selective +-- live-only partial index stays available for conflict paths. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON 1 = 0 +WHERE + t.wallet_id = ? + AND t.block_height IS NULL + AND t.tx_status IN (0, 1) +ORDER BY t.received_time DESC, t.id DESC; + +-- name: ListTransactionsByHeightRange :many +-- Lists all confirmed transactions for a wallet in the provided height range. +-- +-- How: +-- - Reads transactions in a wallet-scoped block-height range. +-- - INNER JOINs blocks on the natural `block_height` key to hydrate block hash +-- and timestamp for confirmed rows. +-- Performance: +-- - The `(wallet_id, block_height)` index bounds the scan before the single-row +-- block join. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +INNER JOIN blocks AS b ON t.block_height = b.block_height +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.block_height >= cast(sqlc.arg('start_height') AS INTEGER) + AND t.block_height <= cast(sqlc.arg('end_height') AS INTEGER) +ORDER BY t.block_height, t.id; + +-- name: UpdateTransactionLabelByHash :execrows +-- Updates only the user-visible transaction label. +-- +-- How: +-- - Leaves block assignment and status untouched. +-- - Exists for user-facing metadata edits only; wallet-internal state +-- transitions use dedicated helper queries. +-- Performance: +-- - Updates at most one row through the wallet-scoped unique tx-hash lookup. +UPDATE transactions +SET tx_label = sqlc.arg('label') +WHERE + wallet_id = sqlc.arg('wallet_id') + AND tx_hash = sqlc.arg('tx_hash'); + +-- name: ConfirmUnminedTransactionByHash :execrows +-- Attaches a confirming block to one existing live unmined transaction row. +-- +-- How: +-- - Updates only rows that are still blockless and live (`pending` or +-- `published`). +-- - Leaves user-visible metadata such as labels untouched so confirmation can +-- reuse the original transaction row instead of reinserting it. +-- Performance: +-- - Updates at most one row through the wallet-scoped unique tx-hash lookup. +UPDATE transactions +SET + block_height = cast(sqlc.arg('block_height') AS INTEGER), + tx_status = 1 +WHERE + wallet_id = sqlc.arg('wallet_id') + AND tx_hash = sqlc.arg('tx_hash') + AND block_height IS NULL + AND tx_status IN (0, 1); + +-- name: UpdateTransactionStatusByIDs :execrows +-- Updates the wallet-relative status for a set of transaction row IDs. +-- +-- How: +-- - Exists for wallet-internal replacement and invalidation flows after the +-- caller has already identified the affected rows. +-- - Leaves block assignment untouched; rollback/disconnect continues to use the +-- dedicated rewind helpers below. +-- Performance: +-- - Restricts by wallet scope first, then matches only the provided ID set. +UPDATE transactions +SET tx_status = sqlc.arg('status') +WHERE + wallet_id = sqlc.arg('wallet_id') + AND id IN (sqlc.slice('tx_ids')); + +-- name: DeleteUnminedTransactionByHash :execrows +-- Deletes an unconfirmed transaction row. +-- +-- How: +-- - Deletes only rows whose `block_height` is still NULL and whose status is +-- still in a live unconfirmed state (`pending` or `published`). +-- - Preserves orphaned/replaced/failed history; those rows must remain visible +-- for audit/reorg handling instead of being treated as ordinary mempool data. +-- - The caller must delete or restore dependent UTXO rows first. +-- Performance: +-- - Targets at most one row by `(wallet_id, tx_hash)`. +DELETE FROM transactions +WHERE + wallet_id = ? + AND tx_hash = ? + AND block_height IS NULL + AND tx_status IN (0, 1); + +-- name: ListRollbackCoinbaseRoots :many +-- Lists wallet-scoped coinbase transaction hashes at or above the rollback +-- boundary that seed descendant invalidation. +-- +-- How: +-- - Reads only confirmed coinbase rows at or above the rollback boundary. +-- - Returns wallet scope alongside each tx hash so callers can treat these +-- coinbase transactions as rollback roots when invalidating now-dead +-- descendants inside the same rollback transaction. +-- - This is a rollback-specific helper, not a generic "coinbase txs from one +-- block" listing query. +-- Performance: +-- - Uses the block-height index to bound the scan to the rollback range. +SELECT + wallet_id, + tx_hash +FROM transactions +WHERE + block_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + AND is_coinbase +ORDER BY wallet_id, id; + +-- name: RewindWalletSyncStateHeightsForRollback :execrows +-- Rewrites wallet sync-state heights so they stop referencing blocks that are +-- about to be deleted during RollbackToBlock. +-- +-- How: +-- - Updates wallet_sync_states directly without joining other tables. +-- - Rewrites both synced_height and birthday_height in one statement so the +-- subsequent block delete does not violate `ON DELETE RESTRICT`. +-- - Example: if `rollback_height = 195`, then any `synced_height` or +-- `birthday_height` at 195 or above rewinds to `new_height = 194`. +-- - If rollback starts from height 0, callers pass `new_height = NULL` so the +-- sync state no longer points at any surviving block row. +-- Performance: +-- - Touches only wallet_sync_states rows whose heights are at or above the +-- rollback boundary. +UPDATE wallet_sync_states +SET + synced_height = CASE + WHEN + synced_height IS NOT NULL + AND synced_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + THEN sqlc.narg('new_height') + ELSE synced_height + END, + birthday_height = CASE + WHEN + birthday_height IS NOT NULL + AND birthday_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + THEN sqlc.narg('new_height') + ELSE birthday_height + END, + updated_at = current_timestamp +WHERE + ( + synced_height IS NOT NULL + AND synced_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + ) + OR ( + birthday_height IS NOT NULL + AND birthday_height >= cast(sqlc.arg('rollback_height') AS INTEGER) + ); + +-- name: DeleteBlocksAtOrAboveHeight :execrows +-- Deletes blocks at and after the provided height. +-- +-- How: +-- - Deletes directly from blocks by the natural height key. +-- - Relies on FK/trigger side effects to null transaction block references and +-- orphan coinbase rows. +-- Performance: +-- - Executes as a range delete over the block-height primary key. +DELETE FROM blocks +WHERE block_height >= ?; From c48b174726f3cfe83823a5813ba09b625a27955b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:04:57 +0800 Subject: [PATCH 452/691] wallet: add sqlc queries for utxos --- wallet/internal/db/queries/postgres/utxos.sql | 370 +++++++++++++++++ wallet/internal/db/queries/sqlite/utxos.sql | 375 ++++++++++++++++++ 2 files changed, 745 insertions(+) create mode 100644 wallet/internal/db/queries/postgres/utxos.sql create mode 100644 wallet/internal/db/queries/sqlite/utxos.sql diff --git a/wallet/internal/db/queries/postgres/utxos.sql b/wallet/internal/db/queries/postgres/utxos.sql new file mode 100644 index 0000000000..1dc4a0c06f --- /dev/null +++ b/wallet/internal/db/queries/postgres/utxos.sql @@ -0,0 +1,370 @@ +-- name: InsertUtxo :one +-- Inserts a new UTXO row and returns its database ID. +-- +-- How: +-- - Writes only the utxos table using already-resolved transaction and address +-- IDs. +-- - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if +-- the provided address ID belongs to the same wallet. +-- Performance: +-- - Single-row insert. The main cost is the wallet-ownership validation join +-- plus FK and uniqueness checks. +INSERT INTO utxos ( + tx_id, + output_index, + amount, + address_id +) SELECT + t.id AS tx_id, + sqlc.arg('output_index') AS output_index, + sqlc.arg('amount') AS amount, + a.id AS address_id +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +CROSS JOIN transactions AS t +WHERE + t.id = sqlc.arg('tx_id') + AND t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_status IN (0, 1) + AND + a.id = sqlc.arg('address_id') + AND ks.wallet_id = sqlc.arg('wallet_id') +RETURNING id; + +-- name: GetUtxoIDByOutpoint :one +-- Retrieves the database ID for a current UTXO by its outpoint. +-- +-- How: +-- - Joins transactions on `id` so callers can address a UTXO by +-- network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. +-- - Restricts the result to unspent outputs whose parent transaction is still +-- in a live state (`pending` or `published`). +-- - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return +-- rows whose credited address does not actually belong to the wallet. +-- - Exists separately from GetUtxoByOutpoint because mutation helpers often +-- need the stable internal UTXO row ID without reading the full public UTXO +-- payload. +-- Performance: +-- - Uses the wallet-scoped transaction hash lookup first, then narrows to the +-- unique `(tx_id, output_index)` outpoint. +SELECT u.id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1); + +-- name: GetUtxoByOutpoint :one +-- Retrieves a single unspent UTXO by its outpoint. +-- +-- How: +-- - Joins utxos -> transactions on `tx_id` to resolve the outpoint +-- from tx hash plus output index. +-- - Joins addresses -> accounts -> key_scopes so the read path reasserts that +-- the credited address belongs to the requested wallet. +-- - Returns leased and unleased outputs alike because leasing affects coin +-- selection, not whether the UTXO exists. +-- - Treats outputs from both live unconfirmed parent states (`pending` and +-- `published`) as part of the wallet's current UTXO set. +-- Performance: +-- - The wallet-scoped tx hash lookup and unique outpoint constraint keep the +-- join fanout to at most one candidate output. +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1); + +-- name: ListUtxos :many +-- Lists unspent UTXOs that match the provided filters. +-- +-- How: +-- - Starts from utxos and joins transactions for tx metadata plus +-- wallet_sync_states for confirmation math. +-- - Joins addresses to return the required script_pub_key, then reuses +-- addresses -> accounts -> key_scopes so account filtering and wallet +-- ownership checks happen in the same read. +-- - Returns leased outputs too because the API models leases separately from +-- UTXO existence. +-- - Includes outputs whose parent transaction is still in a live unconfirmed +-- state (`pending` or `published`). +-- - Intentionally does not enforce coinbase maturity because this query models +-- wallet-owned UTXO existence rather than a strictly spendable subset. +-- Performance: +-- - Restricts first by wallet, spend state, and transaction status. +-- - Uses the address/account/scope joins to keep ownership validation and +-- account filtering in one pass. +-- - Treats min/max confirmations as optional filters so callers can +-- distinguish "not set" from an explicit zero-conf request. +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND ks.wallet_id = sqlc.arg('wallet_id') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + sqlc.narg('account_number')::BIGINT IS NULL + OR acc.account_number = sqlc.narg('account_number')::BIGINT + ) + AND ( + sqlc.narg('min_confirms')::INTEGER IS NULL + OR sqlc.narg('min_confirms')::INTEGER = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('min_confirms')::INTEGER + ) + AND ( + sqlc.narg('max_confirms')::INTEGER IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= sqlc.narg('max_confirms')::INTEGER + ) +ORDER BY u.amount, t.tx_hash, u.output_index; + +-- name: Balance :one +-- Returns the total and locked value represented by the wallet's current +-- unspent UTXO set. +-- +-- How: +-- - Starts from wallet-scoped unspent outputs and rejoins transactions plus +-- wallet_sync_states for confirmation math. +-- - Rejoins addresses -> accounts -> key_scopes so ownership validation and +-- optional account filtering stay in one read. +-- - Applies optional confirmation-range and coinbase-maturity policy directly +-- inside the aggregate query so callers can request factual or policy-shaped +-- balance reads through one public method. +-- - Returns both the total matching value and the locked subset covered by +-- active leases after the same filters are applied. +-- Performance: +-- - Executes as one aggregate over wallet-scoped live outputs. +-- - Uses a filtered aggregate over active leases rather than issuing a second +-- query for the locked subset. +-- - Uses the address/account/scope joins to keep ownership validation and +-- account filtering in one pass. +SELECT + (coalesce(sum(u.amount), 0))::BIGINT AS total_balance, + (coalesce( + sum(u.amount) FILTER ( + WHERE EXISTS ( + SELECT 1 + FROM utxo_leases AS l + WHERE + l.wallet_id = t.wallet_id + AND l.utxo_id = u.id + AND l.expires_at > sqlc.arg('now_utc')::TIMESTAMP + ) + ), + 0 + ))::BIGINT AS locked_balance +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND ks.wallet_id = sqlc.arg('wallet_id') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + sqlc.narg('account_number')::BIGINT IS NULL + OR acc.account_number = sqlc.narg('account_number')::BIGINT + ) + AND ( + sqlc.narg('min_confirms')::INTEGER IS NULL + OR sqlc.narg('min_confirms')::INTEGER = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('min_confirms')::INTEGER + ) + AND ( + sqlc.narg('max_confirms')::INTEGER IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= sqlc.narg('max_confirms')::INTEGER + ) + AND ( + sqlc.narg('coinbase_maturity')::INTEGER IS NULL + OR sqlc.narg('coinbase_maturity')::INTEGER = 0 + OR NOT t.is_coinbase + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('coinbase_maturity')::INTEGER + ); + +-- name: ListSpendingTxIDsByParentTxID :many +-- Lists direct child transaction IDs for one parent transaction ID. +-- +-- How: +-- - Reads the spend edges already materialized on utxos through +-- `(tx_id, spent_by_tx_id)`. +-- - Returns only direct children; callers that need full descendant walks should +-- traverse this query iteratively in application code. +-- Performance: +-- - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded +-- to one wallet-scoped parent. +SELECT DISTINCT u.spent_by_tx_id +FROM utxos AS u +WHERE + u.tx_id = $2 + AND u.spent_by_tx_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ) +ORDER BY u.spent_by_tx_id; + +-- name: GetUtxoSpendByOutpoint :one +-- Returns the current spend edge for one wallet-owned outpoint. +-- +-- How: +-- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only +-- considers outputs whose parent status is `pending` or `published`. +-- - Returns the nullable `spent_by_tx_id` column so callers can distinguish +-- between an external/dead parent and a wallet-owned conflict. +-- Performance: +-- - Targets one wallet-scoped outpoint through the unique `(tx_id, +-- output_index)` key after the parent hash lookup. +SELECT u.spent_by_tx_id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND t.tx_status IN (0, 1); + +-- name: MarkUtxoSpent :execrows +-- Marks a wallet-owned UTXO as spent by a transaction. +-- +-- How: +-- - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside +-- the statement so callers can update by network outpoint. +-- - Requires the parent transaction status to be `pending` or `published` +-- before a child spend edge can attach. +-- - Only changes rows that are currently unspent or already point at the same +-- `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent +-- without allowing a caller to silently rewrite which input spent the UTXO. +-- Performance: +-- - Targets one outpoint in one wallet; the subquery uses the unique +-- wallet-scoped tx hash lookup. +UPDATE utxos AS u +SET + spent_by_tx_id = $4, + spent_input_index = $5 +WHERE + u.tx_id = ( + SELECT t.id + FROM transactions AS t + WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND t.tx_status IN (0, 1) + ) + AND u.output_index = $3 + AND ( + (u.spent_by_tx_id IS NULL AND u.spent_input_index IS NULL) + OR (u.spent_by_tx_id = $4 AND u.spent_input_index = $5) + ); + +-- name: ClearUtxosSpentByTxID :execrows +-- Clears spent_by pointers for all UTXOs spent by the provided transaction ID. +-- +-- How: +-- - Resets both spent columns together so the logical spend pointer remains +-- internally consistent. +-- Performance: +-- - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet +-- ownership through the creating transaction. +UPDATE utxos AS u +SET + spent_by_tx_id = NULL, + spent_input_index = NULL +WHERE + u.spent_by_tx_id = $2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ); + +-- name: DeleteUtxosByTxID :execrows +-- Deletes all UTXO rows created by the provided transaction ID. +-- +-- How: +-- - Removes outputs by the parent transaction's internal ID after callers have +-- already decided the transaction row itself may be deleted. +-- Performance: +-- - Uses the `(tx_id)` index to keep the delete bounded to one transaction's +-- outputs, then rechecks wallet ownership through the parent transaction. +DELETE FROM utxos AS u +WHERE + u.tx_id = $2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ); diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/db/queries/sqlite/utxos.sql new file mode 100644 index 0000000000..403695abe6 --- /dev/null +++ b/wallet/internal/db/queries/sqlite/utxos.sql @@ -0,0 +1,375 @@ +-- name: InsertUtxo :one +-- Inserts a new UTXO row and returns its database ID. +-- +-- How: +-- - Writes only the utxos table using already-resolved transaction and address +-- IDs. +-- - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if +-- the provided address ID belongs to the same wallet. +-- Performance: +-- - Single-row insert. The main cost is the wallet-ownership validation join +-- plus FK and uniqueness checks. +INSERT INTO utxos ( + tx_id, + output_index, + amount, + address_id +) SELECT + t.id AS tx_id, + sqlc.arg('output_index') AS output_index, + sqlc.arg('amount') AS amount, + a.id AS address_id +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +CROSS JOIN transactions AS t +WHERE + t.id = sqlc.arg('tx_id') + AND t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_status IN (0, 1) + AND + a.id = sqlc.arg('address_id') + AND ks.wallet_id = sqlc.arg('wallet_id') +RETURNING id; + +-- name: GetUtxoIDByOutpoint :one +-- Retrieves the database ID for a current UTXO by its outpoint. +-- +-- How: +-- - Joins transactions on `id` so callers can address a UTXO by +-- network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. +-- - Restricts the result to unspent outputs whose parent transaction is still +-- in a live state (`pending` or `published`). +-- - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return +-- rows whose credited address does not actually belong to the wallet. +-- - Exists separately from GetUtxoByOutpoint because mutation helpers often +-- need the stable internal UTXO row ID without reading the full public UTXO +-- payload. +-- Performance: +-- - Uses the wallet-scoped transaction hash lookup first, then narrows to the +-- unique `(tx_id, output_index)` outpoint. +SELECT u.id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND t.tx_hash = ?2 + AND u.output_index = ?3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1); + +-- name: GetUtxoByOutpoint :one +-- Retrieves a single unspent UTXO by its outpoint. +-- +-- How: +-- - Joins utxos -> transactions on `tx_id` to resolve the outpoint +-- from tx hash plus output index. +-- - Joins addresses -> accounts -> key_scopes so the read path reasserts that +-- the credited address belongs to the requested wallet. +-- - Returns leased and unleased outputs alike because leasing affects coin +-- selection, not whether the UTXO exists. +-- - Treats outputs from both live unconfirmed parent states (`pending` and +-- `published`) as part of the wallet's current UTXO set. +-- Performance: +-- - The wallet-scoped tx hash lookup and unique outpoint constraint keep the +-- join fanout to at most one candidate output. +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND t.tx_hash = ?2 + AND u.output_index = ?3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1); + +-- name: ListUtxos :many +-- Lists unspent UTXOs that match the provided filters. +-- +-- How: +-- - Starts from utxos and joins transactions for tx metadata plus +-- wallet_sync_states for confirmation math. +-- - Joins addresses to return the required script_pub_key, then reuses +-- addresses -> accounts -> key_scopes so account filtering and wallet +-- ownership checks happen in the same read. +-- - Returns leased outputs too because the API models leases separately from +-- UTXO existence. +-- - Includes outputs whose parent transaction is still in a live unconfirmed +-- state (`pending` or `published`). +-- - Intentionally does not enforce coinbase maturity because this query models +-- wallet-owned UTXO existence rather than a strictly spendable subset. +-- Performance: +-- - Restricts first by wallet, spend state, and transaction status. +-- - Uses the address/account/scope joins to keep ownership validation and +-- account filtering in one pass. +-- - Treats min/max confirmations as optional filters so callers can +-- distinguish "not set" from an explicit zero-conf request. +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND ks.wallet_id = sqlc.arg('wallet_id') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + sqlc.narg('account_number') IS NULL + OR acc.account_number = sqlc.narg('account_number') + ) + AND ( + sqlc.narg('min_confirms') IS NULL + OR sqlc.narg('min_confirms') = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('min_confirms') + ) + AND ( + sqlc.narg('max_confirms') IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= sqlc.narg('max_confirms') + ) +ORDER BY u.amount, t.tx_hash, u.output_index; + +-- name: Balance :one +-- Returns the total and locked value represented by the wallet's current +-- unspent UTXO set. +-- +-- How: +-- - Starts from wallet-scoped unspent outputs and rejoins transactions plus +-- wallet_sync_states for confirmation math. +-- - Rejoins addresses -> accounts -> key_scopes so ownership validation and +-- optional account filtering stay in one read. +-- - Applies optional confirmation-range and coinbase-maturity policy directly +-- inside the aggregate query so callers can request factual or policy-shaped +-- balance reads through one public method. +-- - Returns both the total matching value and the locked subset covered by +-- active leases after the same filters are applied. +-- Performance: +-- - Executes as one aggregate over wallet-scoped live outputs. +-- - Uses a filtered aggregate over active leases rather than issuing a second +-- query for the locked subset. +-- - Uses the address/account/scope joins to keep ownership validation and +-- account filtering in one pass. +SELECT + cast(coalesce(sum(u.amount), 0) AS INTEGER) AS total_balance, + cast( + coalesce( + sum( + CASE + WHEN EXISTS ( + SELECT 1 + FROM utxo_leases AS l + WHERE + l.wallet_id = t.wallet_id + AND l.utxo_id = u.id + AND l.expires_at > sqlc.arg('now_utc') + ) THEN u.amount + ELSE 0 + END + ), + 0 + ) AS INTEGER + ) AS locked_balance +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND ks.wallet_id = sqlc.arg('wallet_id') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + sqlc.narg('account_number') IS NULL + OR acc.account_number = sqlc.narg('account_number') + ) + AND ( + sqlc.narg('min_confirms') IS NULL + OR sqlc.narg('min_confirms') = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('min_confirms') + ) + AND ( + sqlc.narg('max_confirms') IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= sqlc.narg('max_confirms') + ) + AND ( + sqlc.narg('coinbase_maturity') IS NULL + OR sqlc.narg('coinbase_maturity') = 0 + OR NOT t.is_coinbase + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= sqlc.narg('coinbase_maturity') + ); + +-- name: ListSpendingTxIDsByParentTxID :many +-- Lists direct child transaction IDs for one parent transaction ID. +-- +-- How: +-- - Reads the spend edges already materialized on utxos through +-- `(tx_id, spent_by_tx_id)`. +-- - Returns only direct children; callers that need full descendant walks should +-- traverse this query iteratively in application code. +-- Performance: +-- - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded +-- to one wallet-scoped parent. +SELECT DISTINCT u.spent_by_tx_id +FROM utxos AS u +WHERE + u.tx_id = ?2 + AND u.spent_by_tx_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = ?1 + ) +ORDER BY u.spent_by_tx_id; + +-- name: GetUtxoSpendByOutpoint :one +-- Returns the current spend edge for one wallet-owned outpoint. +-- +-- How: +-- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only +-- considers outputs whose parent status is `pending` or `published`. +-- - Returns the nullable `spent_by_tx_id` column so callers can distinguish +-- between an external/dead parent and a wallet-owned conflict. +-- Performance: +-- - Targets one wallet-scoped outpoint through the unique `(tx_id, +-- output_index)` key after the parent hash lookup. +SELECT utxos.spent_by_tx_id +FROM transactions AS t +INNER JOIN utxos ON t.id = utxos.tx_id +WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND utxos.output_index = ?3 + AND t.tx_status IN (0, 1); + +-- name: MarkUtxoSpent :execrows +-- Marks a wallet-owned UTXO as spent by a transaction. +-- +-- How: +-- - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside +-- the statement so callers can update by network outpoint. +-- - Requires the parent transaction status to be `pending` or `published` +-- before a child spend edge can attach. +-- - Only changes rows that are currently unspent or already point at the same +-- `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent +-- without allowing a caller to silently rewrite which input spent the UTXO. +-- Performance: +-- - Targets one outpoint in one wallet; the subquery uses the unique +-- wallet-scoped tx hash lookup. +UPDATE utxos +SET + spent_by_tx_id = ?4, + spent_input_index = ?5 +WHERE + utxos.tx_id = ( + SELECT t.id + FROM transactions AS t + WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND t.tx_status IN (0, 1) + ) + AND utxos.output_index = ?3 + AND ( + (utxos.spent_by_tx_id IS NULL AND utxos.spent_input_index IS NULL) + OR (utxos.spent_by_tx_id = ?4 AND utxos.spent_input_index = ?5) + ); + +-- name: ClearUtxosSpentByTxID :execrows +-- Clears spent_by pointers for all UTXOs spent by the provided transaction ID. +-- +-- How: +-- - Resets both spent columns together so the logical spend pointer remains +-- internally consistent. +-- Performance: +-- - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet +-- ownership through the creating transaction. +UPDATE utxos +SET + spent_by_tx_id = NULL, + spent_input_index = NULL +WHERE + spent_by_tx_id = ?2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = utxos.tx_id AND t.wallet_id = ?1 + ); + +-- name: DeleteUtxosByTxID :execrows +-- Deletes all UTXO rows created by the provided transaction ID. +-- +-- How: +-- - Removes outputs by the parent transaction's internal ID after callers have +-- already decided the transaction row itself may be deleted. +-- Performance: +-- - Uses the `(tx_id)` index to keep the delete bounded to one transaction's +-- outputs, then rechecks wallet ownership through the parent transaction. +DELETE FROM utxos +WHERE + tx_id = ?2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = utxos.tx_id AND t.wallet_id = ?1 + ); From 95a246c0386e6e042af8cd72bca30c4be4932026 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:05:04 +0800 Subject: [PATCH 453/691] wallet: add sqlc queries for tx_replacements --- .../db/queries/postgres/tx_replacements.sql | 130 ++++++++++++++++++ .../db/queries/sqlite/tx_replacements.sql | 129 +++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 wallet/internal/db/queries/postgres/tx_replacements.sql create mode 100644 wallet/internal/db/queries/sqlite/tx_replacements.sql diff --git a/wallet/internal/db/queries/postgres/tx_replacements.sql b/wallet/internal/db/queries/postgres/tx_replacements.sql new file mode 100644 index 0000000000..0625014fe3 --- /dev/null +++ b/wallet/internal/db/queries/postgres/tx_replacements.sql @@ -0,0 +1,130 @@ +-- name: InsertTxReplacementEdge :execrows +-- Records a replacement edge between two wallet-scoped transactions. +-- +-- How: +-- - Writes directly to tx_replacements using already-resolved transaction IDs. +-- - Relies on the wallet-scoped unique edge constraint to collapse retries into +-- one stored edge. +-- - Leaves created_at to the table default so the database records insertion +-- time for the edge. +-- Performance: +-- - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + $1, $2, $3 +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING; + +-- name: InsertTxReplacementEdgeByHash :execrows +-- Records a replacement edge by resolving tx IDs from transaction hashes. +-- +-- How: +-- - Resolves both endpoint transaction IDs from the transactions table using +-- the wallet-scoped tx-hash unique lookup. +-- - Writes the resulting directed edge to tx_replacements. +-- - Leaves created_at to the table default so the database records insertion +-- time for the edge. +-- Performance: +-- - Trades two indexed scalar subqueries for one network round trip, which is +-- preferable when callers start from tx hashes. +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + $1, + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = $1 AND t.tx_hash = $2 + ), + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = $1 AND t.tx_hash = $3 + ) +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING; + +-- name: ListReplacementTxIDsByReplacedTxID :many +-- Lists replacement transaction IDs for a given victim transaction ID. +-- +-- How: +-- - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the +-- caller already has the victim's internal row ID. +-- - Orders first by created_at and then by id so traversal stays deterministic +-- even when several edges share the same timestamp. +-- Performance: +-- - Uses the replacement-edge index without joining transactions. +SELECT + replacement_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = $1 AND replaced_tx_id = $2 +ORDER BY created_at, id; + +-- name: ListReplacedTxIDsByReplacementTxID :many +-- Lists victim transaction IDs for a given replacement transaction ID. +-- +-- How: +-- - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because +-- the caller already has the replacement row ID. +-- - Orders first by created_at and then by id so traversal stays deterministic +-- even when several edges share the same timestamp. +-- Performance: +-- - Uses the inverse replacement lookup index without joining transactions. +SELECT + replaced_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = $1 AND replacement_tx_id = $2 +ORDER BY created_at, id; + +-- name: ListReplacementTxHashesByReplacedTxHash :many +-- Lists replacement txids for a given victim txid. +-- +-- How: +-- - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +-- id)` to map both edge endpoints back to network tx hashes. +-- - Filters by the victim hash on the `replaced` alias. +-- Performance: +-- - The victim hash lookup narrows the graph walk before the second transaction +-- join materializes replacement hashes. +SELECT + replacement.tx_hash AS replacement_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = $1 AND replaced.tx_hash = $2 +ORDER BY r.created_at, r.id; + +-- name: ListReplacedTxHashesByReplacementTxHash :many +-- Lists victim txids for a given replacement txid. +-- +-- How: +-- - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +-- id)` to map both edge endpoints back to network tx hashes. +-- - Filters by the replacement hash on the `replacement` alias. +-- Performance: +-- - Mirrors the victim lookup path while keeping the graph traversal bounded by +-- wallet scope and indexed edge keys. +SELECT + replaced.tx_hash AS replaced_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = $1 AND replacement.tx_hash = $2 +ORDER BY r.created_at, r.id; diff --git a/wallet/internal/db/queries/sqlite/tx_replacements.sql b/wallet/internal/db/queries/sqlite/tx_replacements.sql new file mode 100644 index 0000000000..5b69a8b00e --- /dev/null +++ b/wallet/internal/db/queries/sqlite/tx_replacements.sql @@ -0,0 +1,129 @@ +-- name: InsertTxReplacementEdge :execrows +-- Records a replacement edge between two wallet-scoped transactions. +-- +-- How: +-- - Writes directly to tx_replacements using already-resolved transaction IDs. +-- - Uses an explicit conflict target so only duplicate edges are ignored; +-- missing transaction IDs, self-replacements, and other constraint failures +-- still surface to the caller. +-- Performance: +-- - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + ?1, ?2, ?3 +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING; + +-- name: InsertTxReplacementEdgeByHash :execrows +-- Records a replacement edge by resolving tx IDs from transaction hashes. +-- +-- How: +-- - Resolves both endpoint transaction IDs from the transactions table using +-- the wallet-scoped tx-hash unique lookup. +-- - Writes the resulting directed edge to tx_replacements. +-- - Uses an explicit conflict target so duplicate-edge retries are ignored +-- without masking missing-hash or check-constraint failures. +-- Performance: +-- - Trades two indexed scalar subqueries for one network round trip, which is +-- preferable when callers start from tx hashes. +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + ?1, + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = ?1 AND t.tx_hash = ?2 + ), + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = ?1 AND t.tx_hash = ?3 + ) +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING; + +-- name: ListReplacementTxIDsByReplacedTxID :many +-- Lists replacement transaction IDs for a given victim transaction ID. +-- +-- How: +-- - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the +-- caller already has the victim's internal row ID. +-- - Orders first by created_at and then by id so traversal stays deterministic +-- even when several edges share the same timestamp. +-- Performance: +-- - Uses the replacement-edge index without joining transactions. +SELECT + replacement_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = ?1 AND replaced_tx_id = ?2 +ORDER BY created_at, id; + +-- name: ListReplacedTxIDsByReplacementTxID :many +-- Lists victim transaction IDs for a given replacement transaction ID. +-- +-- How: +-- - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because +-- the caller already has the replacement row ID. +-- - Orders first by created_at and then by id so traversal stays deterministic +-- even when several edges share the same timestamp. +-- Performance: +-- - Uses the inverse replacement lookup index without joining transactions. +SELECT + replaced_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = ?1 AND replacement_tx_id = ?2 +ORDER BY created_at, id; + +-- name: ListReplacementTxHashesByReplacedTxHash :many +-- Lists replacement txids for a given victim txid. +-- +-- How: +-- - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +-- id)` to map both edge endpoints back to network tx hashes. +-- - Filters by the victim hash on the `replaced` alias. +-- Performance: +-- - The victim hash lookup narrows the graph walk before the second transaction +-- join materializes replacement hashes. +SELECT + replacement.tx_hash AS replacement_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = ?1 AND replaced.tx_hash = ?2 +ORDER BY r.created_at, r.id; + +-- name: ListReplacedTxHashesByReplacementTxHash :many +-- Lists victim txids for a given replacement txid. +-- +-- How: +-- - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +-- id)` to map both edge endpoints back to network tx hashes. +-- - Filters by the replacement hash on the `replacement` alias. +-- Performance: +-- - Mirrors the victim lookup path while keeping the graph traversal bounded by +-- wallet scope and indexed edge keys. +SELECT + replaced.tx_hash AS replaced_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = ?1 AND replacement.tx_hash = ?2 +ORDER BY r.created_at, r.id; From e038810122953c3f40e79c5a97170c8ea5b38e94 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:05:13 +0800 Subject: [PATCH 454/691] wallet: add sqlc queries for utxo_leases --- .../db/queries/postgres/utxo_leases.sql | 126 ++++++++++++++++++ .../db/queries/sqlite/utxo_leases.sql | 123 +++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 wallet/internal/db/queries/postgres/utxo_leases.sql create mode 100644 wallet/internal/db/queries/sqlite/utxo_leases.sql diff --git a/wallet/internal/db/queries/postgres/utxo_leases.sql b/wallet/internal/db/queries/postgres/utxo_leases.sql new file mode 100644 index 0000000000..262a2287ea --- /dev/null +++ b/wallet/internal/db/queries/postgres/utxo_leases.sql @@ -0,0 +1,126 @@ +-- name: AcquireUtxoLease :one +-- Acquires or renews a lease for an outpoint and returns the resulting +-- expiration time. +-- +-- How: +-- - Resolves the outpoint to a current UTXO row and writes the lease in the +-- same statement. +-- - Rechecks that the outpoint is still unspent and its parent transaction is +-- still in a live state (`pending` or `published`) at write time. +-- - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, +-- and expired-lease takeover all happen atomically. +-- Lease semantics: +-- - If the UTXO has no lease row, insert a new lease. +-- - If the UTXO has an expired lease, steal it. +-- - If the UTXO has an active lease with the same lock_id, renew it. +-- - If the UTXO has an active lease with a different lock_id, return no +-- rows (caller should treat this as "already leased"). +-- +-- NOTE: expires_at is stored as a UTC-normalized TIMESTAMP. Callers should pass +-- UTC values for both expires_at and now_utc. +-- Performance: +-- - Locks the target utxo row during resolution so concurrent spend updates on +-- that row serialize with lease acquisition. +INSERT INTO utxo_leases ( + wallet_id, + utxo_id, + lock_id, + expires_at +) +SELECT + sqlc.arg('wallet_id') AS wallet_id, + u.id AS utxo_id, + sqlc.arg('lock_id') AS lock_id, + sqlc.arg('expires_at')::TIMESTAMP AS expires_at +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_hash = sqlc.arg('tx_hash') + AND u.output_index = sqlc.arg('output_index') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +FOR UPDATE OF u +ON CONFLICT (utxo_id) DO UPDATE + SET + lock_id = excluded.lock_id, + expires_at = excluded.expires_at + WHERE + utxo_leases.wallet_id = excluded.wallet_id + AND ( + utxo_leases.expires_at <= sqlc.arg('now_utc')::TIMESTAMP + OR utxo_leases.lock_id = excluded.lock_id + ) +RETURNING expires_at; + +-- name: ReleaseUtxoLease :execrows +-- Releases a lease for a UTXO ID if the lock_id matches. +-- +-- How: +-- - Deletes by wallet, utxo ID, and lock ID so one caller cannot release +-- another caller's active lease accidentally. +-- Performance: +-- - Targets at most one row through the unique lease key. +DELETE FROM utxo_leases +WHERE + utxo_leases.wallet_id = $1 + AND utxo_leases.utxo_id = $2 + AND utxo_leases.lock_id = $3; + +-- name: GetActiveUtxoLeaseLockID :one +-- Returns the lock ID for the current active lease on a UTXO ID. +-- +-- How: +-- - Reads only non-expired lease rows so callers can distinguish an active +-- lock-ID mismatch from an already-unlocked output. +-- Performance: +-- - Targets at most one row through the unique lease key. +SELECT lock_id +FROM utxo_leases +WHERE + wallet_id = $1 + AND utxo_id = $2 + AND expires_at > sqlc.arg('now_utc')::TIMESTAMP; + +-- name: ListActiveUtxoLeases :many +-- Lists all currently active leases for a wallet. +-- +-- How: +-- - Starts from utxo_leases, then joins utxos and transactions so the result +-- can be returned as network outpoints. +-- - Filters out expired rows using the caller-supplied UTC timestamp. +-- - Restricts the result to outputs that are still unspent and whose parent +-- transaction is still in a live state (`pending` or `published`). +-- Performance: +-- - Restricts first by wallet and expiration, then joins only the surviving +-- lease rows back to utxos/transactions. +SELECT + t.tx_hash, + u.output_index, + l.lock_id, + l.expires_at +FROM utxo_leases AS l +INNER JOIN utxos AS u ON l.utxo_id = u.id +INNER JOIN transactions AS t ON u.tx_id = t.id +WHERE + l.wallet_id = $1 + AND l.expires_at > sqlc.arg('now_utc')::TIMESTAMP + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +ORDER BY l.expires_at; + +-- name: DeleteExpiredUtxoLeases :execrows +-- Deletes all expired lease rows for a wallet. +-- +-- How: +-- - Removes only rows whose expiration has passed, leaving active leases +-- untouched. +-- - Uses the caller-supplied UTC timestamp so lease lifetime semantics do not +-- depend on database session timezone settings. +-- Performance: +-- - Uses the expiration predicate together with wallet scoping to bound the +-- cleanup pass. +DELETE FROM utxo_leases +WHERE + wallet_id = $1 + AND expires_at <= sqlc.arg('now_utc')::TIMESTAMP; diff --git a/wallet/internal/db/queries/sqlite/utxo_leases.sql b/wallet/internal/db/queries/sqlite/utxo_leases.sql new file mode 100644 index 0000000000..fa6f4eb57b --- /dev/null +++ b/wallet/internal/db/queries/sqlite/utxo_leases.sql @@ -0,0 +1,123 @@ +-- name: AcquireUtxoLease :one +-- Acquires or renews a lease for an outpoint and returns the resulting +-- expiration time. +-- +-- How: +-- - Resolves the outpoint to a current UTXO row and writes the lease in the +-- same statement. +-- - Rechecks that the outpoint is still unspent and its parent transaction is +-- still in a live state (`pending` or `published`) at write time. +-- - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, +-- and expired-lease takeover all happen atomically. +-- Lease semantics: +-- - If the UTXO has no lease row, insert a new lease. +-- - If the UTXO has an expired lease, steal it. +-- - If the UTXO has an active lease with the same lock_id, renew it. +-- - If the UTXO has an active lease with a different lock_id, return no +-- rows (caller should treat this as "already leased"). +-- +-- NOTE: expires_at is computed by the caller as time.Now().UTC()+duration, and +-- callers must also pass a UTC-normalized now_utc for lease-expiry checks. +-- Performance: +-- - SQLite executes the resolution and lease write atomically inside one +-- statement, which avoids stale-ID races between separate helper calls. +INSERT INTO utxo_leases ( + wallet_id, + utxo_id, + lock_id, + expires_at +) +SELECT + sqlc.arg('wallet_id') AS wallet_id, + u.id AS utxo_id, + sqlc.arg('lock_id') AS lock_id, + sqlc.arg('expires_at') AS expires_at +FROM utxos AS u +INNER JOIN transactions AS t + ON u.tx_id = t.id +WHERE + t.wallet_id = sqlc.arg('wallet_id') + AND t.tx_hash = sqlc.arg('tx_hash') + AND u.output_index = sqlc.arg('output_index') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND sqlc.arg('now_utc') IS NOT NULL +ON CONFLICT (utxo_id) DO UPDATE +SET + lock_id = excluded.lock_id, + expires_at = excluded.expires_at +WHERE +utxo_leases.wallet_id = excluded.wallet_id +AND ( + utxo_leases.expires_at <= sqlc.arg('now_utc') + OR utxo_leases.lock_id = excluded.lock_id +) +RETURNING expires_at; + +-- name: ReleaseUtxoLease :execrows +-- Releases a lease for a UTXO ID if the lock_id matches. +-- +-- How: +-- - Deletes by wallet, utxo ID, and lock ID so one caller cannot release +-- another caller's active lease accidentally. +-- Performance: +-- - Targets at most one row through the unique lease key. +DELETE FROM utxo_leases +WHERE + utxo_leases.wallet_id = ?1 + AND utxo_leases.utxo_id = ?2 + AND utxo_leases.lock_id = ?3; + +-- name: GetActiveUtxoLeaseLockID :one +-- Returns the lock ID for the current active lease on a UTXO ID. +-- +-- How: +-- - Reads only non-expired lease rows so callers can distinguish an active +-- lock-ID mismatch from an already-unlocked output. +-- Performance: +-- - Targets at most one row through the unique lease key. +SELECT lock_id +FROM utxo_leases +WHERE + wallet_id = ?1 + AND utxo_id = ?2 + AND expires_at > sqlc.arg('now_utc'); + +-- name: ListActiveUtxoLeases :many +-- Lists all currently active leases for a wallet. +-- +-- How: +-- - Starts from utxo_leases, then joins utxos and transactions so the result +-- can be returned as network outpoints. +-- - Filters out expired rows using the caller-supplied UTC timestamp. +-- - Restricts the result to outputs that are still unspent and whose parent +-- transaction is still in a live state (`pending` or `published`). +-- Performance: +-- - Restricts first by wallet and expiration, then joins only the surviving +-- lease rows back to utxos/transactions. +SELECT + t.tx_hash, + u.output_index, + l.lock_id, + l.expires_at +FROM utxo_leases AS l +INNER JOIN utxos AS u ON l.utxo_id = u.id +INNER JOIN transactions AS t ON u.tx_id = t.id +WHERE + l.wallet_id = ?1 + AND l.expires_at > sqlc.arg('now_utc') + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +ORDER BY l.expires_at; + +-- name: DeleteExpiredUtxoLeases :execrows +-- Deletes all expired lease rows for a wallet. +-- +-- How: +-- - Removes only rows whose expiration has passed, leaving active leases +-- untouched. +-- Performance: +-- - Uses the expiration predicate together with wallet scoping to bound the +-- cleanup pass. +DELETE FROM utxo_leases +WHERE wallet_id = ?1 AND expires_at <= sqlc.arg('now_utc'); From 35b29f0f74ae11fd85e3b8767a0ad8853a208a8e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 14 Mar 2026 01:05:21 +0800 Subject: [PATCH 455/691] wallet: generate sqlc for wtxmgr --- go.mod | 2 +- wallet/internal/db/sqlc/postgres/db.go | 330 +++++++++ wallet/internal/db/sqlc/postgres/models.go | 37 + wallet/internal/db/sqlc/postgres/querier.go | 395 +++++++++++ .../db/sqlc/postgres/transactions.sql.go | 606 +++++++++++++++++ .../db/sqlc/postgres/tx_replacements.sql.go | 314 +++++++++ .../db/sqlc/postgres/utxo_leases.sql.go | 251 +++++++ wallet/internal/db/sqlc/postgres/utxos.sql.go | 638 +++++++++++++++++ .../internal/db/sqlc/postgres/wallets.sql.go | 6 +- wallet/internal/db/sqlc/sqlite/db.go | 330 +++++++++ wallet/internal/db/sqlc/sqlite/models.go | 37 + wallet/internal/db/sqlc/sqlite/querier.go | 391 +++++++++++ .../db/sqlc/sqlite/transactions.sql.go | 616 +++++++++++++++++ .../db/sqlc/sqlite/tx_replacements.sql.go | 313 +++++++++ .../db/sqlc/sqlite/utxo_leases.sql.go | 248 +++++++ wallet/internal/db/sqlc/sqlite/utxos.sql.go | 643 ++++++++++++++++++ 16 files changed, 5153 insertions(+), 4 deletions(-) create mode 100644 wallet/internal/db/sqlc/postgres/transactions.sql.go create mode 100644 wallet/internal/db/sqlc/postgres/tx_replacements.sql.go create mode 100644 wallet/internal/db/sqlc/postgres/utxo_leases.sql.go create mode 100644 wallet/internal/db/sqlc/postgres/utxos.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/transactions.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go create mode 100644 wallet/internal/db/sqlc/sqlite/utxos.sql.go diff --git a/go.mod b/go.mod index bf00351c98..9b10eed62f 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/jackc/pgx/v5 v5.5.4 github.com/jessevdk/go-flags v1.6.1 github.com/jrick/logrotate v1.1.2 + github.com/lib/pq v1.10.9 github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf github.com/lightninglabs/neutrino v0.16.2 github.com/lightninglabs/neutrino/cache v1.1.3 @@ -69,7 +70,6 @@ require ( github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/kkdai/bstream v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/lib/pq v1.10.9 // indirect github.com/lightningnetwork/lnd/clock v1.0.1 // indirect github.com/lightningnetwork/lnd/queue v1.0.1 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 17936fad3f..2bd999a7bc 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -24,6 +24,18 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.acquireUtxoLeaseStmt, err = db.PrepareContext(ctx, AcquireUtxoLease); err != nil { + return nil, fmt.Errorf("error preparing query AcquireUtxoLease: %w", err) + } + if q.balanceStmt, err = db.PrepareContext(ctx, Balance); err != nil { + return nil, fmt.Errorf("error preparing query Balance: %w", err) + } + if q.clearUtxosSpentByTxIDStmt, err = db.PrepareContext(ctx, ClearUtxosSpentByTxID); err != nil { + return nil, fmt.Errorf("error preparing query ClearUtxosSpentByTxID: %w", err) + } + if q.confirmUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, ConfirmUnminedTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query ConfirmUnminedTransactionByHash: %w", err) + } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } @@ -51,12 +63,24 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.deleteBlocksAtOrAboveHeightStmt, err = db.PrepareContext(ctx, DeleteBlocksAtOrAboveHeight); err != nil { + return nil, fmt.Errorf("error preparing query DeleteBlocksAtOrAboveHeight: %w", err) + } + if q.deleteExpiredUtxoLeasesStmt, err = db.PrepareContext(ctx, DeleteExpiredUtxoLeases); err != nil { + return nil, fmt.Errorf("error preparing query DeleteExpiredUtxoLeases: %w", err) + } if q.deleteKeyScopeStmt, err = db.PrepareContext(ctx, DeleteKeyScope); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScope: %w", err) } if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) } + if q.deleteUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, DeleteUnminedTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query DeleteUnminedTransactionByHash: %w", err) + } + if q.deleteUtxosByTxIDStmt, err = db.PrepareContext(ctx, DeleteUtxosByTxID); err != nil { + return nil, fmt.Errorf("error preparing query DeleteUtxosByTxID: %w", err) + } if q.getAccountByScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query GetAccountByScopeAndName: %w", err) } @@ -72,6 +96,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getActiveUtxoLeaseLockIDStmt, err = db.PrepareContext(ctx, GetActiveUtxoLeaseLockID); err != nil { + return nil, fmt.Errorf("error preparing query GetActiveUtxoLeaseLockID: %w", err) + } if q.getAddressByScriptPubKeyStmt, err = db.PrepareContext(ctx, GetAddressByScriptPubKey); err != nil { return nil, fmt.Errorf("error preparing query GetAddressByScriptPubKey: %w", err) } @@ -99,6 +126,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getKeyScopeSecretsStmt, err = db.PrepareContext(ctx, GetKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetKeyScopeSecrets: %w", err) } + if q.getTransactionByHashStmt, err = db.PrepareContext(ctx, GetTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query GetTransactionByHash: %w", err) + } + if q.getTransactionMetaByHashStmt, err = db.PrepareContext(ctx, GetTransactionMetaByHash); err != nil { + return nil, fmt.Errorf("error preparing query GetTransactionMetaByHash: %w", err) + } + if q.getUtxoByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoByOutpoint: %w", err) + } + if q.getUtxoIDByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoIDByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoIDByOutpoint: %w", err) + } + if q.getUtxoSpendByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoSpendByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoSpendByOutpoint: %w", err) + } if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) } @@ -117,6 +159,18 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertKeyScopeSecretsStmt, err = db.PrepareContext(ctx, InsertKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertKeyScopeSecrets: %w", err) } + if q.insertTransactionStmt, err = db.PrepareContext(ctx, InsertTransaction); err != nil { + return nil, fmt.Errorf("error preparing query InsertTransaction: %w", err) + } + if q.insertTxReplacementEdgeStmt, err = db.PrepareContext(ctx, InsertTxReplacementEdge); err != nil { + return nil, fmt.Errorf("error preparing query InsertTxReplacementEdge: %w", err) + } + if q.insertTxReplacementEdgeByHashStmt, err = db.PrepareContext(ctx, InsertTxReplacementEdgeByHash); err != nil { + return nil, fmt.Errorf("error preparing query InsertTxReplacementEdgeByHash: %w", err) + } + if q.insertUtxoStmt, err = db.PrepareContext(ctx, InsertUtxo); err != nil { + return nil, fmt.Errorf("error preparing query InsertUtxo: %w", err) + } if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) } @@ -135,6 +189,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAccountsByWalletScopeStmt, err = db.PrepareContext(ctx, ListAccountsByWalletScope); err != nil { return nil, fmt.Errorf("error preparing query ListAccountsByWalletScope: %w", err) } + if q.listActiveUtxoLeasesStmt, err = db.PrepareContext(ctx, ListActiveUtxoLeases); err != nil { + return nil, fmt.Errorf("error preparing query ListActiveUtxoLeases: %w", err) + } if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } @@ -144,18 +201,60 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) } + if q.listReplacedTxHashesByReplacementTxHashStmt, err = db.PrepareContext(ctx, ListReplacedTxHashesByReplacementTxHash); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacedTxHashesByReplacementTxHash: %w", err) + } + if q.listReplacedTxIDsByReplacementTxIDStmt, err = db.PrepareContext(ctx, ListReplacedTxIDsByReplacementTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacedTxIDsByReplacementTxID: %w", err) + } + if q.listReplacementTxHashesByReplacedTxHashStmt, err = db.PrepareContext(ctx, ListReplacementTxHashesByReplacedTxHash); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacementTxHashesByReplacedTxHash: %w", err) + } + if q.listReplacementTxIDsByReplacedTxIDStmt, err = db.PrepareContext(ctx, ListReplacementTxIDsByReplacedTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacementTxIDsByReplacedTxID: %w", err) + } + if q.listRollbackCoinbaseRootsStmt, err = db.PrepareContext(ctx, ListRollbackCoinbaseRoots); err != nil { + return nil, fmt.Errorf("error preparing query ListRollbackCoinbaseRoots: %w", err) + } + if q.listSpendingTxIDsByParentTxIDStmt, err = db.PrepareContext(ctx, ListSpendingTxIDsByParentTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListSpendingTxIDsByParentTxID: %w", err) + } + if q.listTransactionsByHeightRangeStmt, err = db.PrepareContext(ctx, ListTransactionsByHeightRange); err != nil { + return nil, fmt.Errorf("error preparing query ListTransactionsByHeightRange: %w", err) + } + if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { + return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) + } + if q.listUtxosStmt, err = db.PrepareContext(ctx, ListUtxos); err != nil { + return nil, fmt.Errorf("error preparing query ListUtxos: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } if q.lockAccountScopeStmt, err = db.PrepareContext(ctx, LockAccountScope); err != nil { return nil, fmt.Errorf("error preparing query LockAccountScope: %w", err) } + if q.markUtxoSpentStmt, err = db.PrepareContext(ctx, MarkUtxoSpent); err != nil { + return nil, fmt.Errorf("error preparing query MarkUtxoSpent: %w", err) + } + if q.releaseUtxoLeaseStmt, err = db.PrepareContext(ctx, ReleaseUtxoLease); err != nil { + return nil, fmt.Errorf("error preparing query ReleaseUtxoLease: %w", err) + } + if q.rewindWalletSyncStateHeightsForRollbackStmt, err = db.PrepareContext(ctx, RewindWalletSyncStateHeightsForRollback); err != nil { + return nil, fmt.Errorf("error preparing query RewindWalletSyncStateHeightsForRollback: %w", err) + } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } + if q.updateTransactionLabelByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionLabelByHash); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionLabelByHash: %w", err) + } + if q.updateTransactionStatusByIDsStmt, err = db.PrepareContext(ctx, UpdateTransactionStatusByIDs); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionStatusByIDs: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -167,6 +266,26 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.acquireUtxoLeaseStmt != nil { + if cerr := q.acquireUtxoLeaseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing acquireUtxoLeaseStmt: %w", cerr) + } + } + if q.balanceStmt != nil { + if cerr := q.balanceStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing balanceStmt: %w", cerr) + } + } + if q.clearUtxosSpentByTxIDStmt != nil { + if cerr := q.clearUtxosSpentByTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing clearUtxosSpentByTxIDStmt: %w", cerr) + } + } + if q.confirmUnminedTransactionByHashStmt != nil { + if cerr := q.confirmUnminedTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing confirmUnminedTransactionByHashStmt: %w", cerr) + } + } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -212,6 +331,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.deleteBlocksAtOrAboveHeightStmt != nil { + if cerr := q.deleteBlocksAtOrAboveHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteBlocksAtOrAboveHeightStmt: %w", cerr) + } + } + if q.deleteExpiredUtxoLeasesStmt != nil { + if cerr := q.deleteExpiredUtxoLeasesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteExpiredUtxoLeasesStmt: %w", cerr) + } + } if q.deleteKeyScopeStmt != nil { if cerr := q.deleteKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteKeyScopeStmt: %w", cerr) @@ -222,6 +351,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) } } + if q.deleteUnminedTransactionByHashStmt != nil { + if cerr := q.deleteUnminedTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteUnminedTransactionByHashStmt: %w", cerr) + } + } + if q.deleteUtxosByTxIDStmt != nil { + if cerr := q.deleteUtxosByTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteUtxosByTxIDStmt: %w", cerr) + } + } if q.getAccountByScopeAndNameStmt != nil { if cerr := q.getAccountByScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAccountByScopeAndNameStmt: %w", cerr) @@ -247,6 +386,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getActiveUtxoLeaseLockIDStmt != nil { + if cerr := q.getActiveUtxoLeaseLockIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getActiveUtxoLeaseLockIDStmt: %w", cerr) + } + } if q.getAddressByScriptPubKeyStmt != nil { if cerr := q.getAddressByScriptPubKeyStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressByScriptPubKeyStmt: %w", cerr) @@ -292,6 +436,31 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getKeyScopeSecretsStmt: %w", cerr) } } + if q.getTransactionByHashStmt != nil { + if cerr := q.getTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTransactionByHashStmt: %w", cerr) + } + } + if q.getTransactionMetaByHashStmt != nil { + if cerr := q.getTransactionMetaByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTransactionMetaByHashStmt: %w", cerr) + } + } + if q.getUtxoByOutpointStmt != nil { + if cerr := q.getUtxoByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoByOutpointStmt: %w", cerr) + } + } + if q.getUtxoIDByOutpointStmt != nil { + if cerr := q.getUtxoIDByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoIDByOutpointStmt: %w", cerr) + } + } + if q.getUtxoSpendByOutpointStmt != nil { + if cerr := q.getUtxoSpendByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoSpendByOutpointStmt: %w", cerr) + } + } if q.getWalletByIDStmt != nil { if cerr := q.getWalletByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) @@ -322,6 +491,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertKeyScopeSecretsStmt: %w", cerr) } } + if q.insertTransactionStmt != nil { + if cerr := q.insertTransactionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTransactionStmt: %w", cerr) + } + } + if q.insertTxReplacementEdgeStmt != nil { + if cerr := q.insertTxReplacementEdgeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTxReplacementEdgeStmt: %w", cerr) + } + } + if q.insertTxReplacementEdgeByHashStmt != nil { + if cerr := q.insertTxReplacementEdgeByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTxReplacementEdgeByHashStmt: %w", cerr) + } + } + if q.insertUtxoStmt != nil { + if cerr := q.insertUtxoStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertUtxoStmt: %w", cerr) + } + } if q.insertWalletSecretsStmt != nil { if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) @@ -352,6 +541,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) } } + if q.listActiveUtxoLeasesStmt != nil { + if cerr := q.listActiveUtxoLeasesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listActiveUtxoLeasesStmt: %w", cerr) + } + } if q.listAddressTypesStmt != nil { if cerr := q.listAddressTypesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) @@ -367,6 +561,51 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) } } + if q.listReplacedTxHashesByReplacementTxHashStmt != nil { + if cerr := q.listReplacedTxHashesByReplacementTxHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacedTxHashesByReplacementTxHashStmt: %w", cerr) + } + } + if q.listReplacedTxIDsByReplacementTxIDStmt != nil { + if cerr := q.listReplacedTxIDsByReplacementTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacedTxIDsByReplacementTxIDStmt: %w", cerr) + } + } + if q.listReplacementTxHashesByReplacedTxHashStmt != nil { + if cerr := q.listReplacementTxHashesByReplacedTxHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacementTxHashesByReplacedTxHashStmt: %w", cerr) + } + } + if q.listReplacementTxIDsByReplacedTxIDStmt != nil { + if cerr := q.listReplacementTxIDsByReplacedTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacementTxIDsByReplacedTxIDStmt: %w", cerr) + } + } + if q.listRollbackCoinbaseRootsStmt != nil { + if cerr := q.listRollbackCoinbaseRootsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listRollbackCoinbaseRootsStmt: %w", cerr) + } + } + if q.listSpendingTxIDsByParentTxIDStmt != nil { + if cerr := q.listSpendingTxIDsByParentTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listSpendingTxIDsByParentTxIDStmt: %w", cerr) + } + } + if q.listTransactionsByHeightRangeStmt != nil { + if cerr := q.listTransactionsByHeightRangeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTransactionsByHeightRangeStmt: %w", cerr) + } + } + if q.listUnminedTransactionsStmt != nil { + if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) + } + } + if q.listUtxosStmt != nil { + if cerr := q.listUtxosStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUtxosStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) @@ -377,6 +616,21 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing lockAccountScopeStmt: %w", cerr) } } + if q.markUtxoSpentStmt != nil { + if cerr := q.markUtxoSpentStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing markUtxoSpentStmt: %w", cerr) + } + } + if q.releaseUtxoLeaseStmt != nil { + if cerr := q.releaseUtxoLeaseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing releaseUtxoLeaseStmt: %w", cerr) + } + } + if q.rewindWalletSyncStateHeightsForRollbackStmt != nil { + if cerr := q.rewindWalletSyncStateHeightsForRollbackStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing rewindWalletSyncStateHeightsForRollbackStmt: %w", cerr) + } + } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -387,6 +641,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } + if q.updateTransactionLabelByHashStmt != nil { + if cerr := q.updateTransactionLabelByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionLabelByHashStmt: %w", cerr) + } + } + if q.updateTransactionStatusByIDsStmt != nil { + if cerr := q.updateTransactionStatusByIDsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionStatusByIDsStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -436,6 +700,10 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar type Queries struct { db DBTX tx *sql.Tx + acquireUtxoLeaseStmt *sql.Stmt + balanceStmt *sql.Stmt + clearUtxosSpentByTxIDStmt *sql.Stmt + confirmUnminedTransactionByHashStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt @@ -445,13 +713,18 @@ type Queries struct { createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt + deleteBlocksAtOrAboveHeightStmt *sql.Stmt + deleteExpiredUtxoLeasesStmt *sql.Stmt deleteKeyScopeStmt *sql.Stmt deleteKeyScopeSecretsStmt *sql.Stmt + deleteUnminedTransactionByHashStmt *sql.Stmt + deleteUtxosByTxIDStmt *sql.Stmt getAccountByScopeAndNameStmt *sql.Stmt getAccountByScopeAndNumberStmt *sql.Stmt getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt @@ -461,25 +734,49 @@ type Queries struct { getKeyScopeByIDStmt *sql.Stmt getKeyScopeByWalletAndScopeStmt *sql.Stmt getKeyScopeSecretsStmt *sql.Stmt + getTransactionByHashStmt *sql.Stmt + getTransactionMetaByHashStmt *sql.Stmt + getUtxoByOutpointStmt *sql.Stmt + getUtxoIDByOutpointStmt *sql.Stmt + getUtxoSpendByOutpointStmt *sql.Stmt getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt + insertTransactionStmt *sql.Stmt + insertTxReplacementEdgeStmt *sql.Stmt + insertTxReplacementEdgeByHashStmt *sql.Stmt + insertUtxoStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt insertWalletSyncStateStmt *sql.Stmt listAccountsByScopeStmt *sql.Stmt listAccountsByWalletStmt *sql.Stmt listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt + listActiveUtxoLeasesStmt *sql.Stmt listAddressTypesStmt *sql.Stmt listAddressesByAccountStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt + listReplacedTxHashesByReplacementTxHashStmt *sql.Stmt + listReplacedTxIDsByReplacementTxIDStmt *sql.Stmt + listReplacementTxHashesByReplacedTxHashStmt *sql.Stmt + listReplacementTxIDsByReplacedTxIDStmt *sql.Stmt + listRollbackCoinbaseRootsStmt *sql.Stmt + listSpendingTxIDsByParentTxIDStmt *sql.Stmt + listTransactionsByHeightRangeStmt *sql.Stmt + listUnminedTransactionsStmt *sql.Stmt + listUtxosStmt *sql.Stmt listWalletsStmt *sql.Stmt lockAccountScopeStmt *sql.Stmt + markUtxoSpentStmt *sql.Stmt + releaseUtxoLeaseStmt *sql.Stmt + rewindWalletSyncStateHeightsForRollbackStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateTransactionLabelByHashStmt *sql.Stmt + updateTransactionStatusByIDsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -488,6 +785,10 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ db: tx, tx: tx, + acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, + balanceStmt: q.balanceStmt, + clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, + confirmUnminedTransactionByHashStmt: q.confirmUnminedTransactionByHashStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, @@ -497,13 +798,18 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, + deleteBlocksAtOrAboveHeightStmt: q.deleteBlocksAtOrAboveHeightStmt, + deleteExpiredUtxoLeasesStmt: q.deleteExpiredUtxoLeasesStmt, deleteKeyScopeStmt: q.deleteKeyScopeStmt, deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + deleteUnminedTransactionByHashStmt: q.deleteUnminedTransactionByHashStmt, + deleteUtxosByTxIDStmt: q.deleteUtxosByTxIDStmt, getAccountByScopeAndNameStmt: q.getAccountByScopeAndNameStmt, getAccountByScopeAndNumberStmt: q.getAccountByScopeAndNumberStmt, getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getActiveUtxoLeaseLockIDStmt: q.getActiveUtxoLeaseLockIDStmt, getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, @@ -513,25 +819,49 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getTransactionByHashStmt: q.getTransactionByHashStmt, + getTransactionMetaByHashStmt: q.getTransactionMetaByHashStmt, + getUtxoByOutpointStmt: q.getUtxoByOutpointStmt, + getUtxoIDByOutpointStmt: q.getUtxoIDByOutpointStmt, + getUtxoSpendByOutpointStmt: q.getUtxoSpendByOutpointStmt, getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertTransactionStmt: q.insertTransactionStmt, + insertTxReplacementEdgeStmt: q.insertTxReplacementEdgeStmt, + insertTxReplacementEdgeByHashStmt: q.insertTxReplacementEdgeByHashStmt, + insertUtxoStmt: q.insertUtxoStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, listAccountsByScopeStmt: q.listAccountsByScopeStmt, listAccountsByWalletStmt: q.listAccountsByWalletStmt, listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listActiveUtxoLeasesStmt: q.listActiveUtxoLeasesStmt, listAddressTypesStmt: q.listAddressTypesStmt, listAddressesByAccountStmt: q.listAddressesByAccountStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listReplacedTxHashesByReplacementTxHashStmt: q.listReplacedTxHashesByReplacementTxHashStmt, + listReplacedTxIDsByReplacementTxIDStmt: q.listReplacedTxIDsByReplacementTxIDStmt, + listReplacementTxHashesByReplacedTxHashStmt: q.listReplacementTxHashesByReplacedTxHashStmt, + listReplacementTxIDsByReplacedTxIDStmt: q.listReplacementTxIDsByReplacedTxIDStmt, + listRollbackCoinbaseRootsStmt: q.listRollbackCoinbaseRootsStmt, + listSpendingTxIDsByParentTxIDStmt: q.listSpendingTxIDsByParentTxIDStmt, + listTransactionsByHeightRangeStmt: q.listTransactionsByHeightRangeStmt, + listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, + listUtxosStmt: q.listUtxosStmt, listWalletsStmt: q.listWalletsStmt, lockAccountScopeStmt: q.lockAccountScopeStmt, + markUtxoSpentStmt: q.markUtxoSpentStmt, + releaseUtxoLeaseStmt: q.releaseUtxoLeaseStmt, + rewindWalletSyncStateHeightsForRollbackStmt: q.rewindWalletSyncStateHeightsForRollbackStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateTransactionLabelByHashStmt: q.updateTransactionLabelByHashStmt, + updateTransactionStatusByIDsStmt: q.updateTransactionStatusByIDsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/db/sqlc/postgres/models.go index 411c05c8c6..363466e546 100644 --- a/wallet/internal/db/sqlc/postgres/models.go +++ b/wallet/internal/db/sqlc/postgres/models.go @@ -77,6 +77,43 @@ type KeyScopeSecret struct { EncryptedCoinPrivKey []byte } +type Transaction struct { + WalletID int64 + ID int64 + TxHash []byte + RawTx []byte + BlockHeight sql.NullInt32 + TxStatus int16 + ReceivedTime time.Time + IsCoinbase bool + TxLabel string +} + +type TxReplacement struct { + WalletID int64 + ID int64 + ReplacedTxID int64 + ReplacementTxID int64 + CreatedAt time.Time +} + +type Utxo struct { + ID int64 + TxID int64 + OutputIndex int32 + Amount int64 + AddressID int64 + SpentByTxID sql.NullInt64 + SpentInputIndex sql.NullInt32 +} + +type UtxoLease struct { + WalletID int64 + UtxoID int64 + LockID []byte + ExpiresAt time.Time +} + type Wallet struct { ID int64 WalletName string diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 84f628a8c8..073b78a071 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -6,9 +6,73 @@ package sqlcpg import ( "context" + "database/sql" + "time" ) type Querier interface { + // Acquires or renews a lease for an outpoint and returns the resulting + // expiration time. + // + // How: + // - Resolves the outpoint to a current UTXO row and writes the lease in the + // same statement. + // - Rechecks that the outpoint is still unspent and its parent transaction is + // still in a live state (`pending` or `published`) at write time. + // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, + // and expired-lease takeover all happen atomically. + // Lease semantics: + // - If the UTXO has no lease row, insert a new lease. + // - If the UTXO has an expired lease, steal it. + // - If the UTXO has an active lease with the same lock_id, renew it. + // - If the UTXO has an active lease with a different lock_id, return no + // rows (caller should treat this as "already leased"). + // + // NOTE: expires_at is stored as a UTC-normalized TIMESTAMP. Callers should pass + // UTC values for both expires_at and now_utc. + // Performance: + // - Locks the target utxo row during resolution so concurrent spend updates on + // that row serialize with lease acquisition. + AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) + // Returns the total and locked value represented by the wallet's current + // unspent UTXO set. + // + // How: + // - Starts from wallet-scoped unspent outputs and rejoins transactions plus + // wallet_sync_states for confirmation math. + // - Rejoins addresses -> accounts -> key_scopes so ownership validation and + // optional account filtering stay in one read. + // - Applies optional confirmation-range and coinbase-maturity policy directly + // inside the aggregate query so callers can request factual or policy-shaped + // balance reads through one public method. + // - Returns both the total matching value and the locked subset covered by + // active leases after the same filters are applied. + // Performance: + // - Executes as one aggregate over wallet-scoped live outputs. + // - Uses a filtered aggregate over active leases rather than issuing a second + // query for the locked subset. + // - Uses the address/account/scope joins to keep ownership validation and + // account filtering in one pass. + Balance(ctx context.Context, arg BalanceParams) (BalanceRow, error) + // Clears spent_by pointers for all UTXOs spent by the provided transaction ID. + // + // How: + // - Resets both spent columns together so the logical spend pointer remains + // internally consistent. + // Performance: + // - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet + // ownership through the creating transaction. + ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) + // Attaches a confirming block to one existing live unmined transaction row. + // + // How: + // - Updates only rows that are still blockless and live (`pending` or + // `published`). + // - Leaves user-visible metadata such as labels untouched so confirmation can + // reuse the original transaction row instead of reinserting it. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error // Creates a new derived account under the given scope, computing the next @@ -33,10 +97,50 @@ type Querier interface { CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int32) error + // Deletes blocks at and after the provided height. + // + // How: + // - Deletes directly from blocks by the natural height key. + // - Relies on FK/trigger side effects to null transaction block references and + // orphan coinbase rows. + // Performance: + // - Executes as a range delete over the block-height primary key. + DeleteBlocksAtOrAboveHeight(ctx context.Context, blockHeight int32) (int64, error) + // Deletes all expired lease rows for a wallet. + // + // How: + // - Removes only rows whose expiration has passed, leaving active leases + // untouched. + // - Uses the caller-supplied UTC timestamp so lease lifetime semantics do not + // depend on database session timezone settings. + // Performance: + // - Uses the expiration predicate together with wallet scoping to bound the + // cleanup pass. + DeleteExpiredUtxoLeases(ctx context.Context, arg DeleteExpiredUtxoLeasesParams) (int64, error) // Deletes a key scope by its ID. DeleteKeyScope(ctx context.Context, id int64) (int64, error) // Deletes the secrets for a key scope. DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) + // Deletes an unconfirmed transaction row. + // + // How: + // - Deletes only rows whose `block_height` is still NULL and whose status is + // still in a live unconfirmed state (`pending` or `published`). + // - Preserves orphaned/replaced/failed history; those rows must remain visible + // for audit/reorg handling instead of being treated as ordinary mempool data. + // - The caller must delete or restore dependent UTXO rows first. + // Performance: + // - Targets at most one row by `(wallet_id, tx_hash)`. + DeleteUnminedTransactionByHash(ctx context.Context, arg DeleteUnminedTransactionByHashParams) (int64, error) + // Deletes all UTXO rows created by the provided transaction ID. + // + // How: + // - Removes outputs by the parent transaction's internal ID after callers have + // already decided the transaction row itself may be deleted. + // Performance: + // - Uses the `(tx_id)` index to keep the delete bounded to one transaction's + // outputs, then rechecks wallet ownership through the parent transaction. + DeleteUtxosByTxID(ctx context.Context, arg DeleteUtxosByTxIDParams) (int64, error) // Returns a single account by scope id and account name. GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) // Returns a single account by scope id and account number. @@ -47,6 +151,14 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Returns the lock ID for the current active lease on a UTXO ID. + // + // How: + // - Reads only non-expired lease rows so callers can distinguish an active + // lock-ID mismatch from an already-unlocked output. + // Performance: + // - Targets at most one row through the unique lease key. + GetActiveUtxoLeaseLockID(ctx context.Context, arg GetActiveUtxoLeaseLockIDParams) ([]byte, error) // Retrieves an address by its script pubkey and account wallet. GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) // Retrieves secret information for an address. Uses LEFT JOIN to distinguish: @@ -69,6 +181,66 @@ type Querier interface { GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) + // Retrieves the full transaction row along with optional block metadata. + // + // How: + // - Looks up the transaction by `(wallet_id, tx_hash)`. + // - LEFT JOINs blocks on `block_height` so the same query handles mined and + // unmined rows. + // Performance: + // - The unique transaction lookup limits the join fanout to at most one block + // row. + GetTransactionByHash(ctx context.Context, arg GetTransactionByHashParams) (GetTransactionByHashRow, error) + // Retrieves the primary key and lightweight transaction metadata. + // + // How: + // - Reads only the transactions table because callers only need row identity + // plus lightweight status/label fields. + // Performance: + // - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. + GetTransactionMetaByHash(ctx context.Context, arg GetTransactionMetaByHashParams) (GetTransactionMetaByHashRow, error) + // Retrieves a single unspent UTXO by its outpoint. + // + // How: + // - Joins utxos -> transactions on `tx_id` to resolve the outpoint + // from tx hash plus output index. + // - Joins addresses -> accounts -> key_scopes so the read path reasserts that + // the credited address belongs to the requested wallet. + // - Returns leased and unleased outputs alike because leasing affects coin + // selection, not whether the UTXO exists. + // - Treats outputs from both live unconfirmed parent states (`pending` and + // `published`) as part of the wallet's current UTXO set. + // Performance: + // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the + // join fanout to at most one candidate output. + GetUtxoByOutpoint(ctx context.Context, arg GetUtxoByOutpointParams) (GetUtxoByOutpointRow, error) + // Retrieves the database ID for a current UTXO by its outpoint. + // + // How: + // - Joins transactions on `id` so callers can address a UTXO by + // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. + // - Restricts the result to unspent outputs whose parent transaction is still + // in a live state (`pending` or `published`). + // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return + // rows whose credited address does not actually belong to the wallet. + // - Exists separately from GetUtxoByOutpoint because mutation helpers often + // need the stable internal UTXO row ID without reading the full public UTXO + // payload. + // Performance: + // - Uses the wallet-scoped transaction hash lookup first, then narrows to the + // unique `(tx_id, output_index)` outpoint. + GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpointParams) (int64, error) + // Returns the current spend edge for one wallet-owned outpoint. + // + // How: + // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only + // considers outputs whose parent status is `pending` or `published`. + // - Returns the nullable `spent_by_tx_id` column so callers can distinguish + // between an external/dead parent and a wallet-owned conflict. + // Performance: + // - Targets one wallet-scoped outpoint through the unique `(tx_id, + // output_index)` key after the parent hash lookup. + GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (sql.NullInt64, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) @@ -79,6 +251,52 @@ type Querier interface { // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for // watch-only scopes. InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error + // Inserts a wallet-scoped transaction row and returns its database ID. + // + // How: + // - Writes only the transactions table. + // - Expects the caller to have already resolved wallet scope and any optional + // block reference. + // - Expects the caller to supply the initial status explicitly so unmined rows + // do not have to guess between `pending` and `published`. + // Performance: + // - Single-row insert. The cost is dominated by the wallet/hash uniqueness + // checks and any optional block foreign-key validation. + InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) + // Records a replacement edge between two wallet-scoped transactions. + // + // How: + // - Writes directly to tx_replacements using already-resolved transaction IDs. + // - Relies on the wallet-scoped unique edge constraint to collapse retries into + // one stored edge. + // - Leaves created_at to the table default so the database records insertion + // time for the edge. + // Performance: + // - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. + InsertTxReplacementEdge(ctx context.Context, arg InsertTxReplacementEdgeParams) (int64, error) + // Records a replacement edge by resolving tx IDs from transaction hashes. + // + // How: + // - Resolves both endpoint transaction IDs from the transactions table using + // the wallet-scoped tx-hash unique lookup. + // - Writes the resulting directed edge to tx_replacements. + // - Leaves created_at to the table default so the database records insertion + // time for the edge. + // Performance: + // - Trades two indexed scalar subqueries for one network round trip, which is + // preferable when callers start from tx hashes. + InsertTxReplacementEdgeByHash(ctx context.Context, arg InsertTxReplacementEdgeByHashParams) (int64, error) + // Inserts a new UTXO row and returns its database ID. + // + // How: + // - Writes only the utxos table using already-resolved transaction and address + // IDs. + // - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if + // the provided address ID belongs to the same wallet. + // Performance: + // - Single-row insert. The main cost is the wallet-ownership validation join + // plus FK and uniqueness checks. + InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, error) InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error // Lists all accounts in a scope, ordered by account number. Imported accounts @@ -93,6 +311,18 @@ type Querier interface { // Lists all accounts for a wallet and scope tuple, ordered by account number. // Imported accounts (with NULL account_number) appear last. ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) + // Lists all currently active leases for a wallet. + // + // How: + // - Starts from utxo_leases, then joins utxos and transactions so the result + // can be returned as network outpoints. + // - Filters out expired rows using the caller-supplied UTC timestamp. + // - Restricts the result to outputs that are still unspent and whose parent + // transaction is still in a live state (`pending` or `published`). + // Performance: + // - Restricts first by wallet and expiration, then joins only the surviving + // lease rows back to utxos/transactions. + ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all addresses for a given account identified by wallet_id, key scope @@ -101,6 +331,115 @@ type Querier interface { ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) + // Lists victim txids for a given replacement txid. + // + // How: + // - Starts from tx_replacements, then joins transactions twice on `(wallet_id, + // id)` to map both edge endpoints back to network tx hashes. + // - Filters by the replacement hash on the `replacement` alias. + // Performance: + // - Mirrors the victim lookup path while keeping the graph traversal bounded by + // wallet scope and indexed edge keys. + ListReplacedTxHashesByReplacementTxHash(ctx context.Context, arg ListReplacedTxHashesByReplacementTxHashParams) ([]ListReplacedTxHashesByReplacementTxHashRow, error) + // Lists victim transaction IDs for a given replacement transaction ID. + // + // How: + // - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because + // the caller already has the replacement row ID. + // - Orders first by created_at and then by id so traversal stays deterministic + // even when several edges share the same timestamp. + // Performance: + // - Uses the inverse replacement lookup index without joining transactions. + ListReplacedTxIDsByReplacementTxID(ctx context.Context, arg ListReplacedTxIDsByReplacementTxIDParams) ([]ListReplacedTxIDsByReplacementTxIDRow, error) + // Lists replacement txids for a given victim txid. + // + // How: + // - Starts from tx_replacements, then joins transactions twice on `(wallet_id, + // id)` to map both edge endpoints back to network tx hashes. + // - Filters by the victim hash on the `replaced` alias. + // Performance: + // - The victim hash lookup narrows the graph walk before the second transaction + // join materializes replacement hashes. + ListReplacementTxHashesByReplacedTxHash(ctx context.Context, arg ListReplacementTxHashesByReplacedTxHashParams) ([]ListReplacementTxHashesByReplacedTxHashRow, error) + // Lists replacement transaction IDs for a given victim transaction ID. + // + // How: + // - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the + // caller already has the victim's internal row ID. + // - Orders first by created_at and then by id so traversal stays deterministic + // even when several edges share the same timestamp. + // Performance: + // - Uses the replacement-edge index without joining transactions. + ListReplacementTxIDsByReplacedTxID(ctx context.Context, arg ListReplacementTxIDsByReplacedTxIDParams) ([]ListReplacementTxIDsByReplacedTxIDRow, error) + // Lists wallet-scoped coinbase transaction hashes at or above the rollback + // boundary that seed descendant invalidation. + // + // How: + // - Reads only confirmed coinbase rows at or above the rollback boundary. + // - Returns wallet scope alongside each tx hash so callers can treat these + // coinbase transactions as rollback roots when invalidating now-dead + // descendants inside the same rollback transaction. + // - This is a rollback-specific helper, not a generic "coinbase txs from one + // block" listing query. + // Performance: + // - Uses the block-height index to bound the scan to the rollback range. + ListRollbackCoinbaseRoots(ctx context.Context, rollbackHeight int32) ([]ListRollbackCoinbaseRootsRow, error) + // Lists direct child transaction IDs for one parent transaction ID. + // + // How: + // - Reads the spend edges already materialized on utxos through + // `(tx_id, spent_by_tx_id)`. + // - Returns only direct children; callers that need full descendant walks should + // traverse this query iteratively in application code. + // Performance: + // - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded + // to one wallet-scoped parent. + ListSpendingTxIDsByParentTxID(ctx context.Context, arg ListSpendingTxIDsByParentTxIDParams) ([]sql.NullInt64, error) + // Lists all confirmed transactions for a wallet in the provided height range. + // + // How: + // - Reads transactions in a wallet-scoped block-height range. + // - INNER JOINs blocks on the natural `block_height` key to hydrate block hash + // and timestamp for confirmed rows. + // Performance: + // - The `(wallet_id, block_height)` index bounds the scan before the single-row + // block join. + ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) + // Lists all unconfirmed transactions for a wallet. + // + // How: + // - Reads from transactions only and filters on blockless rows that are still + // in a live unconfirmed state (`pending` or `published`). + // - Excludes orphaned/replaced/failed history so rollback-produced coinbase + // rows do not reappear as mempool transactions. + // - Returns typed NULL block metadata explicitly because live unmined rows have + // no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` + // keep the unmined row shape aligned with the confirmed query below. + // Performance: + // - Matches the dedicated blockless-history index while the more selective + // live-only partial index stays available for conflict paths. + ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) + // Lists unspent UTXOs that match the provided filters. + // + // How: + // - Starts from utxos and joins transactions for tx metadata plus + // wallet_sync_states for confirmation math. + // - Joins addresses to return the required script_pub_key, then reuses + // addresses -> accounts -> key_scopes so account filtering and wallet + // ownership checks happen in the same read. + // - Returns leased outputs too because the API models leases separately from + // UTXO existence. + // - Includes outputs whose parent transaction is still in a live unconfirmed + // state (`pending` or `published`). + // - Intentionally does not enforce coinbase maturity because this query models + // wallet-owned UTXO existence rather than a strictly spendable subset. + // Performance: + // - Restricts first by wallet, spend state, and transaction status. + // - Uses the address/account/scope joins to keep ownership validation and + // account filtering in one pass. + // - Treats min/max confirmations as optional filters so callers can + // distinguish "not set" from an explicit zero-conf request. + ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) // Acquires a transaction-level advisory lock to serialize account creation within a scope. // The lock is automatically released upon transaction commit or rollback. @@ -126,10 +465,66 @@ type Querier interface { // LockAccountScope returns, guaranteeing that the subsequent SELECT MAX() // operates inside a strictly serialized execution window for that scope. LockAccountScope(ctx context.Context, dollar_1 int64) error + // Marks a wallet-owned UTXO as spent by a transaction. + // + // How: + // - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside + // the statement so callers can update by network outpoint. + // - Requires the parent transaction status to be `pending` or `published` + // before a child spend edge can attach. + // - Only changes rows that are currently unspent or already point at the same + // `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent + // without allowing a caller to silently rewrite which input spent the UTXO. + // Performance: + // - Targets one outpoint in one wallet; the subquery uses the unique + // wallet-scoped tx hash lookup. + MarkUtxoSpent(ctx context.Context, arg MarkUtxoSpentParams) (int64, error) + // Releases a lease for a UTXO ID if the lock_id matches. + // + // How: + // - Deletes by wallet, utxo ID, and lock ID so one caller cannot release + // another caller's active lease accidentally. + // Performance: + // - Targets at most one row through the unique lease key. + ReleaseUtxoLease(ctx context.Context, arg ReleaseUtxoLeaseParams) (int64, error) + // Rewrites wallet sync-state heights so they stop referencing blocks that are + // about to be deleted during RollbackToBlock. + // + // How: + // - Updates wallet_sync_states directly without joining other tables. + // - Rewrites both synced_height and birthday_height in one statement so the + // subsequent block delete does not violate `ON DELETE RESTRICT`. + // - Example: if `rollback_height = 195`, then any `synced_height` or + // `birthday_height` at 195 or above rewinds to `new_height = 194`. + // - If rollback starts from height 0, callers pass `new_height = NULL` so the + // sync state no longer points at any surviving block row. + // Performance: + // - Touches only wallet_sync_states rows whose heights are at or above the + // rollback boundary. + RewindWalletSyncStateHeightsForRollback(ctx context.Context, arg RewindWalletSyncStateHeightsForRollbackParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) + // Updates only the user-visible transaction label. + // + // How: + // - Leaves block assignment and status untouched. + // - Exists for user-facing metadata edits only; wallet-internal state + // transitions use dedicated helper queries. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) + // Updates the wallet-relative status for a set of transaction row IDs. + // + // How: + // - Exists for wallet-internal replacement and invalidation flows after the + // caller has already identified the affected rows. + // - Leaves block assignment untouched; rollback/disconnect continues to use the + // dedicated rewind helpers below. + // Performance: + // - Restricts by wallet scope first, then matches only the provided ID set. + UpdateTransactionStatusByIDs(ctx context.Context, arg UpdateTransactionStatusByIDsParams) (int64, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/db/sqlc/postgres/transactions.sql.go new file mode 100644 index 0000000000..2cb9a124e7 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/transactions.sql.go @@ -0,0 +1,606 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: transactions.sql + +package sqlcpg + +import ( + "context" + "database/sql" + "time" + + "github.com/lib/pq" +) + +const ConfirmUnminedTransactionByHash = `-- name: ConfirmUnminedTransactionByHash :execrows +UPDATE transactions +SET + block_height = $1::INTEGER, + tx_status = 1 +WHERE + wallet_id = $2 + AND tx_hash = $3 + AND block_height IS NULL + AND tx_status IN (0, 1) +` + +type ConfirmUnminedTransactionByHashParams struct { + BlockHeight int32 + WalletID int64 + TxHash []byte +} + +// Attaches a confirming block to one existing live unmined transaction row. +// +// How: +// - Updates only rows that are still blockless and live (`pending` or +// `published`). +// - Leaves user-visible metadata such as labels untouched so confirmation can +// reuse the original transaction row instead of reinserting it. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) { + result, err := q.exec(ctx, q.confirmUnminedTransactionByHashStmt, ConfirmUnminedTransactionByHash, arg.BlockHeight, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteBlocksAtOrAboveHeight = `-- name: DeleteBlocksAtOrAboveHeight :execrows +DELETE FROM blocks +WHERE block_height >= $1 +` + +// Deletes blocks at and after the provided height. +// +// How: +// - Deletes directly from blocks by the natural height key. +// - Relies on FK/trigger side effects to null transaction block references and +// orphan coinbase rows. +// +// Performance: +// - Executes as a range delete over the block-height primary key. +func (q *Queries) DeleteBlocksAtOrAboveHeight(ctx context.Context, blockHeight int32) (int64, error) { + result, err := q.exec(ctx, q.deleteBlocksAtOrAboveHeightStmt, DeleteBlocksAtOrAboveHeight, blockHeight) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteUnminedTransactionByHash = `-- name: DeleteUnminedTransactionByHash :execrows +DELETE FROM transactions +WHERE + wallet_id = $1 + AND tx_hash = $2 + AND block_height IS NULL + AND tx_status IN (0, 1) +` + +type DeleteUnminedTransactionByHashParams struct { + WalletID int64 + TxHash []byte +} + +// Deletes an unconfirmed transaction row. +// +// How: +// - Deletes only rows whose `block_height` is still NULL and whose status is +// still in a live unconfirmed state (`pending` or `published`). +// - Preserves orphaned/replaced/failed history; those rows must remain visible +// for audit/reorg handling instead of being treated as ordinary mempool data. +// - The caller must delete or restore dependent UTXO rows first. +// +// Performance: +// - Targets at most one row by `(wallet_id, tx_hash)`. +func (q *Queries) DeleteUnminedTransactionByHash(ctx context.Context, arg DeleteUnminedTransactionByHashParams) (int64, error) { + result, err := q.exec(ctx, q.deleteUnminedTransactionByHashStmt, DeleteUnminedTransactionByHash, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetTransactionByHash = `-- name: GetTransactionByHash :one +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON t.block_height = b.block_height +WHERE t.wallet_id = $1 AND t.tx_hash = $2 +` + +type GetTransactionByHashParams struct { + WalletID int64 + TxHash []byte +} + +type GetTransactionByHashRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt32 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Retrieves the full transaction row along with optional block metadata. +// +// How: +// - Looks up the transaction by `(wallet_id, tx_hash)`. +// - LEFT JOINs blocks on `block_height` so the same query handles mined and +// unmined rows. +// +// Performance: +// - The unique transaction lookup limits the join fanout to at most one block +// row. +func (q *Queries) GetTransactionByHash(ctx context.Context, arg GetTransactionByHashParams) (GetTransactionByHashRow, error) { + row := q.queryRow(ctx, q.getTransactionByHashStmt, GetTransactionByHash, arg.WalletID, arg.TxHash) + var i GetTransactionByHashRow + err := row.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ) + return i, err +} + +const GetTransactionMetaByHash = `-- name: GetTransactionMetaByHash :one +SELECT + id, + block_height, + is_coinbase, + tx_status, + tx_label +FROM transactions +WHERE wallet_id = $1 AND tx_hash = $2 +` + +type GetTransactionMetaByHashParams struct { + WalletID int64 + TxHash []byte +} + +type GetTransactionMetaByHashRow struct { + ID int64 + BlockHeight sql.NullInt32 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Retrieves the primary key and lightweight transaction metadata. +// +// How: +// - Reads only the transactions table because callers only need row identity +// plus lightweight status/label fields. +// +// Performance: +// - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. +func (q *Queries) GetTransactionMetaByHash(ctx context.Context, arg GetTransactionMetaByHashParams) (GetTransactionMetaByHashRow, error) { + row := q.queryRow(ctx, q.getTransactionMetaByHashStmt, GetTransactionMetaByHash, arg.WalletID, arg.TxHash) + var i GetTransactionMetaByHashRow + err := row.Scan( + &i.ID, + &i.BlockHeight, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ) + return i, err +} + +const InsertTransaction = `-- name: InsertTransaction :one +INSERT INTO transactions ( + wallet_id, + tx_hash, + raw_tx, + block_height, + tx_status, + received_time, + is_coinbase, + tx_label +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +RETURNING id +` + +type InsertTransactionParams struct { + WalletID int64 + TxHash []byte + RawTx []byte + BlockHeight sql.NullInt32 + TxStatus int16 + ReceivedTime time.Time + IsCoinbase bool + TxLabel string +} + +// Inserts a wallet-scoped transaction row and returns its database ID. +// +// How: +// - Writes only the transactions table. +// - Expects the caller to have already resolved wallet scope and any optional +// block reference. +// - Expects the caller to supply the initial status explicitly so unmined rows +// do not have to guess between `pending` and `published`. +// +// Performance: +// - Single-row insert. The cost is dominated by the wallet/hash uniqueness +// checks and any optional block foreign-key validation. +func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { + row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, + arg.WalletID, + arg.TxHash, + arg.RawTx, + arg.BlockHeight, + arg.TxStatus, + arg.ReceivedTime, + arg.IsCoinbase, + arg.TxLabel, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const ListRollbackCoinbaseRoots = `-- name: ListRollbackCoinbaseRoots :many +SELECT + wallet_id, + tx_hash +FROM transactions +WHERE + block_height >= $1::INTEGER + AND is_coinbase +ORDER BY wallet_id, id +` + +type ListRollbackCoinbaseRootsRow struct { + WalletID int64 + TxHash []byte +} + +// Lists wallet-scoped coinbase transaction hashes at or above the rollback +// boundary that seed descendant invalidation. +// +// How: +// - Reads only confirmed coinbase rows at or above the rollback boundary. +// - Returns wallet scope alongside each tx hash so callers can treat these +// coinbase transactions as rollback roots when invalidating now-dead +// descendants inside the same rollback transaction. +// - This is a rollback-specific helper, not a generic "coinbase txs from one +// block" listing query. +// +// Performance: +// - Uses the block-height index to bound the scan to the rollback range. +func (q *Queries) ListRollbackCoinbaseRoots(ctx context.Context, rollbackHeight int32) ([]ListRollbackCoinbaseRootsRow, error) { + rows, err := q.query(ctx, q.listRollbackCoinbaseRootsStmt, ListRollbackCoinbaseRoots, rollbackHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRollbackCoinbaseRootsRow + for rows.Next() { + var i ListRollbackCoinbaseRootsRow + if err := rows.Scan(&i.WalletID, &i.TxHash); 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 ListTransactionsByHeightRange = `-- name: ListTransactionsByHeightRange :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +INNER JOIN blocks AS b ON t.block_height = b.block_height +WHERE + t.wallet_id = $1 + AND t.block_height BETWEEN + $2::INTEGER + AND $3::INTEGER +ORDER BY t.block_height, t.id +` + +type ListTransactionsByHeightRangeParams struct { + WalletID int64 + StartHeight int32 + EndHeight int32 +} + +type ListTransactionsByHeightRangeRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt32 + BlockHash []byte + BlockTimestamp int64 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Lists all confirmed transactions for a wallet in the provided height range. +// +// How: +// - Reads transactions in a wallet-scoped block-height range. +// - INNER JOINs blocks on the natural `block_height` key to hydrate block hash +// and timestamp for confirmed rows. +// +// Performance: +// - The `(wallet_id, block_height)` index bounds the scan before the single-row +// block join. +func (q *Queries) ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) { + rows, err := q.query(ctx, q.listTransactionsByHeightRangeStmt, ListTransactionsByHeightRange, arg.WalletID, arg.StartHeight, arg.EndHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTransactionsByHeightRangeRow + for rows.Next() { + var i ListTransactionsByHeightRangeRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); 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 ListUnminedTransactions = `-- name: ListUnminedTransactions :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + NULL::BYTEA AS block_hash, + NULL::BIGINT AS block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.block_height IS NULL + AND t.tx_status IN (0, 1) +ORDER BY t.received_time DESC, t.id DESC +` + +type ListUnminedTransactionsRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt32 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Lists all unconfirmed transactions for a wallet. +// +// How: +// - Reads from transactions only and filters on blockless rows that are still +// in a live unconfirmed state (`pending` or `published`). +// - Excludes orphaned/replaced/failed history so rollback-produced coinbase +// rows do not reappear as mempool transactions. +// - Returns typed NULL block metadata explicitly because live unmined rows have +// no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +// keep the unmined row shape aligned with the confirmed query below. +// +// Performance: +// - Matches the dedicated blockless-history index while the more selective +// live-only partial index stays available for conflict paths. +func (q *Queries) ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) { + rows, err := q.query(ctx, q.listUnminedTransactionsStmt, ListUnminedTransactions, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUnminedTransactionsRow + for rows.Next() { + var i ListUnminedTransactionsRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); 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 RewindWalletSyncStateHeightsForRollback = `-- name: RewindWalletSyncStateHeightsForRollback :execrows +UPDATE wallet_sync_states +SET + synced_height = CASE + WHEN + synced_height IS NOT NULL + AND synced_height >= $1::INTEGER + THEN $2 + ELSE synced_height + END, + birthday_height = CASE + WHEN + birthday_height IS NOT NULL + AND birthday_height >= $1::INTEGER + THEN $2 + ELSE birthday_height + END, + updated_at = current_timestamp AT TIME ZONE 'UTC' +WHERE + ( + synced_height IS NOT NULL + AND synced_height >= $1::INTEGER + ) + OR ( + birthday_height IS NOT NULL + AND birthday_height >= $1::INTEGER + ) +` + +type RewindWalletSyncStateHeightsForRollbackParams struct { + RollbackHeight int32 + NewHeight sql.NullInt32 +} + +// Rewrites wallet sync-state heights so they stop referencing blocks that are +// about to be deleted during RollbackToBlock. +// +// How: +// - Updates wallet_sync_states directly without joining other tables. +// - Rewrites both synced_height and birthday_height in one statement so the +// subsequent block delete does not violate `ON DELETE RESTRICT`. +// - Example: if `rollback_height = 195`, then any `synced_height` or +// `birthday_height` at 195 or above rewinds to `new_height = 194`. +// - If rollback starts from height 0, callers pass `new_height = NULL` so the +// sync state no longer points at any surviving block row. +// +// Performance: +// - Touches only wallet_sync_states rows whose heights are at or above the +// rollback boundary. +func (q *Queries) RewindWalletSyncStateHeightsForRollback(ctx context.Context, arg RewindWalletSyncStateHeightsForRollbackParams) (int64, error) { + result, err := q.exec(ctx, q.rewindWalletSyncStateHeightsForRollbackStmt, RewindWalletSyncStateHeightsForRollback, arg.RollbackHeight, arg.NewHeight) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateTransactionLabelByHash = `-- name: UpdateTransactionLabelByHash :execrows +UPDATE transactions +SET tx_label = $1 +WHERE + wallet_id = $2 + AND tx_hash = $3 +` + +type UpdateTransactionLabelByHashParams struct { + Label string + WalletID int64 + TxHash []byte +} + +// Updates only the user-visible transaction label. +// +// How: +// - Leaves block assignment and status untouched. +// - Exists for user-facing metadata edits only; wallet-internal state +// transitions use dedicated helper queries. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionLabelByHashStmt, UpdateTransactionLabelByHash, arg.Label, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateTransactionStatusByIDs = `-- name: UpdateTransactionStatusByIDs :execrows +UPDATE transactions +SET tx_status = $1 +WHERE + wallet_id = $2 + AND id = any($3::BIGINT []) +` + +type UpdateTransactionStatusByIDsParams struct { + Status int16 + WalletID int64 + TxIds []int64 +} + +// Updates the wallet-relative status for a set of transaction row IDs. +// +// How: +// - Exists for wallet-internal replacement and invalidation flows after the +// caller has already identified the affected rows. +// - Leaves block assignment untouched; rollback/disconnect continues to use the +// dedicated rewind helpers below. +// +// Performance: +// - Restricts by wallet scope first, then matches only the provided ID set. +func (q *Queries) UpdateTransactionStatusByIDs(ctx context.Context, arg UpdateTransactionStatusByIDsParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionStatusByIDsStmt, UpdateTransactionStatusByIDs, arg.Status, arg.WalletID, pq.Array(arg.TxIds)) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/postgres/tx_replacements.sql.go b/wallet/internal/db/sqlc/postgres/tx_replacements.sql.go new file mode 100644 index 0000000000..c536d3710b --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/tx_replacements.sql.go @@ -0,0 +1,314 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: tx_replacements.sql + +package sqlcpg + +import ( + "context" + "time" +) + +const InsertTxReplacementEdge = `-- name: InsertTxReplacementEdge :execrows +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + $1, $2, $3 +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING +` + +type InsertTxReplacementEdgeParams struct { + WalletID int64 + ReplacedTxID int64 + ReplacementTxID int64 +} + +// Records a replacement edge between two wallet-scoped transactions. +// +// How: +// - Writes directly to tx_replacements using already-resolved transaction IDs. +// - Relies on the wallet-scoped unique edge constraint to collapse retries into +// one stored edge. +// - Leaves created_at to the table default so the database records insertion +// time for the edge. +// +// Performance: +// - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. +func (q *Queries) InsertTxReplacementEdge(ctx context.Context, arg InsertTxReplacementEdgeParams) (int64, error) { + result, err := q.exec(ctx, q.insertTxReplacementEdgeStmt, InsertTxReplacementEdge, arg.WalletID, arg.ReplacedTxID, arg.ReplacementTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const InsertTxReplacementEdgeByHash = `-- name: InsertTxReplacementEdgeByHash :execrows +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + $1, + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = $1 AND t.tx_hash = $2 + ), + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = $1 AND t.tx_hash = $3 + ) +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING +` + +type InsertTxReplacementEdgeByHashParams struct { + WalletID int64 + TxHash []byte + TxHash_2 []byte +} + +// Records a replacement edge by resolving tx IDs from transaction hashes. +// +// How: +// - Resolves both endpoint transaction IDs from the transactions table using +// the wallet-scoped tx-hash unique lookup. +// - Writes the resulting directed edge to tx_replacements. +// - Leaves created_at to the table default so the database records insertion +// time for the edge. +// +// Performance: +// - Trades two indexed scalar subqueries for one network round trip, which is +// preferable when callers start from tx hashes. +func (q *Queries) InsertTxReplacementEdgeByHash(ctx context.Context, arg InsertTxReplacementEdgeByHashParams) (int64, error) { + result, err := q.exec(ctx, q.insertTxReplacementEdgeByHashStmt, InsertTxReplacementEdgeByHash, arg.WalletID, arg.TxHash, arg.TxHash_2) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const ListReplacedTxHashesByReplacementTxHash = `-- name: ListReplacedTxHashesByReplacementTxHash :many +SELECT + replaced.tx_hash AS replaced_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = $1 AND replacement.tx_hash = $2 +ORDER BY r.created_at, r.id +` + +type ListReplacedTxHashesByReplacementTxHashParams struct { + WalletID int64 + TxHash []byte +} + +type ListReplacedTxHashesByReplacementTxHashRow struct { + ReplacedTxHash []byte + CreatedAt time.Time +} + +// Lists victim txids for a given replacement txid. +// +// How: +// - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +// id)` to map both edge endpoints back to network tx hashes. +// - Filters by the replacement hash on the `replacement` alias. +// +// Performance: +// - Mirrors the victim lookup path while keeping the graph traversal bounded by +// wallet scope and indexed edge keys. +func (q *Queries) ListReplacedTxHashesByReplacementTxHash(ctx context.Context, arg ListReplacedTxHashesByReplacementTxHashParams) ([]ListReplacedTxHashesByReplacementTxHashRow, error) { + rows, err := q.query(ctx, q.listReplacedTxHashesByReplacementTxHashStmt, ListReplacedTxHashesByReplacementTxHash, arg.WalletID, arg.TxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacedTxHashesByReplacementTxHashRow + for rows.Next() { + var i ListReplacedTxHashesByReplacementTxHashRow + if err := rows.Scan(&i.ReplacedTxHash, &i.CreatedAt); 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 ListReplacedTxIDsByReplacementTxID = `-- name: ListReplacedTxIDsByReplacementTxID :many +SELECT + replaced_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = $1 AND replacement_tx_id = $2 +ORDER BY created_at, id +` + +type ListReplacedTxIDsByReplacementTxIDParams struct { + WalletID int64 + ReplacementTxID int64 +} + +type ListReplacedTxIDsByReplacementTxIDRow struct { + ReplacedTxID int64 + CreatedAt time.Time +} + +// Lists victim transaction IDs for a given replacement transaction ID. +// +// How: +// - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because +// the caller already has the replacement row ID. +// - Orders first by created_at and then by id so traversal stays deterministic +// even when several edges share the same timestamp. +// +// Performance: +// - Uses the inverse replacement lookup index without joining transactions. +func (q *Queries) ListReplacedTxIDsByReplacementTxID(ctx context.Context, arg ListReplacedTxIDsByReplacementTxIDParams) ([]ListReplacedTxIDsByReplacementTxIDRow, error) { + rows, err := q.query(ctx, q.listReplacedTxIDsByReplacementTxIDStmt, ListReplacedTxIDsByReplacementTxID, arg.WalletID, arg.ReplacementTxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacedTxIDsByReplacementTxIDRow + for rows.Next() { + var i ListReplacedTxIDsByReplacementTxIDRow + if err := rows.Scan(&i.ReplacedTxID, &i.CreatedAt); 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 ListReplacementTxHashesByReplacedTxHash = `-- name: ListReplacementTxHashesByReplacedTxHash :many +SELECT + replacement.tx_hash AS replacement_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = $1 AND replaced.tx_hash = $2 +ORDER BY r.created_at, r.id +` + +type ListReplacementTxHashesByReplacedTxHashParams struct { + WalletID int64 + TxHash []byte +} + +type ListReplacementTxHashesByReplacedTxHashRow struct { + ReplacementTxHash []byte + CreatedAt time.Time +} + +// Lists replacement txids for a given victim txid. +// +// How: +// - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +// id)` to map both edge endpoints back to network tx hashes. +// - Filters by the victim hash on the `replaced` alias. +// +// Performance: +// - The victim hash lookup narrows the graph walk before the second transaction +// join materializes replacement hashes. +func (q *Queries) ListReplacementTxHashesByReplacedTxHash(ctx context.Context, arg ListReplacementTxHashesByReplacedTxHashParams) ([]ListReplacementTxHashesByReplacedTxHashRow, error) { + rows, err := q.query(ctx, q.listReplacementTxHashesByReplacedTxHashStmt, ListReplacementTxHashesByReplacedTxHash, arg.WalletID, arg.TxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacementTxHashesByReplacedTxHashRow + for rows.Next() { + var i ListReplacementTxHashesByReplacedTxHashRow + if err := rows.Scan(&i.ReplacementTxHash, &i.CreatedAt); 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 ListReplacementTxIDsByReplacedTxID = `-- name: ListReplacementTxIDsByReplacedTxID :many +SELECT + replacement_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = $1 AND replaced_tx_id = $2 +ORDER BY created_at, id +` + +type ListReplacementTxIDsByReplacedTxIDParams struct { + WalletID int64 + ReplacedTxID int64 +} + +type ListReplacementTxIDsByReplacedTxIDRow struct { + ReplacementTxID int64 + CreatedAt time.Time +} + +// Lists replacement transaction IDs for a given victim transaction ID. +// +// How: +// - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the +// caller already has the victim's internal row ID. +// - Orders first by created_at and then by id so traversal stays deterministic +// even when several edges share the same timestamp. +// +// Performance: +// - Uses the replacement-edge index without joining transactions. +func (q *Queries) ListReplacementTxIDsByReplacedTxID(ctx context.Context, arg ListReplacementTxIDsByReplacedTxIDParams) ([]ListReplacementTxIDsByReplacedTxIDRow, error) { + rows, err := q.query(ctx, q.listReplacementTxIDsByReplacedTxIDStmt, ListReplacementTxIDsByReplacedTxID, arg.WalletID, arg.ReplacedTxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacementTxIDsByReplacedTxIDRow + for rows.Next() { + var i ListReplacementTxIDsByReplacedTxIDRow + if err := rows.Scan(&i.ReplacementTxID, &i.CreatedAt); 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 +} diff --git a/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go b/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go new file mode 100644 index 0000000000..354d7b34ba --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go @@ -0,0 +1,251 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: utxo_leases.sql + +package sqlcpg + +import ( + "context" + "time" +) + +const AcquireUtxoLease = `-- name: AcquireUtxoLease :one +INSERT INTO utxo_leases ( + wallet_id, + utxo_id, + lock_id, + expires_at +) +SELECT + $1 AS wallet_id, + u.id AS utxo_id, + $2 AS lock_id, + $3::TIMESTAMP AS expires_at +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +WHERE + t.wallet_id = $1 + AND t.tx_hash = $4 + AND u.output_index = $5 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +FOR UPDATE OF u +ON CONFLICT (utxo_id) DO UPDATE + SET + lock_id = excluded.lock_id, + expires_at = excluded.expires_at + WHERE + utxo_leases.wallet_id = excluded.wallet_id + AND ( + utxo_leases.expires_at <= $6::TIMESTAMP + OR utxo_leases.lock_id = excluded.lock_id + ) +RETURNING expires_at +` + +type AcquireUtxoLeaseParams struct { + WalletID int64 + LockID []byte + ExpiresAt time.Time + TxHash []byte + OutputIndex int32 + NowUtc time.Time +} + +// Acquires or renews a lease for an outpoint and returns the resulting +// expiration time. +// +// How: +// - Resolves the outpoint to a current UTXO row and writes the lease in the +// same statement. +// - Rechecks that the outpoint is still unspent and its parent transaction is +// still in a live state (`pending` or `published`) at write time. +// - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, +// and expired-lease takeover all happen atomically. +// +// Lease semantics: +// - If the UTXO has no lease row, insert a new lease. +// - If the UTXO has an expired lease, steal it. +// - If the UTXO has an active lease with the same lock_id, renew it. +// - If the UTXO has an active lease with a different lock_id, return no +// rows (caller should treat this as "already leased"). +// +// NOTE: expires_at is stored as a UTC-normalized TIMESTAMP. Callers should pass +// UTC values for both expires_at and now_utc. +// Performance: +// - Locks the target utxo row during resolution so concurrent spend updates on +// that row serialize with lease acquisition. +func (q *Queries) AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) { + row := q.queryRow(ctx, q.acquireUtxoLeaseStmt, AcquireUtxoLease, + arg.WalletID, + arg.LockID, + arg.ExpiresAt, + arg.TxHash, + arg.OutputIndex, + arg.NowUtc, + ) + var expires_at time.Time + err := row.Scan(&expires_at) + return expires_at, err +} + +const DeleteExpiredUtxoLeases = `-- name: DeleteExpiredUtxoLeases :execrows +DELETE FROM utxo_leases +WHERE + wallet_id = $1 + AND expires_at <= $2::TIMESTAMP +` + +type DeleteExpiredUtxoLeasesParams struct { + WalletID int64 + NowUtc time.Time +} + +// Deletes all expired lease rows for a wallet. +// +// How: +// - Removes only rows whose expiration has passed, leaving active leases +// untouched. +// - Uses the caller-supplied UTC timestamp so lease lifetime semantics do not +// depend on database session timezone settings. +// +// Performance: +// - Uses the expiration predicate together with wallet scoping to bound the +// cleanup pass. +func (q *Queries) DeleteExpiredUtxoLeases(ctx context.Context, arg DeleteExpiredUtxoLeasesParams) (int64, error) { + result, err := q.exec(ctx, q.deleteExpiredUtxoLeasesStmt, DeleteExpiredUtxoLeases, arg.WalletID, arg.NowUtc) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetActiveUtxoLeaseLockID = `-- name: GetActiveUtxoLeaseLockID :one +SELECT lock_id +FROM utxo_leases +WHERE + wallet_id = $1 + AND utxo_id = $2 + AND expires_at > $3::TIMESTAMP +` + +type GetActiveUtxoLeaseLockIDParams struct { + WalletID int64 + UtxoID int64 + NowUtc time.Time +} + +// Returns the lock ID for the current active lease on a UTXO ID. +// +// How: +// - Reads only non-expired lease rows so callers can distinguish an active +// lock-ID mismatch from an already-unlocked output. +// +// Performance: +// - Targets at most one row through the unique lease key. +func (q *Queries) GetActiveUtxoLeaseLockID(ctx context.Context, arg GetActiveUtxoLeaseLockIDParams) ([]byte, error) { + row := q.queryRow(ctx, q.getActiveUtxoLeaseLockIDStmt, GetActiveUtxoLeaseLockID, arg.WalletID, arg.UtxoID, arg.NowUtc) + var lock_id []byte + err := row.Scan(&lock_id) + return lock_id, err +} + +const ListActiveUtxoLeases = `-- name: ListActiveUtxoLeases :many +SELECT + t.tx_hash, + u.output_index, + l.lock_id, + l.expires_at +FROM utxo_leases AS l +INNER JOIN utxos AS u ON l.utxo_id = u.id +INNER JOIN transactions AS t ON u.tx_id = t.id +WHERE + l.wallet_id = $1 + AND l.expires_at > $2::TIMESTAMP + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +ORDER BY l.expires_at +` + +type ListActiveUtxoLeasesParams struct { + WalletID int64 + NowUtc time.Time +} + +type ListActiveUtxoLeasesRow struct { + TxHash []byte + OutputIndex int32 + LockID []byte + ExpiresAt time.Time +} + +// Lists all currently active leases for a wallet. +// +// How: +// - Starts from utxo_leases, then joins utxos and transactions so the result +// can be returned as network outpoints. +// - Filters out expired rows using the caller-supplied UTC timestamp. +// - Restricts the result to outputs that are still unspent and whose parent +// transaction is still in a live state (`pending` or `published`). +// +// Performance: +// - Restricts first by wallet and expiration, then joins only the surviving +// lease rows back to utxos/transactions. +func (q *Queries) ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) { + rows, err := q.query(ctx, q.listActiveUtxoLeasesStmt, ListActiveUtxoLeases, arg.WalletID, arg.NowUtc) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveUtxoLeasesRow + for rows.Next() { + var i ListActiveUtxoLeasesRow + if err := rows.Scan( + &i.TxHash, + &i.OutputIndex, + &i.LockID, + &i.ExpiresAt, + ); 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 ReleaseUtxoLease = `-- name: ReleaseUtxoLease :execrows +DELETE FROM utxo_leases +WHERE + utxo_leases.wallet_id = $1 + AND utxo_leases.utxo_id = $2 + AND utxo_leases.lock_id = $3 +` + +type ReleaseUtxoLeaseParams struct { + WalletID int64 + UtxoID int64 + LockID []byte +} + +// Releases a lease for a UTXO ID if the lock_id matches. +// +// How: +// - Deletes by wallet, utxo ID, and lock ID so one caller cannot release +// another caller's active lease accidentally. +// +// Performance: +// - Targets at most one row through the unique lease key. +func (q *Queries) ReleaseUtxoLease(ctx context.Context, arg ReleaseUtxoLeaseParams) (int64, error) { + result, err := q.exec(ctx, q.releaseUtxoLeaseStmt, ReleaseUtxoLease, arg.WalletID, arg.UtxoID, arg.LockID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/postgres/utxos.sql.go b/wallet/internal/db/sqlc/postgres/utxos.sql.go new file mode 100644 index 0000000000..2363718f03 --- /dev/null +++ b/wallet/internal/db/sqlc/postgres/utxos.sql.go @@ -0,0 +1,638 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: utxos.sql + +package sqlcpg + +import ( + "context" + "database/sql" + "time" +) + +const Balance = `-- name: Balance :one +SELECT + (coalesce(sum(u.amount), 0))::BIGINT AS total_balance, + (coalesce( + sum(u.amount) FILTER ( + WHERE EXISTS ( + SELECT 1 + FROM utxo_leases AS l + WHERE + l.wallet_id = t.wallet_id + AND l.utxo_id = u.id + AND l.expires_at > $1::TIMESTAMP + ) + ), + 0 + ))::BIGINT AS locked_balance +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = $2 + AND ks.wallet_id = $2 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + $3::BIGINT IS NULL + OR acc.account_number = $3::BIGINT + ) + AND ( + $4::INTEGER IS NULL + OR $4::INTEGER = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= $4::INTEGER + ) + AND ( + $5::INTEGER IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= $5::INTEGER + ) + AND ( + $6::INTEGER IS NULL + OR $6::INTEGER = 0 + OR NOT t.is_coinbase + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= $6::INTEGER + ) +` + +type BalanceParams struct { + NowUtc time.Time + WalletID int64 + AccountNumber sql.NullInt64 + MinConfirms sql.NullInt32 + MaxConfirms sql.NullInt32 + CoinbaseMaturity sql.NullInt32 +} + +type BalanceRow struct { + TotalBalance int64 + LockedBalance int64 +} + +// Returns the total and locked value represented by the wallet's current +// unspent UTXO set. +// +// How: +// - Starts from wallet-scoped unspent outputs and rejoins transactions plus +// wallet_sync_states for confirmation math. +// - Rejoins addresses -> accounts -> key_scopes so ownership validation and +// optional account filtering stay in one read. +// - Applies optional confirmation-range and coinbase-maturity policy directly +// inside the aggregate query so callers can request factual or policy-shaped +// balance reads through one public method. +// - Returns both the total matching value and the locked subset covered by +// active leases after the same filters are applied. +// +// Performance: +// - Executes as one aggregate over wallet-scoped live outputs. +// - Uses a filtered aggregate over active leases rather than issuing a second +// query for the locked subset. +// - Uses the address/account/scope joins to keep ownership validation and +// account filtering in one pass. +func (q *Queries) Balance(ctx context.Context, arg BalanceParams) (BalanceRow, error) { + row := q.queryRow(ctx, q.balanceStmt, Balance, + arg.NowUtc, + arg.WalletID, + arg.AccountNumber, + arg.MinConfirms, + arg.MaxConfirms, + arg.CoinbaseMaturity, + ) + var i BalanceRow + err := row.Scan(&i.TotalBalance, &i.LockedBalance) + return i, err +} + +const ClearUtxosSpentByTxID = `-- name: ClearUtxosSpentByTxID :execrows +UPDATE utxos AS u +SET + spent_by_tx_id = NULL, + spent_input_index = NULL +WHERE + u.spent_by_tx_id = $2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ) +` + +type ClearUtxosSpentByTxIDParams struct { + WalletID int64 + SpentByTxID sql.NullInt64 +} + +// Clears spent_by pointers for all UTXOs spent by the provided transaction ID. +// +// How: +// - Resets both spent columns together so the logical spend pointer remains +// internally consistent. +// +// Performance: +// - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet +// ownership through the creating transaction. +func (q *Queries) ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) { + result, err := q.exec(ctx, q.clearUtxosSpentByTxIDStmt, ClearUtxosSpentByTxID, arg.WalletID, arg.SpentByTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteUtxosByTxID = `-- name: DeleteUtxosByTxID :execrows +DELETE FROM utxos AS u +WHERE + u.tx_id = $2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ) +` + +type DeleteUtxosByTxIDParams struct { + WalletID int64 + TxID int64 +} + +// Deletes all UTXO rows created by the provided transaction ID. +// +// How: +// - Removes outputs by the parent transaction's internal ID after callers have +// already decided the transaction row itself may be deleted. +// +// Performance: +// - Uses the `(tx_id)` index to keep the delete bounded to one transaction's +// outputs, then rechecks wallet ownership through the parent transaction. +func (q *Queries) DeleteUtxosByTxID(ctx context.Context, arg DeleteUtxosByTxIDParams) (int64, error) { + result, err := q.exec(ctx, q.deleteUtxosByTxIDStmt, DeleteUtxosByTxID, arg.WalletID, arg.TxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetUtxoByOutpoint = `-- name: GetUtxoByOutpoint :one +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +` + +type GetUtxoByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 +} + +type GetUtxoByOutpointRow struct { + TxHash []byte + OutputIndex int32 + Amount int64 + ScriptPubKey []byte + ReceivedTime time.Time + IsCoinbase bool + BlockHeight sql.NullInt32 +} + +// Retrieves a single unspent UTXO by its outpoint. +// +// How: +// - Joins utxos -> transactions on `tx_id` to resolve the outpoint +// from tx hash plus output index. +// - Joins addresses -> accounts -> key_scopes so the read path reasserts that +// the credited address belongs to the requested wallet. +// - Returns leased and unleased outputs alike because leasing affects coin +// selection, not whether the UTXO exists. +// - Treats outputs from both live unconfirmed parent states (`pending` and +// `published`) as part of the wallet's current UTXO set. +// +// Performance: +// - The wallet-scoped tx hash lookup and unique outpoint constraint keep the +// join fanout to at most one candidate output. +func (q *Queries) GetUtxoByOutpoint(ctx context.Context, arg GetUtxoByOutpointParams) (GetUtxoByOutpointRow, error) { + row := q.queryRow(ctx, q.getUtxoByOutpointStmt, GetUtxoByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var i GetUtxoByOutpointRow + err := row.Scan( + &i.TxHash, + &i.OutputIndex, + &i.Amount, + &i.ScriptPubKey, + &i.ReceivedTime, + &i.IsCoinbase, + &i.BlockHeight, + ) + return i, err +} + +const GetUtxoIDByOutpoint = `-- name: GetUtxoIDByOutpoint :one +SELECT u.id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +` + +type GetUtxoIDByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 +} + +// Retrieves the database ID for a current UTXO by its outpoint. +// +// How: +// - Joins transactions on `id` so callers can address a UTXO by +// network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. +// - Restricts the result to unspent outputs whose parent transaction is still +// in a live state (`pending` or `published`). +// - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return +// rows whose credited address does not actually belong to the wallet. +// - Exists separately from GetUtxoByOutpoint because mutation helpers often +// need the stable internal UTXO row ID without reading the full public UTXO +// payload. +// +// Performance: +// - Uses the wallet-scoped transaction hash lookup first, then narrows to the +// unique `(tx_id, output_index)` outpoint. +func (q *Queries) GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpointParams) (int64, error) { + row := q.queryRow(ctx, q.getUtxoIDByOutpointStmt, GetUtxoIDByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var id int64 + err := row.Scan(&id) + return id, err +} + +const GetUtxoSpendByOutpoint = `-- name: GetUtxoSpendByOutpoint :one +SELECT u.spent_by_tx_id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND t.tx_status IN (0, 1) +` + +type GetUtxoSpendByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 +} + +// Returns the current spend edge for one wallet-owned outpoint. +// +// How: +// - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only +// considers outputs whose parent status is `pending` or `published`. +// - Returns the nullable `spent_by_tx_id` column so callers can distinguish +// between an external/dead parent and a wallet-owned conflict. +// +// Performance: +// - Targets one wallet-scoped outpoint through the unique `(tx_id, +// output_index)` key after the parent hash lookup. +func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (sql.NullInt64, error) { + row := q.queryRow(ctx, q.getUtxoSpendByOutpointStmt, GetUtxoSpendByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var spent_by_tx_id sql.NullInt64 + err := row.Scan(&spent_by_tx_id) + return spent_by_tx_id, err +} + +const InsertUtxo = `-- name: InsertUtxo :one +INSERT INTO utxos ( + tx_id, + output_index, + amount, + address_id +) SELECT + t.id AS tx_id, + $1 AS output_index, + $2 AS amount, + a.id AS address_id +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +CROSS JOIN transactions AS t +WHERE + t.id = $3 + AND t.wallet_id = $4 + AND t.tx_status IN (0, 1) + AND + a.id = $5 + AND ks.wallet_id = $4 +RETURNING id +` + +type InsertUtxoParams struct { + OutputIndex int32 + Amount int64 + TxID int64 + WalletID int64 + AddressID int64 +} + +// Inserts a new UTXO row and returns its database ID. +// +// How: +// - Writes only the utxos table using already-resolved transaction and address +// IDs. +// - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if +// the provided address ID belongs to the same wallet. +// +// Performance: +// - Single-row insert. The main cost is the wallet-ownership validation join +// plus FK and uniqueness checks. +func (q *Queries) InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, error) { + row := q.queryRow(ctx, q.insertUtxoStmt, InsertUtxo, + arg.OutputIndex, + arg.Amount, + arg.TxID, + arg.WalletID, + arg.AddressID, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const ListSpendingTxIDsByParentTxID = `-- name: ListSpendingTxIDsByParentTxID :many +SELECT DISTINCT u.spent_by_tx_id +FROM utxos AS u +WHERE + u.tx_id = $2 + AND u.spent_by_tx_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = $1 + ) +ORDER BY u.spent_by_tx_id +` + +type ListSpendingTxIDsByParentTxIDParams struct { + WalletID int64 + TxID int64 +} + +// Lists direct child transaction IDs for one parent transaction ID. +// +// How: +// - Reads the spend edges already materialized on utxos through +// `(tx_id, spent_by_tx_id)`. +// - Returns only direct children; callers that need full descendant walks should +// traverse this query iteratively in application code. +// +// Performance: +// - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded +// to one wallet-scoped parent. +func (q *Queries) ListSpendingTxIDsByParentTxID(ctx context.Context, arg ListSpendingTxIDsByParentTxIDParams) ([]sql.NullInt64, error) { + rows, err := q.query(ctx, q.listSpendingTxIDsByParentTxIDStmt, ListSpendingTxIDsByParentTxID, arg.WalletID, arg.TxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []sql.NullInt64 + for rows.Next() { + var spent_by_tx_id sql.NullInt64 + if err := rows.Scan(&spent_by_tx_id); err != nil { + return nil, err + } + items = append(items, spent_by_tx_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListUtxos = `-- name: ListUtxos :many +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = $1 + AND ks.wallet_id = $1 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + $2::BIGINT IS NULL + OR acc.account_number = $2::BIGINT + ) + AND ( + $3::INTEGER IS NULL + OR $3::INTEGER = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= $3::INTEGER + ) + AND ( + $4::INTEGER IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= $4::INTEGER + ) +ORDER BY u.amount, t.tx_hash, u.output_index +` + +type ListUtxosParams struct { + WalletID int64 + AccountNumber sql.NullInt64 + MinConfirms sql.NullInt32 + MaxConfirms sql.NullInt32 +} + +type ListUtxosRow struct { + TxHash []byte + OutputIndex int32 + Amount int64 + ScriptPubKey []byte + ReceivedTime time.Time + IsCoinbase bool + BlockHeight sql.NullInt32 +} + +// Lists unspent UTXOs that match the provided filters. +// +// How: +// - Starts from utxos and joins transactions for tx metadata plus +// wallet_sync_states for confirmation math. +// - Joins addresses to return the required script_pub_key, then reuses +// addresses -> accounts -> key_scopes so account filtering and wallet +// ownership checks happen in the same read. +// - Returns leased outputs too because the API models leases separately from +// UTXO existence. +// - Includes outputs whose parent transaction is still in a live unconfirmed +// state (`pending` or `published`). +// - Intentionally does not enforce coinbase maturity because this query models +// wallet-owned UTXO existence rather than a strictly spendable subset. +// +// Performance: +// - Restricts first by wallet, spend state, and transaction status. +// - Uses the address/account/scope joins to keep ownership validation and +// account filtering in one pass. +// - Treats min/max confirmations as optional filters so callers can +// distinguish "not set" from an explicit zero-conf request. +func (q *Queries) ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) { + rows, err := q.query(ctx, q.listUtxosStmt, ListUtxos, + arg.WalletID, + arg.AccountNumber, + arg.MinConfirms, + arg.MaxConfirms, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUtxosRow + for rows.Next() { + var i ListUtxosRow + if err := rows.Scan( + &i.TxHash, + &i.OutputIndex, + &i.Amount, + &i.ScriptPubKey, + &i.ReceivedTime, + &i.IsCoinbase, + &i.BlockHeight, + ); 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 MarkUtxoSpent = `-- name: MarkUtxoSpent :execrows +UPDATE utxos AS u +SET + spent_by_tx_id = $4, + spent_input_index = $5 +WHERE + u.tx_id = ( + SELECT t.id + FROM transactions AS t + WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND t.tx_status IN (0, 1) + ) + AND u.output_index = $3 + AND ( + (u.spent_by_tx_id IS NULL AND u.spent_input_index IS NULL) + OR (u.spent_by_tx_id = $4 AND u.spent_input_index = $5) + ) +` + +type MarkUtxoSpentParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 + SpentByTxID sql.NullInt64 + SpentInputIndex sql.NullInt32 +} + +// Marks a wallet-owned UTXO as spent by a transaction. +// +// How: +// - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside +// the statement so callers can update by network outpoint. +// - Requires the parent transaction status to be `pending` or `published` +// before a child spend edge can attach. +// - Only changes rows that are currently unspent or already point at the same +// `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent +// without allowing a caller to silently rewrite which input spent the UTXO. +// +// Performance: +// - Targets one outpoint in one wallet; the subquery uses the unique +// wallet-scoped tx hash lookup. +func (q *Queries) MarkUtxoSpent(ctx context.Context, arg MarkUtxoSpentParams) (int64, error) { + result, err := q.exec(ctx, q.markUtxoSpentStmt, MarkUtxoSpent, + arg.WalletID, + arg.TxHash, + arg.OutputIndex, + arg.SpentByTxID, + arg.SpentInputIndex, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index cddc263bf3..0aa4daccb2 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -231,7 +231,7 @@ INSERT INTO wallet_sync_states ( birthday_timestamp, updated_at ) VALUES ( - $1, $2, $3, $4, current_timestamp + $1, $2, $3, $4, current_timestamp AT TIME ZONE 'UTC' ) ` @@ -371,8 +371,8 @@ SET -- If birthday_timestamp param is NOT NULL, use it. Otherwise, keep existing value. birthday_timestamp = coalesce($4, birthday_timestamp), - -- Always update timestamp to current database time. - updated_at = current_timestamp + -- Always update timestamp to current database time in UTC. + updated_at = current_timestamp AT TIME ZONE 'UTC' WHERE wallet_id = $1 ` diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index b53304ffad..62018917a4 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -24,6 +24,18 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.acquireUtxoLeaseStmt, err = db.PrepareContext(ctx, AcquireUtxoLease); err != nil { + return nil, fmt.Errorf("error preparing query AcquireUtxoLease: %w", err) + } + if q.balanceStmt, err = db.PrepareContext(ctx, Balance); err != nil { + return nil, fmt.Errorf("error preparing query Balance: %w", err) + } + if q.clearUtxosSpentByTxIDStmt, err = db.PrepareContext(ctx, ClearUtxosSpentByTxID); err != nil { + return nil, fmt.Errorf("error preparing query ClearUtxosSpentByTxID: %w", err) + } + if q.confirmUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, ConfirmUnminedTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query ConfirmUnminedTransactionByHash: %w", err) + } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } @@ -51,12 +63,24 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteBlockStmt, err = db.PrepareContext(ctx, DeleteBlock); err != nil { return nil, fmt.Errorf("error preparing query DeleteBlock: %w", err) } + if q.deleteBlocksAtOrAboveHeightStmt, err = db.PrepareContext(ctx, DeleteBlocksAtOrAboveHeight); err != nil { + return nil, fmt.Errorf("error preparing query DeleteBlocksAtOrAboveHeight: %w", err) + } + if q.deleteExpiredUtxoLeasesStmt, err = db.PrepareContext(ctx, DeleteExpiredUtxoLeases); err != nil { + return nil, fmt.Errorf("error preparing query DeleteExpiredUtxoLeases: %w", err) + } if q.deleteKeyScopeStmt, err = db.PrepareContext(ctx, DeleteKeyScope); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScope: %w", err) } if q.deleteKeyScopeSecretsStmt, err = db.PrepareContext(ctx, DeleteKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query DeleteKeyScopeSecrets: %w", err) } + if q.deleteUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, DeleteUnminedTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query DeleteUnminedTransactionByHash: %w", err) + } + if q.deleteUtxosByTxIDStmt, err = db.PrepareContext(ctx, DeleteUtxosByTxID); err != nil { + return nil, fmt.Errorf("error preparing query DeleteUtxosByTxID: %w", err) + } if q.getAccountByScopeAndNameStmt, err = db.PrepareContext(ctx, GetAccountByScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query GetAccountByScopeAndName: %w", err) } @@ -72,6 +96,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %w", err) } + if q.getActiveUtxoLeaseLockIDStmt, err = db.PrepareContext(ctx, GetActiveUtxoLeaseLockID); err != nil { + return nil, fmt.Errorf("error preparing query GetActiveUtxoLeaseLockID: %w", err) + } if q.getAddressByScriptPubKeyStmt, err = db.PrepareContext(ctx, GetAddressByScriptPubKey); err != nil { return nil, fmt.Errorf("error preparing query GetAddressByScriptPubKey: %w", err) } @@ -99,6 +126,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getKeyScopeSecretsStmt, err = db.PrepareContext(ctx, GetKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetKeyScopeSecrets: %w", err) } + if q.getTransactionByHashStmt, err = db.PrepareContext(ctx, GetTransactionByHash); err != nil { + return nil, fmt.Errorf("error preparing query GetTransactionByHash: %w", err) + } + if q.getTransactionMetaByHashStmt, err = db.PrepareContext(ctx, GetTransactionMetaByHash); err != nil { + return nil, fmt.Errorf("error preparing query GetTransactionMetaByHash: %w", err) + } + if q.getUtxoByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoByOutpoint: %w", err) + } + if q.getUtxoIDByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoIDByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoIDByOutpoint: %w", err) + } + if q.getUtxoSpendByOutpointStmt, err = db.PrepareContext(ctx, GetUtxoSpendByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query GetUtxoSpendByOutpoint: %w", err) + } if q.getWalletByIDStmt, err = db.PrepareContext(ctx, GetWalletByID); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByID: %w", err) } @@ -117,6 +159,18 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.insertKeyScopeSecretsStmt, err = db.PrepareContext(ctx, InsertKeyScopeSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertKeyScopeSecrets: %w", err) } + if q.insertTransactionStmt, err = db.PrepareContext(ctx, InsertTransaction); err != nil { + return nil, fmt.Errorf("error preparing query InsertTransaction: %w", err) + } + if q.insertTxReplacementEdgeStmt, err = db.PrepareContext(ctx, InsertTxReplacementEdge); err != nil { + return nil, fmt.Errorf("error preparing query InsertTxReplacementEdge: %w", err) + } + if q.insertTxReplacementEdgeByHashStmt, err = db.PrepareContext(ctx, InsertTxReplacementEdgeByHash); err != nil { + return nil, fmt.Errorf("error preparing query InsertTxReplacementEdgeByHash: %w", err) + } + if q.insertUtxoStmt, err = db.PrepareContext(ctx, InsertUtxo); err != nil { + return nil, fmt.Errorf("error preparing query InsertUtxo: %w", err) + } if q.insertWalletSecretsStmt, err = db.PrepareContext(ctx, InsertWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query InsertWalletSecrets: %w", err) } @@ -135,6 +189,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listAccountsByWalletScopeStmt, err = db.PrepareContext(ctx, ListAccountsByWalletScope); err != nil { return nil, fmt.Errorf("error preparing query ListAccountsByWalletScope: %w", err) } + if q.listActiveUtxoLeasesStmt, err = db.PrepareContext(ctx, ListActiveUtxoLeases); err != nil { + return nil, fmt.Errorf("error preparing query ListActiveUtxoLeases: %w", err) + } if q.listAddressTypesStmt, err = db.PrepareContext(ctx, ListAddressTypes); err != nil { return nil, fmt.Errorf("error preparing query ListAddressTypes: %w", err) } @@ -144,15 +201,57 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listKeyScopesByWalletStmt, err = db.PrepareContext(ctx, ListKeyScopesByWallet); err != nil { return nil, fmt.Errorf("error preparing query ListKeyScopesByWallet: %w", err) } + if q.listReplacedTxHashesByReplacementTxHashStmt, err = db.PrepareContext(ctx, ListReplacedTxHashesByReplacementTxHash); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacedTxHashesByReplacementTxHash: %w", err) + } + if q.listReplacedTxIDsByReplacementTxIDStmt, err = db.PrepareContext(ctx, ListReplacedTxIDsByReplacementTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacedTxIDsByReplacementTxID: %w", err) + } + if q.listReplacementTxHashesByReplacedTxHashStmt, err = db.PrepareContext(ctx, ListReplacementTxHashesByReplacedTxHash); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacementTxHashesByReplacedTxHash: %w", err) + } + if q.listReplacementTxIDsByReplacedTxIDStmt, err = db.PrepareContext(ctx, ListReplacementTxIDsByReplacedTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListReplacementTxIDsByReplacedTxID: %w", err) + } + if q.listRollbackCoinbaseRootsStmt, err = db.PrepareContext(ctx, ListRollbackCoinbaseRoots); err != nil { + return nil, fmt.Errorf("error preparing query ListRollbackCoinbaseRoots: %w", err) + } + if q.listSpendingTxIDsByParentTxIDStmt, err = db.PrepareContext(ctx, ListSpendingTxIDsByParentTxID); err != nil { + return nil, fmt.Errorf("error preparing query ListSpendingTxIDsByParentTxID: %w", err) + } + if q.listTransactionsByHeightRangeStmt, err = db.PrepareContext(ctx, ListTransactionsByHeightRange); err != nil { + return nil, fmt.Errorf("error preparing query ListTransactionsByHeightRange: %w", err) + } + if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { + return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) + } + if q.listUtxosStmt, err = db.PrepareContext(ctx, ListUtxos); err != nil { + return nil, fmt.Errorf("error preparing query ListUtxos: %w", err) + } if q.listWalletsStmt, err = db.PrepareContext(ctx, ListWallets); err != nil { return nil, fmt.Errorf("error preparing query ListWallets: %w", err) } + if q.markUtxoSpentStmt, err = db.PrepareContext(ctx, MarkUtxoSpent); err != nil { + return nil, fmt.Errorf("error preparing query MarkUtxoSpent: %w", err) + } + if q.releaseUtxoLeaseStmt, err = db.PrepareContext(ctx, ReleaseUtxoLease); err != nil { + return nil, fmt.Errorf("error preparing query ReleaseUtxoLease: %w", err) + } + if q.rewindWalletSyncStateHeightsForRollbackStmt, err = db.PrepareContext(ctx, RewindWalletSyncStateHeightsForRollback); err != nil { + return nil, fmt.Errorf("error preparing query RewindWalletSyncStateHeightsForRollback: %w", err) + } if q.updateAccountNameByWalletScopeAndNameStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndName); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndName: %w", err) } if q.updateAccountNameByWalletScopeAndNumberStmt, err = db.PrepareContext(ctx, UpdateAccountNameByWalletScopeAndNumber); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountNameByWalletScopeAndNumber: %w", err) } + if q.updateTransactionLabelByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionLabelByHash); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionLabelByHash: %w", err) + } + if q.updateTransactionStatusByIDsStmt, err = db.PrepareContext(ctx, UpdateTransactionStatusByIDs); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionStatusByIDs: %w", err) + } if q.updateWalletSecretsStmt, err = db.PrepareContext(ctx, UpdateWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query UpdateWalletSecrets: %w", err) } @@ -164,6 +263,26 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.acquireUtxoLeaseStmt != nil { + if cerr := q.acquireUtxoLeaseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing acquireUtxoLeaseStmt: %w", cerr) + } + } + if q.balanceStmt != nil { + if cerr := q.balanceStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing balanceStmt: %w", cerr) + } + } + if q.clearUtxosSpentByTxIDStmt != nil { + if cerr := q.clearUtxosSpentByTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing clearUtxosSpentByTxIDStmt: %w", cerr) + } + } + if q.confirmUnminedTransactionByHashStmt != nil { + if cerr := q.confirmUnminedTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing confirmUnminedTransactionByHashStmt: %w", cerr) + } + } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -209,6 +328,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteBlockStmt: %w", cerr) } } + if q.deleteBlocksAtOrAboveHeightStmt != nil { + if cerr := q.deleteBlocksAtOrAboveHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteBlocksAtOrAboveHeightStmt: %w", cerr) + } + } + if q.deleteExpiredUtxoLeasesStmt != nil { + if cerr := q.deleteExpiredUtxoLeasesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteExpiredUtxoLeasesStmt: %w", cerr) + } + } if q.deleteKeyScopeStmt != nil { if cerr := q.deleteKeyScopeStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteKeyScopeStmt: %w", cerr) @@ -219,6 +348,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteKeyScopeSecretsStmt: %w", cerr) } } + if q.deleteUnminedTransactionByHashStmt != nil { + if cerr := q.deleteUnminedTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteUnminedTransactionByHashStmt: %w", cerr) + } + } + if q.deleteUtxosByTxIDStmt != nil { + if cerr := q.deleteUtxosByTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteUtxosByTxIDStmt: %w", cerr) + } + } if q.getAccountByScopeAndNameStmt != nil { if cerr := q.getAccountByScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAccountByScopeAndNameStmt: %w", cerr) @@ -244,6 +383,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %w", cerr) } } + if q.getActiveUtxoLeaseLockIDStmt != nil { + if cerr := q.getActiveUtxoLeaseLockIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getActiveUtxoLeaseLockIDStmt: %w", cerr) + } + } if q.getAddressByScriptPubKeyStmt != nil { if cerr := q.getAddressByScriptPubKeyStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAddressByScriptPubKeyStmt: %w", cerr) @@ -289,6 +433,31 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getKeyScopeSecretsStmt: %w", cerr) } } + if q.getTransactionByHashStmt != nil { + if cerr := q.getTransactionByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTransactionByHashStmt: %w", cerr) + } + } + if q.getTransactionMetaByHashStmt != nil { + if cerr := q.getTransactionMetaByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTransactionMetaByHashStmt: %w", cerr) + } + } + if q.getUtxoByOutpointStmt != nil { + if cerr := q.getUtxoByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoByOutpointStmt: %w", cerr) + } + } + if q.getUtxoIDByOutpointStmt != nil { + if cerr := q.getUtxoIDByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoIDByOutpointStmt: %w", cerr) + } + } + if q.getUtxoSpendByOutpointStmt != nil { + if cerr := q.getUtxoSpendByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUtxoSpendByOutpointStmt: %w", cerr) + } + } if q.getWalletByIDStmt != nil { if cerr := q.getWalletByIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletByIDStmt: %w", cerr) @@ -319,6 +488,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing insertKeyScopeSecretsStmt: %w", cerr) } } + if q.insertTransactionStmt != nil { + if cerr := q.insertTransactionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTransactionStmt: %w", cerr) + } + } + if q.insertTxReplacementEdgeStmt != nil { + if cerr := q.insertTxReplacementEdgeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTxReplacementEdgeStmt: %w", cerr) + } + } + if q.insertTxReplacementEdgeByHashStmt != nil { + if cerr := q.insertTxReplacementEdgeByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertTxReplacementEdgeByHashStmt: %w", cerr) + } + } + if q.insertUtxoStmt != nil { + if cerr := q.insertUtxoStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing insertUtxoStmt: %w", cerr) + } + } if q.insertWalletSecretsStmt != nil { if cerr := q.insertWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertWalletSecretsStmt: %w", cerr) @@ -349,6 +538,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listAccountsByWalletScopeStmt: %w", cerr) } } + if q.listActiveUtxoLeasesStmt != nil { + if cerr := q.listActiveUtxoLeasesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listActiveUtxoLeasesStmt: %w", cerr) + } + } if q.listAddressTypesStmt != nil { if cerr := q.listAddressTypesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listAddressTypesStmt: %w", cerr) @@ -364,11 +558,71 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listKeyScopesByWalletStmt: %w", cerr) } } + if q.listReplacedTxHashesByReplacementTxHashStmt != nil { + if cerr := q.listReplacedTxHashesByReplacementTxHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacedTxHashesByReplacementTxHashStmt: %w", cerr) + } + } + if q.listReplacedTxIDsByReplacementTxIDStmt != nil { + if cerr := q.listReplacedTxIDsByReplacementTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacedTxIDsByReplacementTxIDStmt: %w", cerr) + } + } + if q.listReplacementTxHashesByReplacedTxHashStmt != nil { + if cerr := q.listReplacementTxHashesByReplacedTxHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacementTxHashesByReplacedTxHashStmt: %w", cerr) + } + } + if q.listReplacementTxIDsByReplacedTxIDStmt != nil { + if cerr := q.listReplacementTxIDsByReplacedTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listReplacementTxIDsByReplacedTxIDStmt: %w", cerr) + } + } + if q.listRollbackCoinbaseRootsStmt != nil { + if cerr := q.listRollbackCoinbaseRootsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listRollbackCoinbaseRootsStmt: %w", cerr) + } + } + if q.listSpendingTxIDsByParentTxIDStmt != nil { + if cerr := q.listSpendingTxIDsByParentTxIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listSpendingTxIDsByParentTxIDStmt: %w", cerr) + } + } + if q.listTransactionsByHeightRangeStmt != nil { + if cerr := q.listTransactionsByHeightRangeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTransactionsByHeightRangeStmt: %w", cerr) + } + } + if q.listUnminedTransactionsStmt != nil { + if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) + } + } + if q.listUtxosStmt != nil { + if cerr := q.listUtxosStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUtxosStmt: %w", cerr) + } + } if q.listWalletsStmt != nil { if cerr := q.listWalletsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listWalletsStmt: %w", cerr) } } + if q.markUtxoSpentStmt != nil { + if cerr := q.markUtxoSpentStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing markUtxoSpentStmt: %w", cerr) + } + } + if q.releaseUtxoLeaseStmt != nil { + if cerr := q.releaseUtxoLeaseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing releaseUtxoLeaseStmt: %w", cerr) + } + } + if q.rewindWalletSyncStateHeightsForRollbackStmt != nil { + if cerr := q.rewindWalletSyncStateHeightsForRollbackStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing rewindWalletSyncStateHeightsForRollbackStmt: %w", cerr) + } + } if q.updateAccountNameByWalletScopeAndNameStmt != nil { if cerr := q.updateAccountNameByWalletScopeAndNameStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNameStmt: %w", cerr) @@ -379,6 +633,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateAccountNameByWalletScopeAndNumberStmt: %w", cerr) } } + if q.updateTransactionLabelByHashStmt != nil { + if cerr := q.updateTransactionLabelByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionLabelByHashStmt: %w", cerr) + } + } + if q.updateTransactionStatusByIDsStmt != nil { + if cerr := q.updateTransactionStatusByIDsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionStatusByIDsStmt: %w", cerr) + } + } if q.updateWalletSecretsStmt != nil { if cerr := q.updateWalletSecretsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateWalletSecretsStmt: %w", cerr) @@ -428,6 +692,10 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar type Queries struct { db DBTX tx *sql.Tx + acquireUtxoLeaseStmt *sql.Stmt + balanceStmt *sql.Stmt + clearUtxosSpentByTxIDStmt *sql.Stmt + confirmUnminedTransactionByHashStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt @@ -437,13 +705,18 @@ type Queries struct { createKeyScopeStmt *sql.Stmt createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt + deleteBlocksAtOrAboveHeightStmt *sql.Stmt + deleteExpiredUtxoLeasesStmt *sql.Stmt deleteKeyScopeStmt *sql.Stmt deleteKeyScopeSecretsStmt *sql.Stmt + deleteUnminedTransactionByHashStmt *sql.Stmt + deleteUtxosByTxIDStmt *sql.Stmt getAccountByScopeAndNameStmt *sql.Stmt getAccountByScopeAndNumberStmt *sql.Stmt getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt getAddressTypeByIDStmt *sql.Stmt @@ -453,24 +726,48 @@ type Queries struct { getKeyScopeByIDStmt *sql.Stmt getKeyScopeByWalletAndScopeStmt *sql.Stmt getKeyScopeSecretsStmt *sql.Stmt + getTransactionByHashStmt *sql.Stmt + getTransactionMetaByHashStmt *sql.Stmt + getUtxoByOutpointStmt *sql.Stmt + getUtxoIDByOutpointStmt *sql.Stmt + getUtxoSpendByOutpointStmt *sql.Stmt getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt + insertTransactionStmt *sql.Stmt + insertTxReplacementEdgeStmt *sql.Stmt + insertTxReplacementEdgeByHashStmt *sql.Stmt + insertUtxoStmt *sql.Stmt insertWalletSecretsStmt *sql.Stmt insertWalletSyncStateStmt *sql.Stmt listAccountsByScopeStmt *sql.Stmt listAccountsByWalletStmt *sql.Stmt listAccountsByWalletAndNameStmt *sql.Stmt listAccountsByWalletScopeStmt *sql.Stmt + listActiveUtxoLeasesStmt *sql.Stmt listAddressTypesStmt *sql.Stmt listAddressesByAccountStmt *sql.Stmt listKeyScopesByWalletStmt *sql.Stmt + listReplacedTxHashesByReplacementTxHashStmt *sql.Stmt + listReplacedTxIDsByReplacementTxIDStmt *sql.Stmt + listReplacementTxHashesByReplacedTxHashStmt *sql.Stmt + listReplacementTxIDsByReplacedTxIDStmt *sql.Stmt + listRollbackCoinbaseRootsStmt *sql.Stmt + listSpendingTxIDsByParentTxIDStmt *sql.Stmt + listTransactionsByHeightRangeStmt *sql.Stmt + listUnminedTransactionsStmt *sql.Stmt + listUtxosStmt *sql.Stmt listWalletsStmt *sql.Stmt + markUtxoSpentStmt *sql.Stmt + releaseUtxoLeaseStmt *sql.Stmt + rewindWalletSyncStateHeightsForRollbackStmt *sql.Stmt updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt + updateTransactionLabelByHashStmt *sql.Stmt + updateTransactionStatusByIDsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt } @@ -479,6 +776,10 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ db: tx, tx: tx, + acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, + balanceStmt: q.balanceStmt, + clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, + confirmUnminedTransactionByHashStmt: q.confirmUnminedTransactionByHashStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, @@ -488,13 +789,18 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createKeyScopeStmt: q.createKeyScopeStmt, createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, + deleteBlocksAtOrAboveHeightStmt: q.deleteBlocksAtOrAboveHeightStmt, + deleteExpiredUtxoLeasesStmt: q.deleteExpiredUtxoLeasesStmt, deleteKeyScopeStmt: q.deleteKeyScopeStmt, deleteKeyScopeSecretsStmt: q.deleteKeyScopeSecretsStmt, + deleteUnminedTransactionByHashStmt: q.deleteUnminedTransactionByHashStmt, + deleteUtxosByTxIDStmt: q.deleteUtxosByTxIDStmt, getAccountByScopeAndNameStmt: q.getAccountByScopeAndNameStmt, getAccountByScopeAndNumberStmt: q.getAccountByScopeAndNumberStmt, getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + getActiveUtxoLeaseLockIDStmt: q.getActiveUtxoLeaseLockIDStmt, getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, getAddressTypeByIDStmt: q.getAddressTypeByIDStmt, @@ -504,24 +810,48 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getKeyScopeByIDStmt: q.getKeyScopeByIDStmt, getKeyScopeByWalletAndScopeStmt: q.getKeyScopeByWalletAndScopeStmt, getKeyScopeSecretsStmt: q.getKeyScopeSecretsStmt, + getTransactionByHashStmt: q.getTransactionByHashStmt, + getTransactionMetaByHashStmt: q.getTransactionMetaByHashStmt, + getUtxoByOutpointStmt: q.getUtxoByOutpointStmt, + getUtxoIDByOutpointStmt: q.getUtxoIDByOutpointStmt, + getUtxoSpendByOutpointStmt: q.getUtxoSpendByOutpointStmt, getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, + insertTransactionStmt: q.insertTransactionStmt, + insertTxReplacementEdgeStmt: q.insertTxReplacementEdgeStmt, + insertTxReplacementEdgeByHashStmt: q.insertTxReplacementEdgeByHashStmt, + insertUtxoStmt: q.insertUtxoStmt, insertWalletSecretsStmt: q.insertWalletSecretsStmt, insertWalletSyncStateStmt: q.insertWalletSyncStateStmt, listAccountsByScopeStmt: q.listAccountsByScopeStmt, listAccountsByWalletStmt: q.listAccountsByWalletStmt, listAccountsByWalletAndNameStmt: q.listAccountsByWalletAndNameStmt, listAccountsByWalletScopeStmt: q.listAccountsByWalletScopeStmt, + listActiveUtxoLeasesStmt: q.listActiveUtxoLeasesStmt, listAddressTypesStmt: q.listAddressTypesStmt, listAddressesByAccountStmt: q.listAddressesByAccountStmt, listKeyScopesByWalletStmt: q.listKeyScopesByWalletStmt, + listReplacedTxHashesByReplacementTxHashStmt: q.listReplacedTxHashesByReplacementTxHashStmt, + listReplacedTxIDsByReplacementTxIDStmt: q.listReplacedTxIDsByReplacementTxIDStmt, + listReplacementTxHashesByReplacedTxHashStmt: q.listReplacementTxHashesByReplacedTxHashStmt, + listReplacementTxIDsByReplacedTxIDStmt: q.listReplacementTxIDsByReplacedTxIDStmt, + listRollbackCoinbaseRootsStmt: q.listRollbackCoinbaseRootsStmt, + listSpendingTxIDsByParentTxIDStmt: q.listSpendingTxIDsByParentTxIDStmt, + listTransactionsByHeightRangeStmt: q.listTransactionsByHeightRangeStmt, + listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, + listUtxosStmt: q.listUtxosStmt, listWalletsStmt: q.listWalletsStmt, + markUtxoSpentStmt: q.markUtxoSpentStmt, + releaseUtxoLeaseStmt: q.releaseUtxoLeaseStmt, + rewindWalletSyncStateHeightsForRollbackStmt: q.rewindWalletSyncStateHeightsForRollbackStmt, updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, + updateTransactionLabelByHashStmt: q.updateTransactionLabelByHashStmt, + updateTransactionStatusByIDsStmt: q.updateTransactionStatusByIDsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, } diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/db/sqlc/sqlite/models.go index 6352775b72..a08fb8d7a7 100644 --- a/wallet/internal/db/sqlc/sqlite/models.go +++ b/wallet/internal/db/sqlc/sqlite/models.go @@ -77,6 +77,43 @@ type KeyScopeSecret struct { EncryptedCoinPrivKey []byte } +type Transaction struct { + WalletID int64 + ID int64 + TxHash []byte + RawTx []byte + BlockHeight sql.NullInt64 + TxStatus int64 + ReceivedTime time.Time + IsCoinbase bool + TxLabel string +} + +type TxReplacement struct { + WalletID int64 + ID int64 + ReplacedTxID int64 + ReplacementTxID int64 + CreatedAt time.Time +} + +type Utxo struct { + ID int64 + TxID int64 + OutputIndex int64 + Amount int64 + AddressID int64 + SpentByTxID sql.NullInt64 + SpentInputIndex sql.NullInt64 +} + +type UtxoLease struct { + WalletID int64 + UtxoID int64 + LockID []byte + ExpiresAt time.Time +} + type Wallet struct { ID int64 WalletName string diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index e55e93f16a..5c644cbb50 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -6,9 +6,73 @@ package sqlcsqlite import ( "context" + "database/sql" + "time" ) type Querier interface { + // Acquires or renews a lease for an outpoint and returns the resulting + // expiration time. + // + // How: + // - Resolves the outpoint to a current UTXO row and writes the lease in the + // same statement. + // - Rechecks that the outpoint is still unspent and its parent transaction is + // still in a live state (`pending` or `published`) at write time. + // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, + // and expired-lease takeover all happen atomically. + // Lease semantics: + // - If the UTXO has no lease row, insert a new lease. + // - If the UTXO has an expired lease, steal it. + // - If the UTXO has an active lease with the same lock_id, renew it. + // - If the UTXO has an active lease with a different lock_id, return no + // rows (caller should treat this as "already leased"). + // + // NOTE: expires_at is computed by the caller as time.Now().UTC()+duration, and + // callers must also pass a UTC-normalized now_utc for lease-expiry checks. + // Performance: + // - SQLite executes the resolution and lease write atomically inside one + // statement, which avoids stale-ID races between separate helper calls. + AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) + // Returns the total and locked value represented by the wallet's current + // unspent UTXO set. + // + // How: + // - Starts from wallet-scoped unspent outputs and rejoins transactions plus + // wallet_sync_states for confirmation math. + // - Rejoins addresses -> accounts -> key_scopes so ownership validation and + // optional account filtering stay in one read. + // - Applies optional confirmation-range and coinbase-maturity policy directly + // inside the aggregate query so callers can request factual or policy-shaped + // balance reads through one public method. + // - Returns both the total matching value and the locked subset covered by + // active leases after the same filters are applied. + // Performance: + // - Executes as one aggregate over wallet-scoped live outputs. + // - Uses a filtered aggregate over active leases rather than issuing a second + // query for the locked subset. + // - Uses the address/account/scope joins to keep ownership validation and + // account filtering in one pass. + Balance(ctx context.Context, arg BalanceParams) (BalanceRow, error) + // Clears spent_by pointers for all UTXOs spent by the provided transaction ID. + // + // How: + // - Resets both spent columns together so the logical spend pointer remains + // internally consistent. + // Performance: + // - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet + // ownership through the creating transaction. + ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) + // Attaches a confirming block to one existing live unmined transaction row. + // + // How: + // - Updates only rows that are still blockless and live (`pending` or + // `published`). + // - Leaves user-visible metadata such as labels untouched so confirmation can + // reuse the original transaction row instead of reinserting it. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error // Creates a new derived account under the given scope, computing the next @@ -32,10 +96,48 @@ type Querier interface { CreateKeyScope(ctx context.Context, arg CreateKeyScopeParams) (int64, error) CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int64) error + // Deletes blocks at and after the provided height. + // + // How: + // - Deletes directly from blocks by the natural height key. + // - Relies on FK/trigger side effects to null transaction block references and + // orphan coinbase rows. + // Performance: + // - Executes as a range delete over the block-height primary key. + DeleteBlocksAtOrAboveHeight(ctx context.Context, blockHeight int64) (int64, error) + // Deletes all expired lease rows for a wallet. + // + // How: + // - Removes only rows whose expiration has passed, leaving active leases + // untouched. + // Performance: + // - Uses the expiration predicate together with wallet scoping to bound the + // cleanup pass. + DeleteExpiredUtxoLeases(ctx context.Context, arg DeleteExpiredUtxoLeasesParams) (int64, error) // Deletes a key scope by its ID. DeleteKeyScope(ctx context.Context, id int64) (int64, error) // Deletes the secrets for a key scope. DeleteKeyScopeSecrets(ctx context.Context, scopeID int64) (int64, error) + // Deletes an unconfirmed transaction row. + // + // How: + // - Deletes only rows whose `block_height` is still NULL and whose status is + // still in a live unconfirmed state (`pending` or `published`). + // - Preserves orphaned/replaced/failed history; those rows must remain visible + // for audit/reorg handling instead of being treated as ordinary mempool data. + // - The caller must delete or restore dependent UTXO rows first. + // Performance: + // - Targets at most one row by `(wallet_id, tx_hash)`. + DeleteUnminedTransactionByHash(ctx context.Context, arg DeleteUnminedTransactionByHashParams) (int64, error) + // Deletes all UTXO rows created by the provided transaction ID. + // + // How: + // - Removes outputs by the parent transaction's internal ID after callers have + // already decided the transaction row itself may be deleted. + // Performance: + // - Uses the `(tx_id)` index to keep the delete bounded to one transaction's + // outputs, then rechecks wallet ownership through the parent transaction. + DeleteUtxosByTxID(ctx context.Context, arg DeleteUtxosByTxIDParams) (int64, error) // Returns a single account by scope id and account name. GetAccountByScopeAndName(ctx context.Context, arg GetAccountByScopeAndNameParams) (GetAccountByScopeAndNameRow, error) // Returns a single account by scope id and account number. @@ -46,6 +148,14 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) + // Returns the lock ID for the current active lease on a UTXO ID. + // + // How: + // - Reads only non-expired lease rows so callers can distinguish an active + // lock-ID mismatch from an already-unlocked output. + // Performance: + // - Targets at most one row through the unique lease key. + GetActiveUtxoLeaseLockID(ctx context.Context, arg GetActiveUtxoLeaseLockIDParams) ([]byte, error) // Retrieves an address by its script pubkey and account wallet. GetAddressByScriptPubKey(ctx context.Context, arg GetAddressByScriptPubKeyParams) (GetAddressByScriptPubKeyRow, error) // Retrieves secret information for an address. Uses LEFT JOIN to distinguish: @@ -68,6 +178,66 @@ type Querier interface { GetKeyScopeByWalletAndScope(ctx context.Context, arg GetKeyScopeByWalletAndScopeParams) (KeyScope, error) // Retrieves the secrets for a key scope. GetKeyScopeSecrets(ctx context.Context, scopeID int64) (KeyScopeSecret, error) + // Retrieves the full transaction row along with optional block metadata. + // + // How: + // - Looks up the transaction by `(wallet_id, tx_hash)`. + // - LEFT JOINs blocks on `block_height` so the same query handles mined and + // unmined rows. + // Performance: + // - The unique transaction lookup limits the join fanout to at most one block + // row. + GetTransactionByHash(ctx context.Context, arg GetTransactionByHashParams) (GetTransactionByHashRow, error) + // Retrieves the primary key and lightweight transaction metadata. + // + // How: + // - Reads only the transactions table because callers only need row identity + // plus lightweight status/label fields. + // Performance: + // - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. + GetTransactionMetaByHash(ctx context.Context, arg GetTransactionMetaByHashParams) (GetTransactionMetaByHashRow, error) + // Retrieves a single unspent UTXO by its outpoint. + // + // How: + // - Joins utxos -> transactions on `tx_id` to resolve the outpoint + // from tx hash plus output index. + // - Joins addresses -> accounts -> key_scopes so the read path reasserts that + // the credited address belongs to the requested wallet. + // - Returns leased and unleased outputs alike because leasing affects coin + // selection, not whether the UTXO exists. + // - Treats outputs from both live unconfirmed parent states (`pending` and + // `published`) as part of the wallet's current UTXO set. + // Performance: + // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the + // join fanout to at most one candidate output. + GetUtxoByOutpoint(ctx context.Context, arg GetUtxoByOutpointParams) (GetUtxoByOutpointRow, error) + // Retrieves the database ID for a current UTXO by its outpoint. + // + // How: + // - Joins transactions on `id` so callers can address a UTXO by + // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. + // - Restricts the result to unspent outputs whose parent transaction is still + // in a live state (`pending` or `published`). + // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return + // rows whose credited address does not actually belong to the wallet. + // - Exists separately from GetUtxoByOutpoint because mutation helpers often + // need the stable internal UTXO row ID without reading the full public UTXO + // payload. + // Performance: + // - Uses the wallet-scoped transaction hash lookup first, then narrows to the + // unique `(tx_id, output_index)` outpoint. + GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpointParams) (int64, error) + // Returns the current spend edge for one wallet-owned outpoint. + // + // How: + // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only + // considers outputs whose parent status is `pending` or `published`. + // - Returns the nullable `spent_by_tx_id` column so callers can distinguish + // between an external/dead parent and a wallet-owned conflict. + // Performance: + // - Targets one wallet-scoped outpoint through the unique `(tx_id, + // output_index)` key after the parent hash lookup. + GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (sql.NullInt64, error) GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) @@ -78,6 +248,51 @@ type Querier interface { // Inserts secrets for a key scope. encrypted_coin_priv_key may be NULL for // watch-only scopes. InsertKeyScopeSecrets(ctx context.Context, arg InsertKeyScopeSecretsParams) error + // Inserts a wallet-scoped transaction row and returns its database ID. + // + // How: + // - Writes only the transactions table. + // - Expects the caller to have already resolved wallet scope and any optional + // block reference. + // - Expects the caller to supply the initial status explicitly so unmined rows + // do not have to guess between `pending` and `published`. + // Performance: + // - Single-row insert. The cost is dominated by the wallet/hash uniqueness + // checks and any optional block foreign-key validation. + InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) + // Records a replacement edge between two wallet-scoped transactions. + // + // How: + // - Writes directly to tx_replacements using already-resolved transaction IDs. + // - Uses an explicit conflict target so only duplicate edges are ignored; + // missing transaction IDs, self-replacements, and other constraint failures + // still surface to the caller. + // Performance: + // - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. + InsertTxReplacementEdge(ctx context.Context, arg InsertTxReplacementEdgeParams) (int64, error) + // Records a replacement edge by resolving tx IDs from transaction hashes. + // + // How: + // - Resolves both endpoint transaction IDs from the transactions table using + // the wallet-scoped tx-hash unique lookup. + // - Writes the resulting directed edge to tx_replacements. + // - Uses an explicit conflict target so duplicate-edge retries are ignored + // without masking missing-hash or check-constraint failures. + // Performance: + // - Trades two indexed scalar subqueries for one network round trip, which is + // preferable when callers start from tx hashes. + InsertTxReplacementEdgeByHash(ctx context.Context, arg InsertTxReplacementEdgeByHashParams) (int64, error) + // Inserts a new UTXO row and returns its database ID. + // + // How: + // - Writes only the utxos table using already-resolved transaction and address + // IDs. + // - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if + // the provided address ID belongs to the same wallet. + // Performance: + // - Single-row insert. The main cost is the wallet-ownership validation join + // plus FK and uniqueness checks. + InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, error) InsertWalletSecrets(ctx context.Context, arg InsertWalletSecretsParams) error InsertWalletSyncState(ctx context.Context, arg InsertWalletSyncStateParams) error // Lists all accounts in a scope, ordered by account number. Imported accounts @@ -92,6 +307,18 @@ type Querier interface { // Lists all accounts for a wallet and scope tuple, ordered by account number. // Imported accounts (with NULL account_number) appear last. ListAccountsByWalletScope(ctx context.Context, arg ListAccountsByWalletScopeParams) ([]ListAccountsByWalletScopeRow, error) + // Lists all currently active leases for a wallet. + // + // How: + // - Starts from utxo_leases, then joins utxos and transactions so the result + // can be returned as network outpoints. + // - Filters out expired rows using the caller-supplied UTC timestamp. + // - Restricts the result to outputs that are still unspent and whose parent + // transaction is still in a live state (`pending` or `published`). + // Performance: + // - Restricts first by wallet and expiration, then joins only the surviving + // lease rows back to utxos/transactions. + ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) // Lists all addresses for a given account identified by wallet_id, key scope @@ -100,11 +327,175 @@ type Querier interface { ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) + // Lists victim txids for a given replacement txid. + // + // How: + // - Starts from tx_replacements, then joins transactions twice on `(wallet_id, + // id)` to map both edge endpoints back to network tx hashes. + // - Filters by the replacement hash on the `replacement` alias. + // Performance: + // - Mirrors the victim lookup path while keeping the graph traversal bounded by + // wallet scope and indexed edge keys. + ListReplacedTxHashesByReplacementTxHash(ctx context.Context, arg ListReplacedTxHashesByReplacementTxHashParams) ([]ListReplacedTxHashesByReplacementTxHashRow, error) + // Lists victim transaction IDs for a given replacement transaction ID. + // + // How: + // - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because + // the caller already has the replacement row ID. + // - Orders first by created_at and then by id so traversal stays deterministic + // even when several edges share the same timestamp. + // Performance: + // - Uses the inverse replacement lookup index without joining transactions. + ListReplacedTxIDsByReplacementTxID(ctx context.Context, arg ListReplacedTxIDsByReplacementTxIDParams) ([]ListReplacedTxIDsByReplacementTxIDRow, error) + // Lists replacement txids for a given victim txid. + // + // How: + // - Starts from tx_replacements, then joins transactions twice on `(wallet_id, + // id)` to map both edge endpoints back to network tx hashes. + // - Filters by the victim hash on the `replaced` alias. + // Performance: + // - The victim hash lookup narrows the graph walk before the second transaction + // join materializes replacement hashes. + ListReplacementTxHashesByReplacedTxHash(ctx context.Context, arg ListReplacementTxHashesByReplacedTxHashParams) ([]ListReplacementTxHashesByReplacedTxHashRow, error) + // Lists replacement transaction IDs for a given victim transaction ID. + // + // How: + // - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the + // caller already has the victim's internal row ID. + // - Orders first by created_at and then by id so traversal stays deterministic + // even when several edges share the same timestamp. + // Performance: + // - Uses the replacement-edge index without joining transactions. + ListReplacementTxIDsByReplacedTxID(ctx context.Context, arg ListReplacementTxIDsByReplacedTxIDParams) ([]ListReplacementTxIDsByReplacedTxIDRow, error) + // Lists wallet-scoped coinbase transaction hashes at or above the rollback + // boundary that seed descendant invalidation. + // + // How: + // - Reads only confirmed coinbase rows at or above the rollback boundary. + // - Returns wallet scope alongside each tx hash so callers can treat these + // coinbase transactions as rollback roots when invalidating now-dead + // descendants inside the same rollback transaction. + // - This is a rollback-specific helper, not a generic "coinbase txs from one + // block" listing query. + // Performance: + // - Uses the block-height index to bound the scan to the rollback range. + ListRollbackCoinbaseRoots(ctx context.Context, rollbackHeight int64) ([]ListRollbackCoinbaseRootsRow, error) + // Lists direct child transaction IDs for one parent transaction ID. + // + // How: + // - Reads the spend edges already materialized on utxos through + // `(tx_id, spent_by_tx_id)`. + // - Returns only direct children; callers that need full descendant walks should + // traverse this query iteratively in application code. + // Performance: + // - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded + // to one wallet-scoped parent. + ListSpendingTxIDsByParentTxID(ctx context.Context, arg ListSpendingTxIDsByParentTxIDParams) ([]sql.NullInt64, error) + // Lists all confirmed transactions for a wallet in the provided height range. + // + // How: + // - Reads transactions in a wallet-scoped block-height range. + // - INNER JOINs blocks on the natural `block_height` key to hydrate block hash + // and timestamp for confirmed rows. + // Performance: + // - The `(wallet_id, block_height)` index bounds the scan before the single-row + // block join. + ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) + // Lists all unconfirmed transactions for a wallet. + // + // How: + // - Reads from transactions only and filters on blockless rows that are still + // in a live unconfirmed state (`pending` or `published`). + // - Excludes orphaned/replaced/failed history so rollback-produced coinbase + // rows do not reappear as mempool transactions. + // - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` + // so the unmined row shape stays aligned with the confirmed query below. + // Performance: + // - Matches the dedicated blockless-history index while the more selective + // live-only partial index stays available for conflict paths. + ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) + // Lists unspent UTXOs that match the provided filters. + // + // How: + // - Starts from utxos and joins transactions for tx metadata plus + // wallet_sync_states for confirmation math. + // - Joins addresses to return the required script_pub_key, then reuses + // addresses -> accounts -> key_scopes so account filtering and wallet + // ownership checks happen in the same read. + // - Returns leased outputs too because the API models leases separately from + // UTXO existence. + // - Includes outputs whose parent transaction is still in a live unconfirmed + // state (`pending` or `published`). + // - Intentionally does not enforce coinbase maturity because this query models + // wallet-owned UTXO existence rather than a strictly spendable subset. + // Performance: + // - Restricts first by wallet, spend state, and transaction status. + // - Uses the address/account/scope joins to keep ownership validation and + // account filtering in one pass. + // - Treats min/max confirmations as optional filters so callers can + // distinguish "not set" from an explicit zero-conf request. + ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Marks a wallet-owned UTXO as spent by a transaction. + // + // How: + // - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside + // the statement so callers can update by network outpoint. + // - Requires the parent transaction status to be `pending` or `published` + // before a child spend edge can attach. + // - Only changes rows that are currently unspent or already point at the same + // `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent + // without allowing a caller to silently rewrite which input spent the UTXO. + // Performance: + // - Targets one outpoint in one wallet; the subquery uses the unique + // wallet-scoped tx hash lookup. + MarkUtxoSpent(ctx context.Context, arg MarkUtxoSpentParams) (int64, error) + // Releases a lease for a UTXO ID if the lock_id matches. + // + // How: + // - Deletes by wallet, utxo ID, and lock ID so one caller cannot release + // another caller's active lease accidentally. + // Performance: + // - Targets at most one row through the unique lease key. + ReleaseUtxoLease(ctx context.Context, arg ReleaseUtxoLeaseParams) (int64, error) + // Rewrites wallet sync-state heights so they stop referencing blocks that are + // about to be deleted during RollbackToBlock. + // + // How: + // - Updates wallet_sync_states directly without joining other tables. + // - Rewrites both synced_height and birthday_height in one statement so the + // subsequent block delete does not violate `ON DELETE RESTRICT`. + // - Example: if `rollback_height = 195`, then any `synced_height` or + // `birthday_height` at 195 or above rewinds to `new_height = 194`. + // - If rollback starts from height 0, callers pass `new_height = NULL` so the + // sync state no longer points at any surviving block row. + // Performance: + // - Touches only wallet_sync_states rows whose heights are at or above the + // rollback boundary. + RewindWalletSyncStateHeightsForRollback(ctx context.Context, arg RewindWalletSyncStateHeightsForRollbackParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and current account name. UpdateAccountNameByWalletScopeAndName(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNameParams) (int64, error) // Renames an account identified by wallet id, scope tuple, and account number. UpdateAccountNameByWalletScopeAndNumber(ctx context.Context, arg UpdateAccountNameByWalletScopeAndNumberParams) (int64, error) + // Updates only the user-visible transaction label. + // + // How: + // - Leaves block assignment and status untouched. + // - Exists for user-facing metadata edits only; wallet-internal state + // transitions use dedicated helper queries. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) + // Updates the wallet-relative status for a set of transaction row IDs. + // + // How: + // - Exists for wallet-internal replacement and invalidation flows after the + // caller has already identified the affected rows. + // - Leaves block assignment untouched; rollback/disconnect continues to use the + // dedicated rewind helpers below. + // Performance: + // - Restricts by wallet scope first, then matches only the provided ID set. + UpdateTransactionStatusByIDs(ctx context.Context, arg UpdateTransactionStatusByIDsParams) (int64, error) UpdateWalletSecrets(ctx context.Context, arg UpdateWalletSecretsParams) (int64, error) UpdateWalletSyncState(ctx context.Context, arg UpdateWalletSyncStateParams) (int64, error) } diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/db/sqlc/sqlite/transactions.sql.go new file mode 100644 index 0000000000..a6b28a0f11 --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/transactions.sql.go @@ -0,0 +1,616 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: transactions.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" + "strings" + "time" +) + +const ConfirmUnminedTransactionByHash = `-- name: ConfirmUnminedTransactionByHash :execrows +UPDATE transactions +SET + block_height = cast(?1 AS INTEGER), + tx_status = 1 +WHERE + wallet_id = ?2 + AND tx_hash = ?3 + AND block_height IS NULL + AND tx_status IN (0, 1) +` + +type ConfirmUnminedTransactionByHashParams struct { + BlockHeight int64 + WalletID int64 + TxHash []byte +} + +// Attaches a confirming block to one existing live unmined transaction row. +// +// How: +// - Updates only rows that are still blockless and live (`pending` or +// `published`). +// - Leaves user-visible metadata such as labels untouched so confirmation can +// reuse the original transaction row instead of reinserting it. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) { + result, err := q.exec(ctx, q.confirmUnminedTransactionByHashStmt, ConfirmUnminedTransactionByHash, arg.BlockHeight, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteBlocksAtOrAboveHeight = `-- name: DeleteBlocksAtOrAboveHeight :execrows +DELETE FROM blocks +WHERE block_height >= ? +` + +// Deletes blocks at and after the provided height. +// +// How: +// - Deletes directly from blocks by the natural height key. +// - Relies on FK/trigger side effects to null transaction block references and +// orphan coinbase rows. +// +// Performance: +// - Executes as a range delete over the block-height primary key. +func (q *Queries) DeleteBlocksAtOrAboveHeight(ctx context.Context, blockHeight int64) (int64, error) { + result, err := q.exec(ctx, q.deleteBlocksAtOrAboveHeightStmt, DeleteBlocksAtOrAboveHeight, blockHeight) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteUnminedTransactionByHash = `-- name: DeleteUnminedTransactionByHash :execrows +DELETE FROM transactions +WHERE + wallet_id = ? + AND tx_hash = ? + AND block_height IS NULL + AND tx_status IN (0, 1) +` + +type DeleteUnminedTransactionByHashParams struct { + WalletID int64 + TxHash []byte +} + +// Deletes an unconfirmed transaction row. +// +// How: +// - Deletes only rows whose `block_height` is still NULL and whose status is +// still in a live unconfirmed state (`pending` or `published`). +// - Preserves orphaned/replaced/failed history; those rows must remain visible +// for audit/reorg handling instead of being treated as ordinary mempool data. +// - The caller must delete or restore dependent UTXO rows first. +// +// Performance: +// - Targets at most one row by `(wallet_id, tx_hash)`. +func (q *Queries) DeleteUnminedTransactionByHash(ctx context.Context, arg DeleteUnminedTransactionByHashParams) (int64, error) { + result, err := q.exec(ctx, q.deleteUnminedTransactionByHashStmt, DeleteUnminedTransactionByHash, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetTransactionByHash = `-- name: GetTransactionByHash :one +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON t.block_height = b.block_height +WHERE t.wallet_id = ? AND t.tx_hash = ? +` + +type GetTransactionByHashParams struct { + WalletID int64 + TxHash []byte +} + +type GetTransactionByHashRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt64 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Retrieves the full transaction row along with optional block metadata. +// +// How: +// - Looks up the transaction by `(wallet_id, tx_hash)`. +// - LEFT JOINs blocks on `block_height` so the same query handles mined and +// unmined rows. +// +// Performance: +// - The unique transaction lookup limits the join fanout to at most one block +// row. +func (q *Queries) GetTransactionByHash(ctx context.Context, arg GetTransactionByHashParams) (GetTransactionByHashRow, error) { + row := q.queryRow(ctx, q.getTransactionByHashStmt, GetTransactionByHash, arg.WalletID, arg.TxHash) + var i GetTransactionByHashRow + err := row.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ) + return i, err +} + +const GetTransactionMetaByHash = `-- name: GetTransactionMetaByHash :one +SELECT + id, + block_height, + is_coinbase, + tx_status, + tx_label +FROM transactions +WHERE wallet_id = ? AND tx_hash = ? +` + +type GetTransactionMetaByHashParams struct { + WalletID int64 + TxHash []byte +} + +type GetTransactionMetaByHashRow struct { + ID int64 + BlockHeight sql.NullInt64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Retrieves the primary key and lightweight transaction metadata. +// +// How: +// - Reads only the transactions table because callers only need row identity +// plus lightweight status/label fields. +// +// Performance: +// - Uses the wallet-scoped unique `(wallet_id, tx_hash)` lookup path. +func (q *Queries) GetTransactionMetaByHash(ctx context.Context, arg GetTransactionMetaByHashParams) (GetTransactionMetaByHashRow, error) { + row := q.queryRow(ctx, q.getTransactionMetaByHashStmt, GetTransactionMetaByHash, arg.WalletID, arg.TxHash) + var i GetTransactionMetaByHashRow + err := row.Scan( + &i.ID, + &i.BlockHeight, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ) + return i, err +} + +const InsertTransaction = `-- name: InsertTransaction :one +INSERT INTO transactions ( + wallet_id, + tx_hash, + raw_tx, + block_height, + tx_status, + received_time, + is_coinbase, + tx_label +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ? +) +RETURNING id +` + +type InsertTransactionParams struct { + WalletID int64 + TxHash []byte + RawTx []byte + BlockHeight sql.NullInt64 + TxStatus int64 + ReceivedTime time.Time + IsCoinbase bool + TxLabel string +} + +// Inserts a wallet-scoped transaction row and returns its database ID. +// +// How: +// - Writes only the transactions table. +// - Expects the caller to have already resolved wallet scope and any optional +// block reference. +// - Expects the caller to supply the initial status explicitly so unmined rows +// do not have to guess between `pending` and `published`. +// +// Performance: +// - Single-row insert. The cost is dominated by the wallet/hash uniqueness +// checks and any optional block foreign-key validation. +func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { + row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, + arg.WalletID, + arg.TxHash, + arg.RawTx, + arg.BlockHeight, + arg.TxStatus, + arg.ReceivedTime, + arg.IsCoinbase, + arg.TxLabel, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const ListRollbackCoinbaseRoots = `-- name: ListRollbackCoinbaseRoots :many +SELECT + wallet_id, + tx_hash +FROM transactions +WHERE + block_height >= cast(?1 AS INTEGER) + AND is_coinbase +ORDER BY wallet_id, id +` + +type ListRollbackCoinbaseRootsRow struct { + WalletID int64 + TxHash []byte +} + +// Lists wallet-scoped coinbase transaction hashes at or above the rollback +// boundary that seed descendant invalidation. +// +// How: +// - Reads only confirmed coinbase rows at or above the rollback boundary. +// - Returns wallet scope alongside each tx hash so callers can treat these +// coinbase transactions as rollback roots when invalidating now-dead +// descendants inside the same rollback transaction. +// - This is a rollback-specific helper, not a generic "coinbase txs from one +// block" listing query. +// +// Performance: +// - Uses the block-height index to bound the scan to the rollback range. +func (q *Queries) ListRollbackCoinbaseRoots(ctx context.Context, rollbackHeight int64) ([]ListRollbackCoinbaseRootsRow, error) { + rows, err := q.query(ctx, q.listRollbackCoinbaseRootsStmt, ListRollbackCoinbaseRoots, rollbackHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRollbackCoinbaseRootsRow + for rows.Next() { + var i ListRollbackCoinbaseRootsRow + if err := rows.Scan(&i.WalletID, &i.TxHash); 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 ListTransactionsByHeightRange = `-- name: ListTransactionsByHeightRange :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +INNER JOIN blocks AS b ON t.block_height = b.block_height +WHERE + t.wallet_id = ?1 + AND t.block_height >= cast(?2 AS INTEGER) + AND t.block_height <= cast(?3 AS INTEGER) +ORDER BY t.block_height, t.id +` + +type ListTransactionsByHeightRangeParams struct { + WalletID int64 + StartHeight int64 + EndHeight int64 +} + +type ListTransactionsByHeightRangeRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt64 + BlockHash []byte + BlockTimestamp int64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Lists all confirmed transactions for a wallet in the provided height range. +// +// How: +// - Reads transactions in a wallet-scoped block-height range. +// - INNER JOINs blocks on the natural `block_height` key to hydrate block hash +// and timestamp for confirmed rows. +// +// Performance: +// - The `(wallet_id, block_height)` index bounds the scan before the single-row +// block join. +func (q *Queries) ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) { + rows, err := q.query(ctx, q.listTransactionsByHeightRangeStmt, ListTransactionsByHeightRange, arg.WalletID, arg.StartHeight, arg.EndHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTransactionsByHeightRangeRow + for rows.Next() { + var i ListTransactionsByHeightRangeRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); 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 ListUnminedTransactions = `-- name: ListUnminedTransactions :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON 1 = 0 +WHERE + t.wallet_id = ? + AND t.block_height IS NULL + AND t.tx_status IN (0, 1) +ORDER BY t.received_time DESC, t.id DESC +` + +type ListUnminedTransactionsRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt64 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Lists all unconfirmed transactions for a wallet. +// +// How: +// - Reads from transactions only and filters on blockless rows that are still +// in a live unconfirmed state (`pending` or `published`). +// - Excludes orphaned/replaced/failed history so rollback-produced coinbase +// rows do not reappear as mempool transactions. +// - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` +// so the unmined row shape stays aligned with the confirmed query below. +// +// Performance: +// - Matches the dedicated blockless-history index while the more selective +// live-only partial index stays available for conflict paths. +func (q *Queries) ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) { + rows, err := q.query(ctx, q.listUnminedTransactionsStmt, ListUnminedTransactions, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUnminedTransactionsRow + for rows.Next() { + var i ListUnminedTransactionsRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); 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 RewindWalletSyncStateHeightsForRollback = `-- name: RewindWalletSyncStateHeightsForRollback :execrows +UPDATE wallet_sync_states +SET + synced_height = CASE + WHEN + synced_height IS NOT NULL + AND synced_height >= cast(?1 AS INTEGER) + THEN ?2 + ELSE synced_height + END, + birthday_height = CASE + WHEN + birthday_height IS NOT NULL + AND birthday_height >= cast(?1 AS INTEGER) + THEN ?2 + ELSE birthday_height + END, + updated_at = current_timestamp +WHERE + ( + synced_height IS NOT NULL + AND synced_height >= cast(?1 AS INTEGER) + ) + OR ( + birthday_height IS NOT NULL + AND birthday_height >= cast(?1 AS INTEGER) + ) +` + +type RewindWalletSyncStateHeightsForRollbackParams struct { + RollbackHeight int64 + NewHeight sql.NullInt64 +} + +// Rewrites wallet sync-state heights so they stop referencing blocks that are +// about to be deleted during RollbackToBlock. +// +// How: +// - Updates wallet_sync_states directly without joining other tables. +// - Rewrites both synced_height and birthday_height in one statement so the +// subsequent block delete does not violate `ON DELETE RESTRICT`. +// - Example: if `rollback_height = 195`, then any `synced_height` or +// `birthday_height` at 195 or above rewinds to `new_height = 194`. +// - If rollback starts from height 0, callers pass `new_height = NULL` so the +// sync state no longer points at any surviving block row. +// +// Performance: +// - Touches only wallet_sync_states rows whose heights are at or above the +// rollback boundary. +func (q *Queries) RewindWalletSyncStateHeightsForRollback(ctx context.Context, arg RewindWalletSyncStateHeightsForRollbackParams) (int64, error) { + result, err := q.exec(ctx, q.rewindWalletSyncStateHeightsForRollbackStmt, RewindWalletSyncStateHeightsForRollback, arg.RollbackHeight, arg.NewHeight) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateTransactionLabelByHash = `-- name: UpdateTransactionLabelByHash :execrows +UPDATE transactions +SET tx_label = ?1 +WHERE + wallet_id = ?2 + AND tx_hash = ?3 +` + +type UpdateTransactionLabelByHashParams struct { + Label string + WalletID int64 + TxHash []byte +} + +// Updates only the user-visible transaction label. +// +// How: +// - Leaves block assignment and status untouched. +// - Exists for user-facing metadata edits only; wallet-internal state +// transitions use dedicated helper queries. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionLabelByHashStmt, UpdateTransactionLabelByHash, arg.Label, arg.WalletID, arg.TxHash) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateTransactionStatusByIDs = `-- name: UpdateTransactionStatusByIDs :execrows +UPDATE transactions +SET tx_status = ?1 +WHERE + wallet_id = ?2 + AND id IN (/*SLICE:tx_ids*/?) +` + +type UpdateTransactionStatusByIDsParams struct { + Status int64 + WalletID int64 + TxIds []int64 +} + +// Updates the wallet-relative status for a set of transaction row IDs. +// +// How: +// - Exists for wallet-internal replacement and invalidation flows after the +// caller has already identified the affected rows. +// - Leaves block assignment untouched; rollback/disconnect continues to use the +// dedicated rewind helpers below. +// +// Performance: +// - Restricts by wallet scope first, then matches only the provided ID set. +func (q *Queries) UpdateTransactionStatusByIDs(ctx context.Context, arg UpdateTransactionStatusByIDsParams) (int64, error) { + query := UpdateTransactionStatusByIDs + var queryParams []interface{} + queryParams = append(queryParams, arg.Status) + queryParams = append(queryParams, arg.WalletID) + if len(arg.TxIds) > 0 { + for _, v := range arg.TxIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:tx_ids*/?", strings.Repeat(",?", len(arg.TxIds))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:tx_ids*/?", "NULL", 1) + } + result, err := q.exec(ctx, nil, query, queryParams...) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go b/wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go new file mode 100644 index 0000000000..a4a2cd60ce --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go @@ -0,0 +1,313 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: tx_replacements.sql + +package sqlcsqlite + +import ( + "context" + "time" +) + +const InsertTxReplacementEdge = `-- name: InsertTxReplacementEdge :execrows +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + ?1, ?2, ?3 +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING +` + +type InsertTxReplacementEdgeParams struct { + WalletID int64 + ReplacedTxID int64 + ReplacementTxID int64 +} + +// Records a replacement edge between two wallet-scoped transactions. +// +// How: +// - Writes directly to tx_replacements using already-resolved transaction IDs. +// - Uses an explicit conflict target so only duplicate edges are ignored; +// missing transaction IDs, self-replacements, and other constraint failures +// still surface to the caller. +// +// Performance: +// - Single-row insert with cheap duplicate suppression via `ON CONFLICT`. +func (q *Queries) InsertTxReplacementEdge(ctx context.Context, arg InsertTxReplacementEdgeParams) (int64, error) { + result, err := q.exec(ctx, q.insertTxReplacementEdgeStmt, InsertTxReplacementEdge, arg.WalletID, arg.ReplacedTxID, arg.ReplacementTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const InsertTxReplacementEdgeByHash = `-- name: InsertTxReplacementEdgeByHash :execrows +INSERT INTO tx_replacements ( + wallet_id, + replaced_tx_id, + replacement_tx_id +) VALUES ( + ?1, + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = ?1 AND t.tx_hash = ?2 + ), + ( + SELECT t.id + FROM transactions AS t + WHERE t.wallet_id = ?1 AND t.tx_hash = ?3 + ) +) +ON CONFLICT (wallet_id, replaced_tx_id, replacement_tx_id) DO NOTHING +` + +type InsertTxReplacementEdgeByHashParams struct { + WalletID int64 + TxHash []byte + TxHash_2 []byte +} + +// Records a replacement edge by resolving tx IDs from transaction hashes. +// +// How: +// - Resolves both endpoint transaction IDs from the transactions table using +// the wallet-scoped tx-hash unique lookup. +// - Writes the resulting directed edge to tx_replacements. +// - Uses an explicit conflict target so duplicate-edge retries are ignored +// without masking missing-hash or check-constraint failures. +// +// Performance: +// - Trades two indexed scalar subqueries for one network round trip, which is +// preferable when callers start from tx hashes. +func (q *Queries) InsertTxReplacementEdgeByHash(ctx context.Context, arg InsertTxReplacementEdgeByHashParams) (int64, error) { + result, err := q.exec(ctx, q.insertTxReplacementEdgeByHashStmt, InsertTxReplacementEdgeByHash, arg.WalletID, arg.TxHash, arg.TxHash_2) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const ListReplacedTxHashesByReplacementTxHash = `-- name: ListReplacedTxHashesByReplacementTxHash :many +SELECT + replaced.tx_hash AS replaced_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = ?1 AND replacement.tx_hash = ?2 +ORDER BY r.created_at, r.id +` + +type ListReplacedTxHashesByReplacementTxHashParams struct { + WalletID int64 + TxHash []byte +} + +type ListReplacedTxHashesByReplacementTxHashRow struct { + ReplacedTxHash []byte + CreatedAt time.Time +} + +// Lists victim txids for a given replacement txid. +// +// How: +// - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +// id)` to map both edge endpoints back to network tx hashes. +// - Filters by the replacement hash on the `replacement` alias. +// +// Performance: +// - Mirrors the victim lookup path while keeping the graph traversal bounded by +// wallet scope and indexed edge keys. +func (q *Queries) ListReplacedTxHashesByReplacementTxHash(ctx context.Context, arg ListReplacedTxHashesByReplacementTxHashParams) ([]ListReplacedTxHashesByReplacementTxHashRow, error) { + rows, err := q.query(ctx, q.listReplacedTxHashesByReplacementTxHashStmt, ListReplacedTxHashesByReplacementTxHash, arg.WalletID, arg.TxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacedTxHashesByReplacementTxHashRow + for rows.Next() { + var i ListReplacedTxHashesByReplacementTxHashRow + if err := rows.Scan(&i.ReplacedTxHash, &i.CreatedAt); 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 ListReplacedTxIDsByReplacementTxID = `-- name: ListReplacedTxIDsByReplacementTxID :many +SELECT + replaced_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = ?1 AND replacement_tx_id = ?2 +ORDER BY created_at, id +` + +type ListReplacedTxIDsByReplacementTxIDParams struct { + WalletID int64 + ReplacementTxID int64 +} + +type ListReplacedTxIDsByReplacementTxIDRow struct { + ReplacedTxID int64 + CreatedAt time.Time +} + +// Lists victim transaction IDs for a given replacement transaction ID. +// +// How: +// - Reads tx_replacements directly by `(wallet_id, replacement_tx_id)` because +// the caller already has the replacement row ID. +// - Orders first by created_at and then by id so traversal stays deterministic +// even when several edges share the same timestamp. +// +// Performance: +// - Uses the inverse replacement lookup index without joining transactions. +func (q *Queries) ListReplacedTxIDsByReplacementTxID(ctx context.Context, arg ListReplacedTxIDsByReplacementTxIDParams) ([]ListReplacedTxIDsByReplacementTxIDRow, error) { + rows, err := q.query(ctx, q.listReplacedTxIDsByReplacementTxIDStmt, ListReplacedTxIDsByReplacementTxID, arg.WalletID, arg.ReplacementTxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacedTxIDsByReplacementTxIDRow + for rows.Next() { + var i ListReplacedTxIDsByReplacementTxIDRow + if err := rows.Scan(&i.ReplacedTxID, &i.CreatedAt); 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 ListReplacementTxHashesByReplacedTxHash = `-- name: ListReplacementTxHashesByReplacedTxHash :many +SELECT + replacement.tx_hash AS replacement_tx_hash, + r.created_at +FROM tx_replacements AS r +INNER JOIN transactions AS replaced + ON r.wallet_id = replaced.wallet_id AND r.replaced_tx_id = replaced.id +INNER JOIN transactions AS replacement + ON + r.wallet_id = replacement.wallet_id + AND r.replacement_tx_id = replacement.id +WHERE r.wallet_id = ?1 AND replaced.tx_hash = ?2 +ORDER BY r.created_at, r.id +` + +type ListReplacementTxHashesByReplacedTxHashParams struct { + WalletID int64 + TxHash []byte +} + +type ListReplacementTxHashesByReplacedTxHashRow struct { + ReplacementTxHash []byte + CreatedAt time.Time +} + +// Lists replacement txids for a given victim txid. +// +// How: +// - Starts from tx_replacements, then joins transactions twice on `(wallet_id, +// id)` to map both edge endpoints back to network tx hashes. +// - Filters by the victim hash on the `replaced` alias. +// +// Performance: +// - The victim hash lookup narrows the graph walk before the second transaction +// join materializes replacement hashes. +func (q *Queries) ListReplacementTxHashesByReplacedTxHash(ctx context.Context, arg ListReplacementTxHashesByReplacedTxHashParams) ([]ListReplacementTxHashesByReplacedTxHashRow, error) { + rows, err := q.query(ctx, q.listReplacementTxHashesByReplacedTxHashStmt, ListReplacementTxHashesByReplacedTxHash, arg.WalletID, arg.TxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacementTxHashesByReplacedTxHashRow + for rows.Next() { + var i ListReplacementTxHashesByReplacedTxHashRow + if err := rows.Scan(&i.ReplacementTxHash, &i.CreatedAt); 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 ListReplacementTxIDsByReplacedTxID = `-- name: ListReplacementTxIDsByReplacedTxID :many +SELECT + replacement_tx_id, + created_at +FROM tx_replacements +WHERE wallet_id = ?1 AND replaced_tx_id = ?2 +ORDER BY created_at, id +` + +type ListReplacementTxIDsByReplacedTxIDParams struct { + WalletID int64 + ReplacedTxID int64 +} + +type ListReplacementTxIDsByReplacedTxIDRow struct { + ReplacementTxID int64 + CreatedAt time.Time +} + +// Lists replacement transaction IDs for a given victim transaction ID. +// +// How: +// - Reads tx_replacements directly by `(wallet_id, replaced_tx_id)` because the +// caller already has the victim's internal row ID. +// - Orders first by created_at and then by id so traversal stays deterministic +// even when several edges share the same timestamp. +// +// Performance: +// - Uses the replacement-edge index without joining transactions. +func (q *Queries) ListReplacementTxIDsByReplacedTxID(ctx context.Context, arg ListReplacementTxIDsByReplacedTxIDParams) ([]ListReplacementTxIDsByReplacedTxIDRow, error) { + rows, err := q.query(ctx, q.listReplacementTxIDsByReplacedTxIDStmt, ListReplacementTxIDsByReplacedTxID, arg.WalletID, arg.ReplacedTxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListReplacementTxIDsByReplacedTxIDRow + for rows.Next() { + var i ListReplacementTxIDsByReplacedTxIDRow + if err := rows.Scan(&i.ReplacementTxID, &i.CreatedAt); 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 +} diff --git a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go new file mode 100644 index 0000000000..d5ece2d6ac --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go @@ -0,0 +1,248 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: utxo_leases.sql + +package sqlcsqlite + +import ( + "context" + "time" +) + +const AcquireUtxoLease = `-- name: AcquireUtxoLease :one +INSERT INTO utxo_leases ( + wallet_id, + utxo_id, + lock_id, + expires_at +) +SELECT + ?1 AS wallet_id, + u.id AS utxo_id, + ?2 AS lock_id, + ?3 AS expires_at +FROM utxos AS u +INNER JOIN transactions AS t + ON u.tx_id = t.id +WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?4 + AND u.output_index = ?5 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ?6 IS NOT NULL +ON CONFLICT (utxo_id) DO UPDATE +SET + lock_id = excluded.lock_id, + expires_at = excluded.expires_at +WHERE +utxo_leases.wallet_id = excluded.wallet_id +AND ( + utxo_leases.expires_at <= sqlc.arg('now_utc') + OR utxo_leases.lock_id = excluded.lock_id +) +RETURNING expires_at +` + +type AcquireUtxoLeaseParams struct { + WalletID int64 + LockID []byte + ExpiresAt time.Time + TxHash []byte + OutputIndex int64 + NowUtc interface{} +} + +// Acquires or renews a lease for an outpoint and returns the resulting +// expiration time. +// +// How: +// - Resolves the outpoint to a current UTXO row and writes the lease in the +// same statement. +// - Rechecks that the outpoint is still unspent and its parent transaction is +// still in a live state (`pending` or `published`) at write time. +// - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, +// and expired-lease takeover all happen atomically. +// +// Lease semantics: +// - If the UTXO has no lease row, insert a new lease. +// - If the UTXO has an expired lease, steal it. +// - If the UTXO has an active lease with the same lock_id, renew it. +// - If the UTXO has an active lease with a different lock_id, return no +// rows (caller should treat this as "already leased"). +// +// NOTE: expires_at is computed by the caller as time.Now().UTC()+duration, and +// callers must also pass a UTC-normalized now_utc for lease-expiry checks. +// Performance: +// - SQLite executes the resolution and lease write atomically inside one +// statement, which avoids stale-ID races between separate helper calls. +func (q *Queries) AcquireUtxoLease(ctx context.Context, arg AcquireUtxoLeaseParams) (time.Time, error) { + row := q.queryRow(ctx, q.acquireUtxoLeaseStmt, AcquireUtxoLease, + arg.WalletID, + arg.LockID, + arg.ExpiresAt, + arg.TxHash, + arg.OutputIndex, + arg.NowUtc, + ) + var expires_at time.Time + err := row.Scan(&expires_at) + return expires_at, err +} + +const DeleteExpiredUtxoLeases = `-- name: DeleteExpiredUtxoLeases :execrows +DELETE FROM utxo_leases +WHERE wallet_id = ?1 AND expires_at <= ?2 +` + +type DeleteExpiredUtxoLeasesParams struct { + WalletID int64 + NowUtc time.Time +} + +// Deletes all expired lease rows for a wallet. +// +// How: +// - Removes only rows whose expiration has passed, leaving active leases +// untouched. +// +// Performance: +// - Uses the expiration predicate together with wallet scoping to bound the +// cleanup pass. +func (q *Queries) DeleteExpiredUtxoLeases(ctx context.Context, arg DeleteExpiredUtxoLeasesParams) (int64, error) { + result, err := q.exec(ctx, q.deleteExpiredUtxoLeasesStmt, DeleteExpiredUtxoLeases, arg.WalletID, arg.NowUtc) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetActiveUtxoLeaseLockID = `-- name: GetActiveUtxoLeaseLockID :one +SELECT lock_id +FROM utxo_leases +WHERE + wallet_id = ?1 + AND utxo_id = ?2 + AND expires_at > ?3 +` + +type GetActiveUtxoLeaseLockIDParams struct { + WalletID int64 + UtxoID int64 + NowUtc time.Time +} + +// Returns the lock ID for the current active lease on a UTXO ID. +// +// How: +// - Reads only non-expired lease rows so callers can distinguish an active +// lock-ID mismatch from an already-unlocked output. +// +// Performance: +// - Targets at most one row through the unique lease key. +func (q *Queries) GetActiveUtxoLeaseLockID(ctx context.Context, arg GetActiveUtxoLeaseLockIDParams) ([]byte, error) { + row := q.queryRow(ctx, q.getActiveUtxoLeaseLockIDStmt, GetActiveUtxoLeaseLockID, arg.WalletID, arg.UtxoID, arg.NowUtc) + var lock_id []byte + err := row.Scan(&lock_id) + return lock_id, err +} + +const ListActiveUtxoLeases = `-- name: ListActiveUtxoLeases :many +SELECT + t.tx_hash, + u.output_index, + l.lock_id, + l.expires_at +FROM utxo_leases AS l +INNER JOIN utxos AS u ON l.utxo_id = u.id +INNER JOIN transactions AS t ON u.tx_id = t.id +WHERE + l.wallet_id = ?1 + AND l.expires_at > ?2 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +ORDER BY l.expires_at +` + +type ListActiveUtxoLeasesParams struct { + WalletID int64 + NowUtc time.Time +} + +type ListActiveUtxoLeasesRow struct { + TxHash []byte + OutputIndex int64 + LockID []byte + ExpiresAt time.Time +} + +// Lists all currently active leases for a wallet. +// +// How: +// - Starts from utxo_leases, then joins utxos and transactions so the result +// can be returned as network outpoints. +// - Filters out expired rows using the caller-supplied UTC timestamp. +// - Restricts the result to outputs that are still unspent and whose parent +// transaction is still in a live state (`pending` or `published`). +// +// Performance: +// - Restricts first by wallet and expiration, then joins only the surviving +// lease rows back to utxos/transactions. +func (q *Queries) ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) { + rows, err := q.query(ctx, q.listActiveUtxoLeasesStmt, ListActiveUtxoLeases, arg.WalletID, arg.NowUtc) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveUtxoLeasesRow + for rows.Next() { + var i ListActiveUtxoLeasesRow + if err := rows.Scan( + &i.TxHash, + &i.OutputIndex, + &i.LockID, + &i.ExpiresAt, + ); 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 ReleaseUtxoLease = `-- name: ReleaseUtxoLease :execrows +DELETE FROM utxo_leases +WHERE + utxo_leases.wallet_id = ?1 + AND utxo_leases.utxo_id = ?2 + AND utxo_leases.lock_id = ?3 +` + +type ReleaseUtxoLeaseParams struct { + WalletID int64 + UtxoID int64 + LockID []byte +} + +// Releases a lease for a UTXO ID if the lock_id matches. +// +// How: +// - Deletes by wallet, utxo ID, and lock ID so one caller cannot release +// another caller's active lease accidentally. +// +// Performance: +// - Targets at most one row through the unique lease key. +func (q *Queries) ReleaseUtxoLease(ctx context.Context, arg ReleaseUtxoLeaseParams) (int64, error) { + result, err := q.exec(ctx, q.releaseUtxoLeaseStmt, ReleaseUtxoLease, arg.WalletID, arg.UtxoID, arg.LockID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/db/sqlc/sqlite/utxos.sql.go new file mode 100644 index 0000000000..101a153c09 --- /dev/null +++ b/wallet/internal/db/sqlc/sqlite/utxos.sql.go @@ -0,0 +1,643 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: utxos.sql + +package sqlcsqlite + +import ( + "context" + "database/sql" + "time" +) + +const Balance = `-- name: Balance :one +SELECT + cast(coalesce(sum(u.amount), 0) AS INTEGER) AS total_balance, + cast( + coalesce( + sum( + CASE + WHEN EXISTS ( + SELECT 1 + FROM utxo_leases AS l + WHERE + l.wallet_id = t.wallet_id + AND l.utxo_id = u.id + AND l.expires_at > ?1 + ) THEN u.amount + ELSE 0 + END + ), + 0 + ) AS INTEGER + ) AS locked_balance +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = ?2 + AND ks.wallet_id = ?2 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + ?3 IS NULL + OR acc.account_number = ?3 + ) + AND ( + ?4 IS NULL + OR ?4 = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= ?4 + ) + AND ( + ?5 IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= ?5 + ) + AND ( + ?6 IS NULL + OR ?6 = 0 + OR NOT t.is_coinbase + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= ?6 + ) +` + +type BalanceParams struct { + NowUtc time.Time + WalletID int64 + AccountNumber interface{} + MinConfirms interface{} + MaxConfirms interface{} + CoinbaseMaturity interface{} +} + +type BalanceRow struct { + TotalBalance int64 + LockedBalance int64 +} + +// Returns the total and locked value represented by the wallet's current +// unspent UTXO set. +// +// How: +// - Starts from wallet-scoped unspent outputs and rejoins transactions plus +// wallet_sync_states for confirmation math. +// - Rejoins addresses -> accounts -> key_scopes so ownership validation and +// optional account filtering stay in one read. +// - Applies optional confirmation-range and coinbase-maturity policy directly +// inside the aggregate query so callers can request factual or policy-shaped +// balance reads through one public method. +// - Returns both the total matching value and the locked subset covered by +// active leases after the same filters are applied. +// +// Performance: +// - Executes as one aggregate over wallet-scoped live outputs. +// - Uses a filtered aggregate over active leases rather than issuing a second +// query for the locked subset. +// - Uses the address/account/scope joins to keep ownership validation and +// account filtering in one pass. +func (q *Queries) Balance(ctx context.Context, arg BalanceParams) (BalanceRow, error) { + row := q.queryRow(ctx, q.balanceStmt, Balance, + arg.NowUtc, + arg.WalletID, + arg.AccountNumber, + arg.MinConfirms, + arg.MaxConfirms, + arg.CoinbaseMaturity, + ) + var i BalanceRow + err := row.Scan(&i.TotalBalance, &i.LockedBalance) + return i, err +} + +const ClearUtxosSpentByTxID = `-- name: ClearUtxosSpentByTxID :execrows +UPDATE utxos +SET + spent_by_tx_id = NULL, + spent_input_index = NULL +WHERE + spent_by_tx_id = ?2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = utxos.tx_id AND t.wallet_id = ?1 + ) +` + +type ClearUtxosSpentByTxIDParams struct { + WalletID int64 + SpentByTxID sql.NullInt64 +} + +// Clears spent_by pointers for all UTXOs spent by the provided transaction ID. +// +// How: +// - Resets both spent columns together so the logical spend pointer remains +// internally consistent. +// +// Performance: +// - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet +// ownership through the creating transaction. +func (q *Queries) ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) { + result, err := q.exec(ctx, q.clearUtxosSpentByTxIDStmt, ClearUtxosSpentByTxID, arg.WalletID, arg.SpentByTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteUtxosByTxID = `-- name: DeleteUtxosByTxID :execrows +DELETE FROM utxos +WHERE + tx_id = ?2 + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = utxos.tx_id AND t.wallet_id = ?1 + ) +` + +type DeleteUtxosByTxIDParams struct { + WalletID int64 + TxID int64 +} + +// Deletes all UTXO rows created by the provided transaction ID. +// +// How: +// - Removes outputs by the parent transaction's internal ID after callers have +// already decided the transaction row itself may be deleted. +// +// Performance: +// - Uses the `(tx_id)` index to keep the delete bounded to one transaction's +// outputs, then rechecks wallet ownership through the parent transaction. +func (q *Queries) DeleteUtxosByTxID(ctx context.Context, arg DeleteUtxosByTxIDParams) (int64, error) { + result, err := q.exec(ctx, q.deleteUtxosByTxIDStmt, DeleteUtxosByTxID, arg.WalletID, arg.TxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const GetUtxoByOutpoint = `-- name: GetUtxoByOutpoint :one +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND t.tx_hash = ?2 + AND u.output_index = ?3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +` + +type GetUtxoByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 +} + +type GetUtxoByOutpointRow struct { + TxHash []byte + OutputIndex int64 + Amount int64 + ScriptPubKey []byte + ReceivedTime time.Time + IsCoinbase bool + BlockHeight sql.NullInt64 +} + +// Retrieves a single unspent UTXO by its outpoint. +// +// How: +// - Joins utxos -> transactions on `tx_id` to resolve the outpoint +// from tx hash plus output index. +// - Joins addresses -> accounts -> key_scopes so the read path reasserts that +// the credited address belongs to the requested wallet. +// - Returns leased and unleased outputs alike because leasing affects coin +// selection, not whether the UTXO exists. +// - Treats outputs from both live unconfirmed parent states (`pending` and +// `published`) as part of the wallet's current UTXO set. +// +// Performance: +// - The wallet-scoped tx hash lookup and unique outpoint constraint keep the +// join fanout to at most one candidate output. +func (q *Queries) GetUtxoByOutpoint(ctx context.Context, arg GetUtxoByOutpointParams) (GetUtxoByOutpointRow, error) { + row := q.queryRow(ctx, q.getUtxoByOutpointStmt, GetUtxoByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var i GetUtxoByOutpointRow + err := row.Scan( + &i.TxHash, + &i.OutputIndex, + &i.Amount, + &i.ScriptPubKey, + &i.ReceivedTime, + &i.IsCoinbase, + &i.BlockHeight, + ) + return i, err +} + +const GetUtxoIDByOutpoint = `-- name: GetUtxoIDByOutpoint :one +SELECT u.id +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND t.tx_hash = ?2 + AND u.output_index = ?3 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) +` + +type GetUtxoIDByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 +} + +// Retrieves the database ID for a current UTXO by its outpoint. +// +// How: +// - Joins transactions on `id` so callers can address a UTXO by +// network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. +// - Restricts the result to unspent outputs whose parent transaction is still +// in a live state (`pending` or `published`). +// - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return +// rows whose credited address does not actually belong to the wallet. +// - Exists separately from GetUtxoByOutpoint because mutation helpers often +// need the stable internal UTXO row ID without reading the full public UTXO +// payload. +// +// Performance: +// - Uses the wallet-scoped transaction hash lookup first, then narrows to the +// unique `(tx_id, output_index)` outpoint. +func (q *Queries) GetUtxoIDByOutpoint(ctx context.Context, arg GetUtxoIDByOutpointParams) (int64, error) { + row := q.queryRow(ctx, q.getUtxoIDByOutpointStmt, GetUtxoIDByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var id int64 + err := row.Scan(&id) + return id, err +} + +const GetUtxoSpendByOutpoint = `-- name: GetUtxoSpendByOutpoint :one +SELECT utxos.spent_by_tx_id +FROM transactions AS t +INNER JOIN utxos ON t.id = utxos.tx_id +WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND utxos.output_index = ?3 + AND t.tx_status IN (0, 1) +` + +type GetUtxoSpendByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 +} + +// Returns the current spend edge for one wallet-owned outpoint. +// +// How: +// - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only +// considers outputs whose parent status is `pending` or `published`. +// - Returns the nullable `spent_by_tx_id` column so callers can distinguish +// between an external/dead parent and a wallet-owned conflict. +// +// Performance: +// - Targets one wallet-scoped outpoint through the unique `(tx_id, +// output_index)` key after the parent hash lookup. +func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendByOutpointParams) (sql.NullInt64, error) { + row := q.queryRow(ctx, q.getUtxoSpendByOutpointStmt, GetUtxoSpendByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var spent_by_tx_id sql.NullInt64 + err := row.Scan(&spent_by_tx_id) + return spent_by_tx_id, err +} + +const InsertUtxo = `-- name: InsertUtxo :one +INSERT INTO utxos ( + tx_id, + output_index, + amount, + address_id +) SELECT + t.id AS tx_id, + ?1 AS output_index, + ?2 AS amount, + a.id AS address_id +FROM addresses AS a +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +CROSS JOIN transactions AS t +WHERE + t.id = ?3 + AND t.wallet_id = ?4 + AND t.tx_status IN (0, 1) + AND + a.id = ?5 + AND ks.wallet_id = ?4 +RETURNING id +` + +type InsertUtxoParams struct { + OutputIndex int64 + Amount int64 + TxID int64 + WalletID int64 + AddressID int64 +} + +// Inserts a new UTXO row and returns its database ID. +// +// How: +// - Writes only the utxos table using already-resolved transaction and address +// IDs. +// - Rejoins addresses -> accounts -> key_scopes so the insert only succeeds if +// the provided address ID belongs to the same wallet. +// +// Performance: +// - Single-row insert. The main cost is the wallet-ownership validation join +// plus FK and uniqueness checks. +func (q *Queries) InsertUtxo(ctx context.Context, arg InsertUtxoParams) (int64, error) { + row := q.queryRow(ctx, q.insertUtxoStmt, InsertUtxo, + arg.OutputIndex, + arg.Amount, + arg.TxID, + arg.WalletID, + arg.AddressID, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const ListSpendingTxIDsByParentTxID = `-- name: ListSpendingTxIDsByParentTxID :many +SELECT DISTINCT u.spent_by_tx_id +FROM utxos AS u +WHERE + u.tx_id = ?2 + AND u.spent_by_tx_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM transactions AS t + WHERE t.id = u.tx_id AND t.wallet_id = ?1 + ) +ORDER BY u.spent_by_tx_id +` + +type ListSpendingTxIDsByParentTxIDParams struct { + WalletID int64 + TxID int64 +} + +// Lists direct child transaction IDs for one parent transaction ID. +// +// How: +// - Reads the spend edges already materialized on utxos through +// `(tx_id, spent_by_tx_id)`. +// - Returns only direct children; callers that need full descendant walks should +// traverse this query iteratively in application code. +// +// Performance: +// - Uses the `(tx_id)` and `(spent_by_tx_id)` indexes to keep the walk bounded +// to one wallet-scoped parent. +func (q *Queries) ListSpendingTxIDsByParentTxID(ctx context.Context, arg ListSpendingTxIDsByParentTxIDParams) ([]sql.NullInt64, error) { + rows, err := q.query(ctx, q.listSpendingTxIDsByParentTxIDStmt, ListSpendingTxIDsByParentTxID, arg.WalletID, arg.TxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []sql.NullInt64 + for rows.Next() { + var spent_by_tx_id sql.NullInt64 + if err := rows.Scan(&spent_by_tx_id); err != nil { + return nil, err + } + items = append(items, spent_by_tx_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListUtxos = `-- name: ListUtxos :many +SELECT + t.tx_hash, + u.output_index, + u.amount, + a.script_pub_key, + t.received_time, + t.is_coinbase, + t.block_height +FROM transactions AS t +INNER JOIN utxos AS u ON t.id = u.tx_id +INNER JOIN addresses AS a ON u.address_id = a.id +INNER JOIN accounts AS acc ON a.account_id = acc.id +INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id +LEFT JOIN wallet_sync_states AS s ON t.wallet_id = s.wallet_id +WHERE + t.wallet_id = ?1 + AND ks.wallet_id = ?1 + AND u.spent_by_tx_id IS NULL + AND t.tx_status IN (0, 1) + AND ( + ?2 IS NULL + OR acc.account_number = ?2 + ) + AND ( + ?3 IS NULL + OR ?3 = 0 + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) >= ?3 + ) + AND ( + ?4 IS NULL + OR ( + CASE + WHEN t.block_height IS NULL THEN 0 + WHEN s.synced_height IS NULL THEN NULL + WHEN t.block_height > s.synced_height THEN NULL + ELSE s.synced_height - t.block_height + 1 + END + ) <= ?4 + ) +ORDER BY u.amount, t.tx_hash, u.output_index +` + +type ListUtxosParams struct { + WalletID int64 + AccountNumber interface{} + MinConfirms interface{} + MaxConfirms interface{} +} + +type ListUtxosRow struct { + TxHash []byte + OutputIndex int64 + Amount int64 + ScriptPubKey []byte + ReceivedTime time.Time + IsCoinbase bool + BlockHeight sql.NullInt64 +} + +// Lists unspent UTXOs that match the provided filters. +// +// How: +// - Starts from utxos and joins transactions for tx metadata plus +// wallet_sync_states for confirmation math. +// - Joins addresses to return the required script_pub_key, then reuses +// addresses -> accounts -> key_scopes so account filtering and wallet +// ownership checks happen in the same read. +// - Returns leased outputs too because the API models leases separately from +// UTXO existence. +// - Includes outputs whose parent transaction is still in a live unconfirmed +// state (`pending` or `published`). +// - Intentionally does not enforce coinbase maturity because this query models +// wallet-owned UTXO existence rather than a strictly spendable subset. +// +// Performance: +// - Restricts first by wallet, spend state, and transaction status. +// - Uses the address/account/scope joins to keep ownership validation and +// account filtering in one pass. +// - Treats min/max confirmations as optional filters so callers can +// distinguish "not set" from an explicit zero-conf request. +func (q *Queries) ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) { + rows, err := q.query(ctx, q.listUtxosStmt, ListUtxos, + arg.WalletID, + arg.AccountNumber, + arg.MinConfirms, + arg.MaxConfirms, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUtxosRow + for rows.Next() { + var i ListUtxosRow + if err := rows.Scan( + &i.TxHash, + &i.OutputIndex, + &i.Amount, + &i.ScriptPubKey, + &i.ReceivedTime, + &i.IsCoinbase, + &i.BlockHeight, + ); 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 MarkUtxoSpent = `-- name: MarkUtxoSpent :execrows +UPDATE utxos +SET + spent_by_tx_id = ?4, + spent_input_index = ?5 +WHERE + utxos.tx_id = ( + SELECT t.id + FROM transactions AS t + WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND t.tx_status IN (0, 1) + ) + AND utxos.output_index = ?3 + AND ( + (utxos.spent_by_tx_id IS NULL AND utxos.spent_input_index IS NULL) + OR (utxos.spent_by_tx_id = ?4 AND utxos.spent_input_index = ?5) + ) +` + +type MarkUtxoSpentParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 + SpentByTxID sql.NullInt64 + SpentInputIndex sql.NullInt64 +} + +// Marks a wallet-owned UTXO as spent by a transaction. +// +// How: +// - Resolves the created-by transaction row from `(wallet_id, tx_hash)` inside +// the statement so callers can update by network outpoint. +// - Requires the parent transaction status to be `pending` or `published` +// before a child spend edge can attach. +// - Only changes rows that are currently unspent or already point at the same +// `(spent_by_tx_id, spent_input_index)` pair, which keeps retries idempotent +// without allowing a caller to silently rewrite which input spent the UTXO. +// +// Performance: +// - Targets one outpoint in one wallet; the subquery uses the unique +// wallet-scoped tx hash lookup. +func (q *Queries) MarkUtxoSpent(ctx context.Context, arg MarkUtxoSpentParams) (int64, error) { + result, err := q.exec(ctx, q.markUtxoSpentStmt, MarkUtxoSpent, + arg.WalletID, + arg.TxHash, + arg.OutputIndex, + arg.SpentByTxID, + arg.SpentInputIndex, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} From 25c7e1956d098648b5f80f822643af2db8117fa0 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 24 Mar 2026 10:04:06 -0300 Subject: [PATCH 456/691] wallet: dynamic set pg itest store max connections --- wallet/internal/db/itest/pg_test.go | 61 +++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index d8975fa329..f376f3a012 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -5,9 +5,12 @@ package itest import ( "context" "database/sql" + "flag" "fmt" "os" "regexp" + "runtime" + "strconv" "strings" "sync" "testing" @@ -22,9 +25,6 @@ import ( ) var ( - // Limit concurrent database creation to avoid exhausting connections. - pgDBSemaphore = make(chan struct{}, 4) - // Shared container instance, reused across tests for performance. // This is safe to use concurrently because we only share the container // and not the database inside it. Each test gets its own database. @@ -45,6 +45,23 @@ var ( pgTerminateTimeout = 1 * time.Minute ) +// testParallelism returns the effective test parallelism level. It reads the +// -test.parallel flag when set, falling back to GOMAXPROCS (the default used by +// the Go test runner). +func testParallelism() int { + f := flag.Lookup("test.parallel") + if f == nil { + return runtime.GOMAXPROCS(0) + } + + n, err := strconv.Atoi(f.Value.String()) + if err != nil || n <= 0 { + return runtime.GOMAXPROCS(0) + } + + return n +} + // TestMain ensures the shared postgres container is terminated after the // integration test suite completes to avoid leaking docker resources. func TestMain(m *testing.M) { @@ -52,10 +69,16 @@ func TestMain(m *testing.M) { // Terminate the container after the test suite completes. if pgContainer != nil { - ctx, cancel := context.WithTimeout(context.Background(), pgTerminateTimeout) + ctx, cancel := context.WithTimeout( + context.Background(), pgTerminateTimeout, + ) defer cancel() - err := pgContainer.Terminate(ctx) + // As the tests already completed, we can stop the container + // immediately. + err := pgContainer.Terminate( + ctx, testcontainers.StopTimeout(0), + ) if err != nil { fmt.Printf("failed to terminate postgres container: %v\n", err) } @@ -91,9 +114,8 @@ func DefaultPostgresConfig() PostgresConfig { // GetPostgresContainer returns the shared PostgreSQL container instance. // The container is created once and reused across all tests for performance. -// -// Note: postgres:18-alpine defaults max_connections to 100. -func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, error) { +func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, + error) { pgContainerOnce.Do(func() { cfg := DefaultPostgresConfig() @@ -115,6 +137,12 @@ func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, err postgres.WithDatabase(cfg.Database), postgres.WithUsername(cfg.Username), postgres.WithPassword(cfg.Password), + testcontainers.WithCmd( + "-c", fmt.Sprintf( + "max_connections=%d", + testParallelism()*db.DefaultMaxConnections, + ), + ), testcontainers.WithWaitStrategyAndDeadline( pgInitTimeout, waitForSQL, ), @@ -144,18 +172,19 @@ func sanitizedPgDBName(t *testing.T) string { } // NewTestStore creates a new PostgreSQL database connection with migrations -// applied. Each test gets its own database for isolation. +// applied. Each parallel subtest must call NewTestStore itself rather than +// sharing a store created by the parent test. When a parent test creates the +// store and its subtests call t.Parallel(), the parent finishes and releases +// its parallel slot while the subtests are still running. A new parent test +// fills that slot and opens another store, but the original subtests still +// hold their connections. This leads to more open connections than the parallel +// limit allows, exhausting the PostgreSQL connection pool. Avoid this by +// creating NewTestStore inside each parallel subtest so its lifecycle is tied +// to the subtest's parallel slot. func NewTestStore(t *testing.T) *db.PostgresStore { t.Helper() ctx := t.Context() - // Acquire a semaphore slot to limit concurrent database creation and - // parallel test execution that depends on it. - pgDBSemaphore <- struct{}{} - defer func() { - <-pgDBSemaphore - }() - container, err := GetPostgresContainer(ctx) require.NoError(t, err, "failed to get postgres container") From 905e80049cf5b4d3f70bf4e2169d1d59fef73622 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 25 Mar 2026 18:37:17 -0300 Subject: [PATCH 457/691] wallet: give each parallel subtest its own test store When a parent test creates NewTestStore(t) and its subtests call t.Parallel(), the parent finishes and releases its parallel slot while subtests are still running. Another parent test fills that slot and opens its own store, but the original subtests still hold their store open. This leads to more open store connections than the parallel limit allows, exhausting PostgreSQL connections. Fix by creating NewTestStore(t) inside each parallel subtest so the store lifecycle is tied to the subtest's parallel slot. Table-driven error tests use plain struct params with WalletID injected after wallet creation. Setup funcs in TestGetAddress and TestListAddresses accept store and walletID parameters so each subtest operates on its own isolated database. --- .../internal/db/itest/account_store_test.go | 109 +++++++++--------- .../internal/db/itest/address_store_test.go | 95 +++++++++------ .../db/itest/address_types_store_test.go | 3 +- wallet/internal/db/itest/wallet_store_test.go | 2 +- 4 files changed, 119 insertions(+), 90 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 0f8be143c9..3a0e392a4a 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -51,10 +51,6 @@ func TestCreateAccounts(t *testing.T) { func TestCreateDerivedAccountErrors(t *testing.T) { t.Parallel() - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-create-derived-account-errors") - tests := []struct { name string params db.CreateDerivedAccountParams @@ -63,18 +59,16 @@ func TestCreateDerivedAccountErrors(t *testing.T) { { name: "missing name", params: db.CreateDerivedAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - Name: "", + Scope: db.KeyScopeBIP0084, + Name: "", }, wantErr: db.ErrMissingAccountName, }, { name: "unknown scope", params: db.CreateDerivedAccountParams{ - WalletID: walletID, - Scope: db.KeyScope{Purpose: 999, Coin: 999}, - Name: "unknown-scope-account", + Scope: db.KeyScope{Purpose: 999, Coin: 999}, + Name: "unknown-scope-account", }, wantErr: db.ErrUnknownKeyScope, }, @@ -84,6 +78,10 @@ func TestCreateDerivedAccountErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + tc.params.WalletID = walletID + info, err := store.CreateDerivedAccount(t.Context(), tc.params) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, info) @@ -222,10 +220,6 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { func TestCreateImportedAccountErrors(t *testing.T) { t.Parallel() - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-create-imported-account-errors") - tests := []struct { name string params db.CreateImportedAccountParams @@ -234,7 +228,6 @@ func TestCreateImportedAccountErrors(t *testing.T) { { name: "missing name", params: db.CreateImportedAccountParams{ - WalletID: walletID, Name: "", Scope: db.KeyScopeBIP0084, EncryptedPublicKey: RandomBytes(32), @@ -244,7 +237,6 @@ func TestCreateImportedAccountErrors(t *testing.T) { { name: "missing public key", params: db.CreateImportedAccountParams{ - WalletID: walletID, Name: "missing-pubkey", Scope: db.KeyScopeBIP0084, EncryptedPublicKey: nil, @@ -254,7 +246,6 @@ func TestCreateImportedAccountErrors(t *testing.T) { { name: "unknown scope", params: db.CreateImportedAccountParams{ - WalletID: walletID, Name: "unknown-scope", Scope: db.KeyScope{Purpose: 999, Coin: 999}, EncryptedPublicKey: RandomBytes(32), @@ -267,6 +258,10 @@ func TestCreateImportedAccountErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + tc.params.WalletID = walletID + props, err := store.CreateImportedAccount(t.Context(), tc.params) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, props) @@ -345,17 +340,15 @@ func TestGetAccount(t *testing.T) { func TestGetAccountNotFound(t *testing.T) { t.Parallel() - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-get-account-not-found") - - createAllAccounts(t, store, walletID) - scope := db.KeyScopeBIP0084 t.Run("by name", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-account-not-found-name") + createAllAccounts(t, store, walletID) + query := getAccountQueryByName(walletID, scope, "non-existent") info, err := store.GetAccount(t.Context(), query) require.ErrorIs(t, err, db.ErrAccountNotFound) @@ -365,6 +358,10 @@ func TestGetAccountNotFound(t *testing.T) { t.Run("by number", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-account-not-found-number") + createAllAccounts(t, store, walletID) + query := getAccountQueryByNumber(walletID, scope, 99999) info, err := store.GetAccount(t.Context(), query) require.ErrorIs(t, err, db.ErrAccountNotFound) @@ -380,15 +377,13 @@ func TestListAccounts(t *testing.T) { // Ensure that has at least 3 accounts to be tested. require.GreaterOrEqual(t, len(AllAccountCases), 3) - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-list-accounts") - - createAllAccounts(t, store, walletID) - t.Run("all accounts", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-accounts-all") + createAllAccounts(t, store, walletID) + query := db.ListAccountsQuery{WalletID: walletID} accounts, err := store.ListAccounts(t.Context(), query) require.NoError(t, err) @@ -402,6 +397,10 @@ func TestListAccounts(t *testing.T) { t.Run("filter by scope", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-accounts-scope") + createAllAccounts(t, store, walletID) + scope := db.KeyScopeBIP0084 query := db.ListAccountsQuery{ WalletID: walletID, @@ -422,6 +421,10 @@ func TestListAccounts(t *testing.T) { t.Run("filter by name", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-accounts-name") + createAllAccounts(t, store, walletID) + // Ensure that has at least 3 derived accounts to be tested. require.GreaterOrEqual(t, len(DerivedAccountCases), 3) @@ -440,6 +443,8 @@ func TestListAccounts(t *testing.T) { t.Run("empty result", func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + // Create a new wallet with no accounts. emptyWalletID := newWallet(t, store, "wallet-list-empty") query := db.ListAccountsQuery{WalletID: emptyWalletID} @@ -605,15 +610,6 @@ func TestRenameAccount(t *testing.T) { func TestRenameAccountErrors(t *testing.T) { t.Parallel() - store := NewTestStore(t) - - walletID := newWallet(t, store, "wallet-rename-account-errors") - - createAllAccounts(t, store, walletID) - - nonExistentName := "nonexistent" - nonExistentNum := uint32(99999) - tests := []struct { name string params db.RenameAccountParams @@ -622,40 +618,35 @@ func TestRenameAccountErrors(t *testing.T) { { name: "not found", params: db.RenameAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - OldName: nonExistentName, - NewName: "new-name", + Scope: db.KeyScopeBIP0084, + OldName: "nonexistent", + NewName: "new-name", }, wantErr: db.ErrAccountNotFound, }, { name: "invalid - both set", params: db.RenameAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - OldName: nonExistentName, - AccountNumber: &nonExistentNum, - NewName: "new-name", + Scope: db.KeyScopeBIP0084, + OldName: "nonexistent", + NewName: "new-name", }, wantErr: db.ErrInvalidAccountQuery, }, { name: "invalid - neither set", params: db.RenameAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - NewName: "new-name", + Scope: db.KeyScopeBIP0084, + NewName: "new-name", }, wantErr: db.ErrInvalidAccountQuery, }, { name: "invalid - empty new name", params: db.RenameAccountParams{ - WalletID: walletID, - Scope: db.KeyScopeBIP0084, - OldName: nonExistentName, - NewName: "", + Scope: db.KeyScopeBIP0084, + OldName: "nonexistent", + NewName: "", }, wantErr: db.ErrMissingAccountName, }, @@ -665,6 +656,16 @@ func TestRenameAccountErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-rename-account-errors") + createAllAccounts(t, store, walletID) + tc.params.WalletID = walletID + + if tc.name == "invalid - both set" { + num := uint32(99999) + tc.params.AccountNumber = &num + } + err := store.RenameAccount(t.Context(), tc.params) require.ErrorIs(t, err, tc.wantErr) }) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 8e437f5e8c..19f0099e9b 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -572,20 +572,21 @@ func TestGetAddressSecret(t *testing.T) { func TestGetAddress(t *testing.T) { t.Parallel() - store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-get-address") - tests := []struct { name string - setupFunc func(t *testing.T) db.GetAddressQuery - wantErr error - validate func(t *testing.T, addr *db.AddressInfo) + setupFunc func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, walletID uint32) db.GetAddressQuery + wantErr error + validate func(t *testing.T, addr *db.AddressInfo) }{ { name: "get by encrypted script pubkey", - setupFunc: func(t *testing.T) db.GetAddressQuery { + setupFunc: func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, + walletID uint32) db.GetAddressQuery { + createImportedAccount( - t, store, walletID, db.KeyScopeBIP0084, "imported", + t, accountStore, walletID, db.KeyScopeBIP0084, "imported", ) script := RandomBytes(32) @@ -596,7 +597,7 @@ func TestGetAddress(t *testing.T) { PubKey: RandomBytes(33), ScriptPubKey: script, } - _, err := store.NewImportedAddress(t.Context(), params) + _, err := addrStore.NewImportedAddress(t.Context(), params) require.NoError(t, err) return db.GetAddressQuery{ @@ -611,7 +612,9 @@ func TestGetAddress(t *testing.T) { }, { name: "address not found by script", - setupFunc: func(t *testing.T) db.GetAddressQuery { + setupFunc: func(_ *testing.T, _ db.AddressStore, + _ db.AccountStore, walletID uint32) db.GetAddressQuery { + return db.GetAddressQuery{ WalletID: walletID, ScriptPubKey: RandomBytes(32), @@ -621,7 +624,9 @@ func TestGetAddress(t *testing.T) { }, { name: "invalid query - empty script pubkey", - setupFunc: func(t *testing.T) db.GetAddressQuery { + setupFunc: func(_ *testing.T, _ db.AddressStore, + _ db.AccountStore, walletID uint32) db.GetAddressQuery { + return db.GetAddressQuery{ WalletID: walletID, ScriptPubKey: nil, @@ -634,7 +639,11 @@ func TestGetAddress(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - query := tc.setupFunc(t) + + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + + query := tc.setupFunc(t, store, store, walletID) addr, err := store.GetAddress(t.Context(), query) if tc.wantErr != nil { @@ -658,25 +667,28 @@ func TestGetAddress(t *testing.T) { func TestListAddresses(t *testing.T) { t.Parallel() - store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-list-addresses") - tests := []struct { name string - setupFunc func(t *testing.T) db.ListAddressesQuery + setupFunc func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, walletID uint32) db.ListAddressesQuery wantCount int wantErr error validate func(t *testing.T, addrs []db.AddressInfo) }{ { name: "list multiple addresses for account", - setupFunc: func(t *testing.T) db.ListAddressesQuery { + setupFunc: func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, + walletID uint32) db.ListAddressesQuery { + createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0044, "test-account", + t, accountStore, walletID, db.KeyScopeBIP0044, + "test-account", ) createDerivedAddresses( - t, store, walletID, db.KeyScopeBIP0044, "test-account", + t, addrStore, walletID, db.KeyScopeBIP0044, + "test-account", false, 5, ) @@ -698,9 +710,13 @@ func TestListAddresses(t *testing.T) { }, { name: "list addresses - empty result", - setupFunc: func(t *testing.T) db.ListAddressesQuery { + setupFunc: func(t *testing.T, _ db.AddressStore, + accountStore db.AccountStore, + walletID uint32) db.ListAddressesQuery { + createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0084, "empty-account", + t, accountStore, walletID, db.KeyScopeBIP0084, + "empty-account", ) return db.ListAddressesQuery{ @@ -713,22 +729,29 @@ func TestListAddresses(t *testing.T) { }, { name: "list addresses filters by scope correctly", - setupFunc: func(t *testing.T) db.ListAddressesQuery { + setupFunc: func(t *testing.T, addrStore db.AddressStore, + accountStore db.AccountStore, + walletID uint32) db.ListAddressesQuery { + // Create accounts in different scopes. createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0044, "bip44-multi", + t, accountStore, walletID, db.KeyScopeBIP0044, + "bip44-multi", ) createDerivedAccount( - t, store, walletID, db.KeyScopeBIP0049Plus, "bip49-multi", + t, accountStore, walletID, db.KeyScopeBIP0049Plus, + "bip49-multi", ) createDerivedAddresses( - t, store, walletID, db.KeyScopeBIP0044, "bip44-multi", + t, addrStore, walletID, db.KeyScopeBIP0044, + "bip44-multi", false, 3, ) createDerivedAddresses( - t, store, walletID, db.KeyScopeBIP0049Plus, "bip49-multi", + t, addrStore, walletID, db.KeyScopeBIP0049Plus, + "bip49-multi", false, 2, ) @@ -752,7 +775,11 @@ func TestListAddresses(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - query := tc.setupFunc(t) + + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + + query := tc.setupFunc(t, store, store, walletID) addrs, err := store.ListAddresses(t.Context(), query) if tc.wantErr != nil { @@ -966,9 +993,6 @@ func TestListAddressesOrdering(t *testing.T) { func TestNewDerivedAddressErrors(t *testing.T) { t.Parallel() - store := NewTestStore(t) - walletID := newWallet(t, store, "wallet-new-derived-address-errors") - tests := []struct { name string params db.NewDerivedAddressParams @@ -977,7 +1001,6 @@ func TestNewDerivedAddressErrors(t *testing.T) { { name: "non-existent account", params: db.NewDerivedAddressParams{ - WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "non-existent", Change: false, @@ -987,7 +1010,6 @@ func TestNewDerivedAddressErrors(t *testing.T) { { name: "empty account name", params: db.NewDerivedAddressParams{ - WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "", Change: false, @@ -997,7 +1019,6 @@ func TestNewDerivedAddressErrors(t *testing.T) { { name: "unknown key scope returns account not found", params: db.NewDerivedAddressParams{ - WalletID: walletID, Scope: db.KeyScope{ Purpose: 999, Coin: 999, @@ -1013,7 +1034,13 @@ func TestNewDerivedAddressErrors(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - info, err := store.NewDerivedAddress(t.Context(), tc.params, mockDeriveFunc()) + store := NewTestStore(t) + walletID := newWallet(t, store, tc.name+"-wallet") + tc.params.WalletID = walletID + + info, err := store.NewDerivedAddress( + t.Context(), tc.params, mockDeriveFunc(), + ) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, info) }) diff --git a/wallet/internal/db/itest/address_types_store_test.go b/wallet/internal/db/itest/address_types_store_test.go index fec40e59e6..d8fb45d9ab 100644 --- a/wallet/internal/db/itest/address_types_store_test.go +++ b/wallet/internal/db/itest/address_types_store_test.go @@ -33,7 +33,6 @@ func TestListAddressTypes(t *testing.T) { func TestGetAddressType(t *testing.T) { t.Parallel() - store := NewTestStore(t) tests := []struct { id db.AddressType @@ -119,6 +118,8 @@ func TestGetAddressType(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() + store := NewTestStore(t) + got, err := store.GetAddressType(t.Context(), tc.id) if tc.wantErr != nil { diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index fcc098d9b1..8f7f5c3624 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -100,8 +100,8 @@ func TestCreateWallet_Variants(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store := NewTestStore(t) params := tc.params(tc.name) + store := NewTestStore(t) info, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) From 23f2ab71c766d7f1c7b6a74b4a0490a9b31b34d3 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 25 Mar 2026 20:33:27 +0800 Subject: [PATCH 458/691] bwtest: wait for miner p2p readiness bitcoind can race the rpctest miner listener during harness startup and stick at height 0 until a later reconnect, which shows up as a flaky bitcoind v30 itest failure in CI. Poll the miner P2P address before starting external backends so the first outbound peer attempt has a listener to connect to. --- bwtest/harness.go | 2 ++ bwtest/tcp.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 bwtest/tcp.go diff --git a/bwtest/harness.go b/bwtest/harness.go index ec201a069a..debcb53bdf 100644 --- a/bwtest/harness.go +++ b/bwtest/harness.go @@ -70,6 +70,8 @@ func SetupHarness(t *testing.T, chainBackendType, dbType string) *HarnessTest { minerLogDir := createOrEnsureLogSubDir(t, logDir, "miner") miner := newMiner(t, minerLogDir) miner.SetUp() + require.NoError(t, waitForTCPListener(miner.P2PAddress(), + defaultTestTimeout), "miner p2p listener not ready") // 2. Start Chain Backend. backendLogDir := "" diff --git a/bwtest/tcp.go b/bwtest/tcp.go new file mode 100644 index 0000000000..76d534e6be --- /dev/null +++ b/bwtest/tcp.go @@ -0,0 +1,41 @@ +package bwtest + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/btcsuite/btcwallet/bwtest/wait" +) + +// waitForTCPListener polls until addr accepts TCP connections. +// +// The integration harness uses this before starting external backends that +// immediately dial the miner. Without the extra readiness check, a backend's +// first outbound peer attempt can race the rpctest miner listener on slower CI +// runners and leave the backend stuck at height 0 until its next reconnect. +func waitForTCPListener(addr string, timeout time.Duration) error { + err := wait.NoError(func() error { + ctx, cancel := context.WithTimeout( + context.Background(), wait.PollInterval, + ) + defer cancel() + + dialer := &net.Dialer{} + + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("dial %s: %w", addr, err) + } + + _ = conn.Close() + + return nil + }, timeout) + if err != nil { + return fmt.Errorf("wait for tcp listener %s: %w", addr, err) + } + + return nil +} From 76b130561a039ca2b0d0f39473bea011e2402f25 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 27 Mar 2026 11:14:45 -0300 Subject: [PATCH 459/691] wallet: refactor itest testing his name --- wallet/internal/db/itest/account_store_test.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 3a0e392a4a..5d8c8ecafa 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -610,6 +610,9 @@ func TestRenameAccount(t *testing.T) { func TestRenameAccountErrors(t *testing.T) { t.Parallel() + accountNumber := uint32(99999) + accountPointer := &accountNumber + tests := []struct { name string params db.RenameAccountParams @@ -627,9 +630,10 @@ func TestRenameAccountErrors(t *testing.T) { { name: "invalid - both set", params: db.RenameAccountParams{ - Scope: db.KeyScopeBIP0084, - OldName: "nonexistent", - NewName: "new-name", + Scope: db.KeyScopeBIP0084, + OldName: "nonexistent", + AccountNumber: accountPointer, + NewName: "new-name", }, wantErr: db.ErrInvalidAccountQuery, }, @@ -661,11 +665,6 @@ func TestRenameAccountErrors(t *testing.T) { createAllAccounts(t, store, walletID) tc.params.WalletID = walletID - if tc.name == "invalid - both set" { - num := uint32(99999) - tc.params.AccountNumber = &num - } - err := store.RenameAccount(t.Context(), tc.params) require.ErrorIs(t, err, tc.wantErr) }) From 407a391a377c742bbe2ad334a62b01d3ae2dab9b Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Fri, 27 Mar 2026 13:55:32 -0300 Subject: [PATCH 460/691] wallet: size postgres itest max_connections to P*M + M + 3*P --- wallet/internal/db/itest/pg_test.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index f376f3a012..723e481e7b 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -132,16 +132,33 @@ func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, }, ).WithStartupTimeout(pgInitTimeout) + p := testParallelism() + m := db.DefaultMaxConnections + + // pgMaxConns is the Postgres max_connections budget for the + // test container. It is sized as P*M + M + 3*P where: + // + // P*M — steady-state: up to P parallel tests each holding a + // pool of at most M connections (db.SetMaxOpenConns). + // + // +M — teardown latency: one extra store-equivalent for a + // store that has called Close() but whose connections + // have not yet fully disappeared from Postgres. + // + // +3*P — per-slot bootstrap overlap: each slot needs roughly + // 3 transient connections while a new test starts — + // one admin connection for CREATE DATABASE, ~1 for + // PingContext, and ~1 for migration setup — so 3*P + // covers all slots transitioning simultaneously. + pgMaxConns := p*m + m + 3*p + pgContainer, pgContainerErr = postgres.Run(ctx, cfg.Image, postgres.WithDatabase(cfg.Database), postgres.WithUsername(cfg.Username), postgres.WithPassword(cfg.Password), testcontainers.WithCmd( - "-c", fmt.Sprintf( - "max_connections=%d", - testParallelism()*db.DefaultMaxConnections, - ), + "-c", fmt.Sprintf("max_connections=%d", pgMaxConns), ), testcontainers.WithWaitStrategyAndDeadline( pgInitTimeout, waitForSQL, From f907857ce1d1d1e168b65b8ec81e7c42674a0312 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 9 Feb 2026 09:19:28 +0800 Subject: [PATCH 461/691] chain: fix flaky TestPrunedBlockDispatcherQuerySameBlock The test's stop() method asserted that the queriedPeer channel was empty after shutdown. However, the neutrino work manager can non-deterministically dispatch the same query to multiple peers via retries or worker redistribution, producing extra OnGetData callbacks. This caused sporadic "did not consume all queriedPeer signals" failures. Replace the strict empty-check with a drain loop. Tests that need to verify specific query counts already do so explicitly via assertPeerQueried. --- chain/pruned_block_dispatcher_test.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/chain/pruned_block_dispatcher_test.go b/chain/pruned_block_dispatcher_test.go index cca5b9aa42..cea038177a 100644 --- a/chain/pruned_block_dispatcher_test.go +++ b/chain/pruned_block_dispatcher_test.go @@ -181,10 +181,18 @@ func (h *prunedBlockDispatcherHarness) stop() { default: } - select { - case <-h.queriedPeer: - h.t.Fatal("did not consume all queriedPeer signals") - default: + // Drain any remaining queriedPeer signals. The exact number of peer + // queries depends on the work manager's internal scheduling (e.g. + // retries, worker redistribution) and is non-deterministic. Tests + // that care about specific query counts already assert them + // explicitly via assertPeerQueried. +drainQueriedPeer: + for { + select { + case <-h.queriedPeer: + default: + break drainQueriedPeer + } } require.Empty(h.t, h.blocksQueried) @@ -271,11 +279,6 @@ func (h *prunedBlockDispatcherHarness) query(blocks []*chainhash.Hash, cancelChan := make(chan error, 1) blockChan, errChan := h.dispatcher.Query(blocks, cancelChan, opts...) - select { - case err := <-errChan: - require.NoError(h.t, err) - default: - } for _, block := range blocks { h.blocksQueried[*block]++ From 2c8199dac91248ed4bda0d194ac7d585bc56d807 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 30 Mar 2026 18:07:06 +0800 Subject: [PATCH 462/691] wallet: double postgres itest max_connections budget Keep the parallel coverage itests from exhausting Postgres connections without relying on a tighter transient overlap estimate. --- wallet/internal/db/itest/pg_test.go | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 723e481e7b..c400b5855a 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -136,21 +136,11 @@ func GetPostgresContainer(ctx context.Context) (*postgres.PostgresContainer, m := db.DefaultMaxConnections // pgMaxConns is the Postgres max_connections budget for the - // test container. It is sized as P*M + M + 3*P where: - // - // P*M — steady-state: up to P parallel tests each holding a - // pool of at most M connections (db.SetMaxOpenConns). - // - // +M — teardown latency: one extra store-equivalent for a - // store that has called Close() but whose connections - // have not yet fully disappeared from Postgres. - // - // +3*P — per-slot bootstrap overlap: each slot needs roughly - // 3 transient connections while a new test starts — - // one admin connection for CREATE DATABASE, ~1 for - // PingContext, and ~1 for migration setup — so 3*P - // covers all slots transitioning simultaneously. - pgMaxConns := p*m + m + 3*p + // test container. We budget 2x the steady-state pool size to + // absorb connection lifecycle overlap during parallel test + // teardown and startup without trying to model each transient + // connection source separately. + pgMaxConns := 2 * p * m pgContainer, pgContainerErr = postgres.Run(ctx, cfg.Image, From 7e19f89ac23acb51710a5b6cbc86640218d7d5af Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 30 Mar 2026 19:27:37 +0800 Subject: [PATCH 463/691] docs: add repo guide for coding agents Document btcwallet's build, test, and style conventions so coding agents can work consistently in this repository. --- AGENTS.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..1fdf90145f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,160 @@ +# AGENTS.md + +This file is for coding agents working in `btcwallet`. + +## Scope and Priority + +- Follow this file first for repo-specific workflow and style. +- Then follow `docs/developer/` for deeper rationale. +- If guidance conflicts, prefer the stricter rule and match nearby code. + +## Repo Workflow + +- Keep the main checkout at the repo root. +- Place auxiliary worktrees under `/.worktrees/`. +- Use short issue- or task-oriented worktree names. +- Do not create sibling worktrees outside the repo root unless asked. +- Remove finished worktrees with `git worktree remove `. +- Clean stale worktree metadata with `git worktree prune`. + +## Tooling and Environment + +- The authoritative Go version is `1.24.6`. +- Sources: `go.mod`, `.golangci.yml`, and `.github/workflows/main.yml`. +- Some older docs mention older Go versions; ignore them. +- Many maintenance targets use Docker-backed tooling. +- `make fmt`, `make lint*`, `make sqlc`, and `make protolint` use Docker. +- PostgreSQL DB integration tests also require Docker via testcontainers. +- No Cursor rules were found in `.cursor/rules/` or `.cursorrules`. +- No Copilot instructions were found in `.github/copilot-instructions.md`. +- There is an additional style summary in `.gemini/styleguide.md`. + +## Primary Build, Format, and Codegen Commands + +- Build everything: `make build` +- Install binaries: `make install` +- CI compile check: `go install -v ./...` +- Default `make` target is `make build`. +- `make install` installs `btcwallet`, `cmd/dropwtxmgr`, and `cmd/sweepaccount`. +- Go format/imports: `make fmt`; verify with `make fmt-check` +- Lint: `make lint-config-check`, `make lint-check`, `make lint`; optional `workers=4` +- Proto: `make rpc-format`, `make rpc`, `make rpc-check`, `make protolint` +- SQL: `make sql-parse`, `make sql-format`, `make sql-format-check`, `make sql-lint`, `make sql-lint-check`, `make sqlc`, `make sqlc-check`, `make sql` +- Modules and config: `make tidy-module`, `make tidy-module-check`, `make sample-conf-check` + +## Command Gotchas + +- Several `*-check` targets modify files before checking git cleanliness. +- `make fmt-check` runs `make fmt` first. +- `make rpc-check` runs code generation first. +- `make sqlc-check` runs SQL codegen first. +- `make sql-format-check` formats SQL first. +- `make tidy-module-check` runs `go mod tidy` first. +- Do not run those blindly in a dirty tree unless you expect edits. + +## Unit Test Commands + +- Run all unit tests: `make unit` +- Run one package: `make unit pkg=wallet` +- Run one specific test: `make unit pkg=wallet case=TestBuildTxDetail` +- Common flags: `verbose=1`, `nocache=1`, `timeout=5m` +- Run unit tests with race detector: `make unit-race` +- Run targeted race test: `make unit-race pkg=wallet case=TestBuildTxDetail` +- Run unit coverage: `make unit-cover` +- Run targeted coverage: `make unit-cover pkg=wallet` +- Run benchmarks for one package: `make unit-bench pkg=wallet` +- Include alloc stats: `make unit-bench pkg=wallet benchmem=1` + +## Integration Test Commands + +- DB itests: `make itest-db db=sqlite` or `make itest-db db=postgres` +- Single DB itest: `make itest-db db=postgres case=TestWhatever verbose=1` +- DB coverage/race: `make itest-db db=postgres cover=1 verbose=1`, `make itest-db-race db=sqlite verbose=1` +- The DB integration suite lives in `wallet/internal/db/itest`. +- E2E default: `make itest` +- E2E backends: `make itest chain=btcd db=kvdb`, `make itest chain=neutrino db=kvdb`, `make itest chain=bitcoind db=kvdb` +- E2E case filter: `make itest icase=manager` or `make itest chain=btcd db=kvdb icase=manager` +- E2E logs go to `itest/test-logs/`; case names must follow `component action` and must not use `_`. + +## Verification Strategy + +- Run the narrowest relevant test first. +- Before handing off a substantial change, run at least package-level tests. +- For SQL, proto, module, or config changes, run the matching generation/check target. +- For DB-layer changes, prefer `itest-db` in addition to unit tests. +- For backend flow or RPC changes, consider `make itest` coverage. +- If a change claims performance improvement, add or run a benchmark. +- CI covers formatting, imports, modules, proto, SQL, lint, unit, DB, and e2e. + +## Go Formatting and Imports + +- Follow `Effective Go` and the repo docs in `docs/developer/`. +- Let `make fmt` manage imports through `gosimports` and `gofmt`. +- Do not manually fight import grouping; accept formatter output. +- Go files use tab indentation. +- Markdown files use LF and wrap to 80 characters. +- Keep lines near 80 columns on a best-effort basis. +- The style docs mention treating tabs as width 8 for visual wrapping. +- `.editorconfig` and linter settings use width 4; keep lines conservative. +- Formatting excludes generated `*.pb.go` files. + +## Code Layout and Naming + +- Break functions into logical stanzas separated by blank lines. +- Add comments where intent is not obvious; explain why, not the mechanics. +- Every function should have a purpose comment; comments must start with the function name. +- Exported functions need caller-oriented comments, not just maintainer notes. +- Wrap long function calls one argument per line with `)` on its own line. +- If a function declaration spans multiple lines, start the body after a blank line. +- Avoid generic package names like `utils`, `common`, or `helpers`; use `internal` for non-public code. +- Prefer domain-focused package boundaries, avoid circular dependencies, and accept interfaces while returning concrete structs. +- Match existing names in the surrounding package before inventing new terms. + +## Types, Errors, and Concurrency + +- Put `context.Context` first for blocking or long-running operations. +- Wrap dependency errors with context using `%w`. +- Prefer sentinel errors for important conditions and check with `errors.Is`. +- Define normal non-exceptional cases out of the error path when practical. +- Prefer communicating over shared memory. +- Never start a goroutine without a clear shutdown path. +- Do not access maps concurrently without synchronization. +- Treat slices as shared mutable state unless ownership is explicit. +- Pass sync primitives by pointer, not by value. + +## Logging Guidelines + +- Supported levels are `trace`, `debug`, `info`, `warn`, `error`, `critical`. +- Use `error` for unexpected internal failures. +- Expected external failures usually belong at `info`, `debug`, or `warn`. +- Much of the repo still uses legacy `log.Tracef/Debugf/...` patterns. +- In files that already use legacy logging, preserve the local style. +- If adding structured logging, keep the message static and put data in attributes. +- Use `slog.Attr` or helpers like `btclog.Fmt` for structured log fields. +- Log and error formatting are exceptions to the usual multiline call wrapping. + +## Test Style Guidelines + +- New non-trivial behavior and bug fixes should come with regression tests. +- Cover both positive paths and negative or error paths. +- Prefer `require` over `assert` for most checks. +- Structure tests as Arrange, Act, Assert with blank lines between sections. +- Use table-driven tests only when setup shape is identical across cases. +- If setup differs across cases, prefer separate standalone tests and keep case structs data-only. +- Use descriptive flat test names and `t.Parallel()` where safe. +- Tests commonly use fast scrypt parameters; do not remove that optimization. + +## SQL, Proto, Modules, and PRs + +- SQL is formatted and linted with SQLFluff. +- SQL keywords and types should be uppercase; identifiers and function names should be lowercase. +- Prefer `!=` over `<>`; SQL indentation uses 4 spaces. +- Protos are formatted with `clang-format` through `make rpc-format`. +- Proto messages use UpperCamelCase; filenames use lower_snake_case. +- Proto imports should be sorted, package names lowercase, and services/RPCs commented. +- The repo contains multiple Go submodules; avoid ad hoc local `replace` directives. +- Favor small, reviewable commits. +- Commit subjects typically look like `subsystem: short description`. +- Use present tense, keep the subject near 50 chars, and wrap bodies near 72. +- PRs should include clear test steps and cover positive and negative cases. +- Insubstantial typo-only changes are discouraged by the contribution guide. From 21902c0ad08c9dc1da4cb56fd2638d6ed4c93a34 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 30 Mar 2026 19:27:37 +0800 Subject: [PATCH 464/691] repo: ignore repo-local worktrees Ignore the repo-local .worktrees directory so auxiliary worktree checkouts do not pollute git status. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 218d27859d..1ab06d6ed7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage-itest-sqlite.txt .vscode .DS_Store .aider* +/.worktrees/ coverage.out *.prof *.test From a34b937b377223479d15de918351a83e9d9e014e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:48:15 +0800 Subject: [PATCH 465/691] wallet: update store interfaces's terminologies Clarify the published-status wording, explain the dedicated blockless ListTxns path, and make DeleteTx state the exact unmined statuses it accepts. These comments make the tx store contract easier to read before the method implementations land. --- wallet/internal/db/data_types.go | 17 ++++++++++++----- wallet/internal/db/interface.go | 18 ++++++++++-------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index c745fb0968..a268e74a70 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -742,7 +742,7 @@ const ( // caller whether the transaction is mined. Keeping both under // TxStatusPublished avoids contradictory combinations such as // "confirmed but not published" and keeps this field focused on whether the - // wallet still treats the transaction as live. + // wallet still treats the transaction as valid. TxStatusPublished // TxStatusReplaced indicates a transaction that was invalidated by a @@ -944,9 +944,16 @@ type ListTxnsQuery struct { // EndHeight is the ending block height for the query. EndHeight uint32 - // UnminedOnly, if true, will return only unmined (unconfirmed) - // transactions. If this is set, StartHeight and EndHeight will be - // ignored. + // UnminedOnly, if true, switches ListTxns onto the dedicated no-confirming- + // block read path. + // + // This path returns the active unmined set together with retained invalid + // history rows that also no longer have a confirming block, such as + // orphaned or failed transactions after rollback. + // + // This is not equivalent to using zero confirmations. The confirmed + // height-range query cannot express "only rows with no block", so + // StartHeight and EndHeight are ignored when this flag is set. UnminedOnly bool } @@ -1070,7 +1077,7 @@ type LeasedOutput struct { // BalanceResult represents one wallet-scoped balance view after applying the // requested filters. type BalanceResult struct { - // Total is the sum of every matching live UTXO, including leased outputs. + // Total is the sum of every matching UTXO, including leased outputs. Total btcutil.Amount // Locked is the subset of Total currently covered by active output leases. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index d83a0afb54..506d21421a 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -283,8 +283,8 @@ type TxStore interface { // returning a slice of TxInfo or an error if the retrieval fails. ListTxns(ctx context.Context, query ListTxnsQuery) ([]TxInfo, error) - // DeleteTx removes a live unconfirmed transaction from the store. It - // takes a context and DeleteTxParams, returning an error if the + // DeleteTx removes an unmined pending or published transaction from the + // store. It takes a context and DeleteTxParams, returning an error if the // transaction is not found or the deletion fails. // // DeleteTx is intentionally narrower than a generic @@ -319,16 +319,16 @@ type UTXOStore interface { // outpoint. It returns a UtxoInfo struct containing the UTXO's details // or an error if the UTXO is not found or has been spent. // - // GetUtxo treats outputs created by both live unconfirmed states - // (TxStatusPending and TxStatusPublished) as present in the wallet's - // UTXO set. + // GetUtxo treats outputs created by unmined `pending` and `published` + // transactions as present in the wallet's UTXO set. GetUtxo(ctx context.Context, query GetUtxoQuery) (*UtxoInfo, error) // ListUTXOs returns a slice of all unspent transaction outputs (UTXOs) // that match the provided query parameters. This can be used to list // all UTXOs or filter them by account or confirmation status. // - // ListUTXOs includes outputs from live unconfirmed parent transactions. + // ListUTXOs includes outputs from unmined `pending` and `published` + // parent transactions. // Spendability policy such as coinbase maturity, lease state, or whether // a caller wants to exclude TxStatusPending parents belongs in higher-level // selection logic. @@ -350,7 +350,8 @@ type UTXOStore interface { // ErrOutputUnlockNotAllowed. ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error - // ListLeasedOutputs returns a slice of all currently leased live UTXOs. + // ListLeasedOutputs returns a slice of all currently leased UTXOs whose + // parent transaction is still `pending` or `published`. // This can be used to inspect which still-unspent outputs are currently // locked and when their leases expire. ListLeasedOutputs(ctx context.Context, walletID uint32) ( @@ -359,7 +360,8 @@ type UTXOStore interface { // Balance returns a wallet-scoped balance view for the current unspent UTXO // set after applying any optional caller-supplied filters. // - // The zero-value BalanceParams request the wallet's live factual balance. + // The zero-value BalanceParams request the wallet's current factual + // balance. // Callers may narrow that view by account, confirmation range, lease // or coinbase maturity when they need a workflow-specific balance policy // from the public store interface. The returned BalanceResult always uses From c3befffcb4dacbf4cc08502245355edef788952f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:51:09 +0800 Subject: [PATCH 466/691] wallet: update store SQL's terminologies Clarify the transaction-state terminology in the handwritten SQL and migration comments so unmined, invalid, and rollback paths are described consistently. Keep the matching sqlc output in the same commit so the generated query docs stay aligned with the handwritten SQL. --- .../postgres/000007_transactions.up.sql | 10 ++--- .../sqlite/000007_transactions.up.sql | 12 +++--- .../db/queries/postgres/transactions.sql | 22 +++++----- .../db/queries/postgres/utxo_leases.sql | 4 +- wallet/internal/db/queries/postgres/utxos.sql | 15 +++---- .../db/queries/sqlite/transactions.sql | 19 +++++---- .../db/queries/sqlite/utxo_leases.sql | 4 +- wallet/internal/db/queries/sqlite/utxos.sql | 15 +++---- wallet/internal/db/sqlc/postgres/querier.go | 41 ++++++++++--------- .../db/sqlc/postgres/transactions.sql.go | 22 +++++----- .../db/sqlc/postgres/utxo_leases.sql.go | 4 +- wallet/internal/db/sqlc/postgres/utxos.sql.go | 15 +++---- wallet/internal/db/sqlc/sqlite/querier.go | 38 +++++++++-------- .../db/sqlc/sqlite/transactions.sql.go | 19 +++++---- .../db/sqlc/sqlite/utxo_leases.sql.go | 4 +- wallet/internal/db/sqlc/sqlite/utxos.sql.go | 15 +++---- 16 files changed, 134 insertions(+), 125 deletions(-) diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.up.sql b/wallet/internal/db/migrations/postgres/000007_transactions.up.sql index c7bd9b5f16..c2c3f54348 100644 --- a/wallet/internal/db/migrations/postgres/000007_transactions.up.sql +++ b/wallet/internal/db/migrations/postgres/000007_transactions.up.sql @@ -83,7 +83,7 @@ CREATE TABLE transactions ( -- A transaction attached to a block is treated as confirmed wallet history. -- For confirmed rows, the only valid status is `published`; every other - -- status represents either blockless local state or disconnected history. + -- status represents either unmined local state or disconnected history. CONSTRAINT check_confirmed_published CHECK ( block_height IS NULL OR tx_status = 1 ), @@ -103,19 +103,19 @@ CREATE TABLE transactions ( ) ); --- Optimization for live unconfirmed transaction lookups. +-- Optimization for unmined pending/published transaction lookups. CREATE INDEX idx_transactions_unconfirmed ON transactions (wallet_id, block_height) WHERE block_height IS NULL AND tx_status IN (0, 1); --- Optimization for wallet-scoped joins into the live transaction set. +-- Optimization for wallet-scoped joins into pending/published transactions. CREATE INDEX idx_transactions_live_by_wallet ON transactions (wallet_id, id) WHERE tx_status IN (0, 1); --- Optimization for wallet-scoped blockless history reads ordered by newest +-- Optimization for wallet-scoped unmined history reads ordered by newest -- receive time first. -CREATE INDEX idx_transactions_blockless_history +CREATE INDEX idx_transactions_unmined_history ON transactions (wallet_id, received_time DESC, id DESC) WHERE block_height IS NULL; diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql b/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql index e6c9194b82..85b8f06783 100644 --- a/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql +++ b/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql @@ -80,7 +80,7 @@ CREATE TABLE transactions ( -- A transaction attached to a block is treated as confirmed wallet history. -- For confirmed rows, the only valid status is `published`; every other - -- status represents either blockless local state or disconnected history. + -- status represents either unmined local state or disconnected history. CONSTRAINT check_confirmed_published CHECK ( block_height IS NULL OR tx_status = 1 ), @@ -100,19 +100,19 @@ CREATE TABLE transactions ( ) ); --- Optimization for live unconfirmed transaction lookups. +-- Optimization for unmined pending/published transaction lookups. CREATE INDEX idx_transactions_unconfirmed ON transactions (wallet_id, block_height) WHERE block_height IS NULL AND tx_status IN (0, 1); --- Optimization for wallet-scoped joins into the live transaction set. +-- Optimization for wallet-scoped joins into pending/published transactions. CREATE INDEX idx_transactions_live_by_wallet ON transactions (wallet_id, id) WHERE tx_status IN (0, 1); --- Optimization for wallet-scoped blockless history reads ordered by newest +-- Optimization for wallet-scoped unmined history reads ordered by newest -- receive time first. -CREATE INDEX idx_transactions_blockless_history +CREATE INDEX idx_transactions_unmined_history ON transactions (wallet_id, received_time DESC, id DESC) WHERE block_height IS NULL; @@ -161,7 +161,7 @@ BEGIN -- "coinbase + NULL block + published" combination. tx_status = CASE WHEN is_coinbase THEN 4 - -- Ordinary transactions just become blockless; their non-orphaned + -- Ordinary transactions just become unmined; their non-orphaned -- status is preserved for later rollback/invalidation handling. ELSE tx_status END, diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/db/queries/postgres/transactions.sql index 0f41958652..7b09412ab1 100644 --- a/wallet/internal/db/queries/postgres/transactions.sql +++ b/wallet/internal/db/queries/postgres/transactions.sql @@ -67,18 +67,18 @@ LEFT JOIN blocks AS b ON t.block_height = b.block_height WHERE t.wallet_id = $1 AND t.tx_hash = $2; -- name: ListUnminedTransactions :many --- Lists all unconfirmed transactions for a wallet. +-- Lists the wallet transactions that still belong to the active unmined set. -- -- How: --- - Reads from transactions only and filters on blockless rows that are still --- in a live unconfirmed state (`pending` or `published`). --- - Excludes orphaned/replaced/failed history so rollback-produced coinbase --- rows do not reappear as mempool transactions. --- - Returns typed NULL block metadata explicitly because live unmined rows have --- no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` --- keep the unmined row shape aligned with the confirmed query below. +-- - Reads from transactions only and filters on unmined rows that are still +-- in unmined `pending` or `published` status. +-- - Excludes orphaned/replaced/failed history so delete and rollback logic do +-- not treat retained invalid rows as active mempool spends. +-- - Returns typed NULL block metadata explicitly because unmined rows have no +-- block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +-- keep the row shape aligned with other transaction queries. -- Performance: --- - Matches the dedicated blockless-history index while the more selective +-- - Matches the dedicated unmined-history index while the more selective -- live-only partial index stays available for conflict paths. SELECT t.id, @@ -184,7 +184,7 @@ WHERE -- -- How: -- - Deletes only rows whose `block_height` is still NULL and whose status is --- still in a live unconfirmed state (`pending` or `published`). +-- still unmined `pending` or `published`. -- - Preserves orphaned/replaced/failed history; those rows must remain visible -- for audit/reorg handling instead of being treated as ordinary mempool data. -- - The caller must delete or restore dependent UTXO rows first. @@ -204,7 +204,7 @@ WHERE -- How: -- - Reads only confirmed coinbase rows at or above the rollback boundary. -- - Returns wallet scope alongside each tx hash so callers can treat these --- coinbase transactions as rollback roots when invalidating now-dead +-- coinbase transactions as rollback roots when invalidating now-invalid -- descendants inside the same rollback transaction. -- - This is a rollback-specific helper, not a generic "coinbase txs from one -- block" listing query. diff --git a/wallet/internal/db/queries/postgres/utxo_leases.sql b/wallet/internal/db/queries/postgres/utxo_leases.sql index 262a2287ea..d5d7d4fd0b 100644 --- a/wallet/internal/db/queries/postgres/utxo_leases.sql +++ b/wallet/internal/db/queries/postgres/utxo_leases.sql @@ -6,7 +6,7 @@ -- - Resolves the outpoint to a current UTXO row and writes the lease in the -- same statement. -- - Rechecks that the outpoint is still unspent and its parent transaction is --- still in a live state (`pending` or `published`) at write time. +-- still in `pending` or `published` status at write time. -- - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, -- and expired-lease takeover all happen atomically. -- Lease semantics: @@ -90,7 +90,7 @@ WHERE -- can be returned as network outpoints. -- - Filters out expired rows using the caller-supplied UTC timestamp. -- - Restricts the result to outputs that are still unspent and whose parent --- transaction is still in a live state (`pending` or `published`). +-- transaction is still in `pending` or `published` status. -- Performance: -- - Restricts first by wallet and expiration, then joins only the surviving -- lease rows back to utxos/transactions. diff --git a/wallet/internal/db/queries/postgres/utxos.sql b/wallet/internal/db/queries/postgres/utxos.sql index 1dc4a0c06f..e8b7557852 100644 --- a/wallet/internal/db/queries/postgres/utxos.sql +++ b/wallet/internal/db/queries/postgres/utxos.sql @@ -39,7 +39,7 @@ RETURNING id; -- - Joins transactions on `id` so callers can address a UTXO by -- network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. -- - Restricts the result to unspent outputs whose parent transaction is still --- in a live state (`pending` or `published`). +-- `pending` or `published`. -- - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return -- rows whose credited address does not actually belong to the wallet. -- - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -72,8 +72,8 @@ WHERE -- the credited address belongs to the requested wallet. -- - Returns leased and unleased outputs alike because leasing affects coin -- selection, not whether the UTXO exists. --- - Treats outputs from both live unconfirmed parent states (`pending` and --- `published`) as part of the wallet's current UTXO set. +-- - Treats outputs from unmined `pending` and `published` parent transactions +-- as part of the wallet's current UTXO set. -- Performance: -- - The wallet-scoped tx hash lookup and unique outpoint constraint keep the -- join fanout to at most one candidate output. @@ -109,8 +109,8 @@ WHERE -- ownership checks happen in the same read. -- - Returns leased outputs too because the API models leases separately from -- UTXO existence. --- - Includes outputs whose parent transaction is still in a live unconfirmed --- state (`pending` or `published`). +-- - Includes outputs whose parent transaction is still in `pending` or +-- `published` status. -- - Intentionally does not enforce coinbase maturity because this query models -- wallet-owned UTXO existence rather than a strictly spendable subset. -- Performance: @@ -182,7 +182,8 @@ ORDER BY u.amount, t.tx_hash, u.output_index; -- - Returns both the total matching value and the locked subset covered by -- active leases after the same filters are applied. -- Performance: --- - Executes as one aggregate over wallet-scoped live outputs. +-- - Executes as one aggregate over wallet-scoped outputs whose parent +-- transaction is still `pending` or `published`. -- - Uses a filtered aggregate over active leases rather than issuing a second -- query for the locked subset. -- - Uses the address/account/scope joins to keep ownership validation and @@ -284,7 +285,7 @@ ORDER BY u.spent_by_tx_id; -- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only -- considers outputs whose parent status is `pending` or `published`. -- - Returns the nullable `spent_by_tx_id` column so callers can distinguish --- between an external/dead parent and a wallet-owned conflict. +-- between an external/unknown parent and a wallet-owned conflict. -- Performance: -- - Targets one wallet-scoped outpoint through the unique `(tx_id, -- output_index)` key after the parent hash lookup. diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/db/queries/sqlite/transactions.sql index 4ae01419b0..a5663a4be6 100644 --- a/wallet/internal/db/queries/sqlite/transactions.sql +++ b/wallet/internal/db/queries/sqlite/transactions.sql @@ -67,17 +67,18 @@ LEFT JOIN blocks AS b ON t.block_height = b.block_height WHERE t.wallet_id = ? AND t.tx_hash = ?; -- name: ListUnminedTransactions :many --- Lists all unconfirmed transactions for a wallet. +-- Lists the wallet transactions that still belong to the active unmined set. -- -- How: --- - Reads from transactions only and filters on blockless rows that are still --- in a live unconfirmed state (`pending` or `published`). --- - Excludes orphaned/replaced/failed history so rollback-produced coinbase --- rows do not reappear as mempool transactions. +-- - Reads from transactions only and filters on unmined rows that are still +-- in unmined `pending` or `published` status. +-- - Excludes orphaned/replaced/failed history so delete and rollback logic do +-- not treat retained invalid rows as active mempool spends. -- - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` --- so the unmined row shape stays aligned with the confirmed query below. +-- so sqlc preserves the nullable block columns while the row shape stays +-- aligned with other transaction queries. -- Performance: --- - Matches the dedicated blockless-history index while the more selective +-- - Matches the dedicated unmined-history index while the more selective -- live-only partial index stays available for conflict paths. SELECT t.id, @@ -183,7 +184,7 @@ WHERE -- -- How: -- - Deletes only rows whose `block_height` is still NULL and whose status is --- still in a live unconfirmed state (`pending` or `published`). +-- still unmined `pending` or `published`. -- - Preserves orphaned/replaced/failed history; those rows must remain visible -- for audit/reorg handling instead of being treated as ordinary mempool data. -- - The caller must delete or restore dependent UTXO rows first. @@ -203,7 +204,7 @@ WHERE -- How: -- - Reads only confirmed coinbase rows at or above the rollback boundary. -- - Returns wallet scope alongside each tx hash so callers can treat these --- coinbase transactions as rollback roots when invalidating now-dead +-- coinbase transactions as rollback roots when invalidating now-invalid -- descendants inside the same rollback transaction. -- - This is a rollback-specific helper, not a generic "coinbase txs from one -- block" listing query. diff --git a/wallet/internal/db/queries/sqlite/utxo_leases.sql b/wallet/internal/db/queries/sqlite/utxo_leases.sql index fa6f4eb57b..ca7be71137 100644 --- a/wallet/internal/db/queries/sqlite/utxo_leases.sql +++ b/wallet/internal/db/queries/sqlite/utxo_leases.sql @@ -6,7 +6,7 @@ -- - Resolves the outpoint to a current UTXO row and writes the lease in the -- same statement. -- - Rechecks that the outpoint is still unspent and its parent transaction is --- still in a live state (`pending` or `published`) at write time. +-- still in `pending` or `published` status at write time. -- - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, -- and expired-lease takeover all happen atomically. -- Lease semantics: @@ -91,7 +91,7 @@ WHERE -- can be returned as network outpoints. -- - Filters out expired rows using the caller-supplied UTC timestamp. -- - Restricts the result to outputs that are still unspent and whose parent --- transaction is still in a live state (`pending` or `published`). +-- transaction is still in `pending` or `published` status. -- Performance: -- - Restricts first by wallet and expiration, then joins only the surviving -- lease rows back to utxos/transactions. diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/db/queries/sqlite/utxos.sql index 403695abe6..04534606e5 100644 --- a/wallet/internal/db/queries/sqlite/utxos.sql +++ b/wallet/internal/db/queries/sqlite/utxos.sql @@ -39,7 +39,7 @@ RETURNING id; -- - Joins transactions on `id` so callers can address a UTXO by -- network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. -- - Restricts the result to unspent outputs whose parent transaction is still --- in a live state (`pending` or `published`). +-- `pending` or `published`. -- - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return -- rows whose credited address does not actually belong to the wallet. -- - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -72,8 +72,8 @@ WHERE -- the credited address belongs to the requested wallet. -- - Returns leased and unleased outputs alike because leasing affects coin -- selection, not whether the UTXO exists. --- - Treats outputs from both live unconfirmed parent states (`pending` and --- `published`) as part of the wallet's current UTXO set. +-- - Treats outputs from unmined `pending` and `published` parent transactions +-- as part of the wallet's current UTXO set. -- Performance: -- - The wallet-scoped tx hash lookup and unique outpoint constraint keep the -- join fanout to at most one candidate output. @@ -109,8 +109,8 @@ WHERE -- ownership checks happen in the same read. -- - Returns leased outputs too because the API models leases separately from -- UTXO existence. --- - Includes outputs whose parent transaction is still in a live unconfirmed --- state (`pending` or `published`). +-- - Includes outputs whose parent transaction is still in `pending` or +-- `published` status. -- - Intentionally does not enforce coinbase maturity because this query models -- wallet-owned UTXO existence rather than a strictly spendable subset. -- Performance: @@ -182,7 +182,8 @@ ORDER BY u.amount, t.tx_hash, u.output_index; -- - Returns both the total matching value and the locked subset covered by -- active leases after the same filters are applied. -- Performance: --- - Executes as one aggregate over wallet-scoped live outputs. +-- - Executes as one aggregate over wallet-scoped outputs whose parent +-- transaction is still `pending` or `published`. -- - Uses a filtered aggregate over active leases rather than issuing a second -- query for the locked subset. -- - Uses the address/account/scope joins to keep ownership validation and @@ -289,7 +290,7 @@ ORDER BY u.spent_by_tx_id; -- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only -- considers outputs whose parent status is `pending` or `published`. -- - Returns the nullable `spent_by_tx_id` column so callers can distinguish --- between an external/dead parent and a wallet-owned conflict. +-- between an external/unknown parent and a wallet-owned conflict. -- Performance: -- - Targets one wallet-scoped outpoint through the unique `(tx_id, -- output_index)` key after the parent hash lookup. diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 073b78a071..3f54632a62 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -18,7 +18,7 @@ type Querier interface { // - Resolves the outpoint to a current UTXO row and writes the lease in the // same statement. // - Rechecks that the outpoint is still unspent and its parent transaction is - // still in a live state (`pending` or `published`) at write time. + // still in `pending` or `published` status at write time. // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, // and expired-lease takeover all happen atomically. // Lease semantics: @@ -48,7 +48,8 @@ type Querier interface { // - Returns both the total matching value and the locked subset covered by // active leases after the same filters are applied. // Performance: - // - Executes as one aggregate over wallet-scoped live outputs. + // - Executes as one aggregate over wallet-scoped outputs whose parent + // transaction is still `pending` or `published`. // - Uses a filtered aggregate over active leases rather than issuing a second // query for the locked subset. // - Uses the address/account/scope joins to keep ownership validation and @@ -125,7 +126,7 @@ type Querier interface { // // How: // - Deletes only rows whose `block_height` is still NULL and whose status is - // still in a live unconfirmed state (`pending` or `published`). + // still unmined `pending` or `published`. // - Preserves orphaned/replaced/failed history; those rows must remain visible // for audit/reorg handling instead of being treated as ordinary mempool data. // - The caller must delete or restore dependent UTXO rows first. @@ -208,8 +209,8 @@ type Querier interface { // the credited address belongs to the requested wallet. // - Returns leased and unleased outputs alike because leasing affects coin // selection, not whether the UTXO exists. - // - Treats outputs from both live unconfirmed parent states (`pending` and - // `published`) as part of the wallet's current UTXO set. + // - Treats outputs from unmined `pending` and `published` parent transactions + // as part of the wallet's current UTXO set. // Performance: // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the // join fanout to at most one candidate output. @@ -220,7 +221,7 @@ type Querier interface { // - Joins transactions on `id` so callers can address a UTXO by // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. // - Restricts the result to unspent outputs whose parent transaction is still - // in a live state (`pending` or `published`). + // `pending` or `published`. // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return // rows whose credited address does not actually belong to the wallet. // - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -236,7 +237,7 @@ type Querier interface { // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish - // between an external/dead parent and a wallet-owned conflict. + // between an external/unknown parent and a wallet-owned conflict. // Performance: // - Targets one wallet-scoped outpoint through the unique `(tx_id, // output_index)` key after the parent hash lookup. @@ -318,7 +319,7 @@ type Querier interface { // can be returned as network outpoints. // - Filters out expired rows using the caller-supplied UTC timestamp. // - Restricts the result to outputs that are still unspent and whose parent - // transaction is still in a live state (`pending` or `published`). + // transaction is still in `pending` or `published` status. // Performance: // - Restricts first by wallet and expiration, then joins only the surviving // lease rows back to utxos/transactions. @@ -377,7 +378,7 @@ type Querier interface { // How: // - Reads only confirmed coinbase rows at or above the rollback boundary. // - Returns wallet scope alongside each tx hash so callers can treat these - // coinbase transactions as rollback roots when invalidating now-dead + // coinbase transactions as rollback roots when invalidating now-invalid // descendants inside the same rollback transaction. // - This is a rollback-specific helper, not a generic "coinbase txs from one // block" listing query. @@ -405,18 +406,18 @@ type Querier interface { // - The `(wallet_id, block_height)` index bounds the scan before the single-row // block join. ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) - // Lists all unconfirmed transactions for a wallet. + // Lists the wallet transactions that still belong to the active unmined set. // // How: - // - Reads from transactions only and filters on blockless rows that are still - // in a live unconfirmed state (`pending` or `published`). - // - Excludes orphaned/replaced/failed history so rollback-produced coinbase - // rows do not reappear as mempool transactions. - // - Returns typed NULL block metadata explicitly because live unmined rows have - // no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` - // keep the unmined row shape aligned with the confirmed query below. + // - Reads from transactions only and filters on unmined rows that are still + // in unmined `pending` or `published` status. + // - Excludes orphaned/replaced/failed history so delete and rollback logic do + // not treat retained invalid rows as active mempool spends. + // - Returns typed NULL block metadata explicitly because unmined rows have no + // block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` + // keep the row shape aligned with other transaction queries. // Performance: - // - Matches the dedicated blockless-history index while the more selective + // - Matches the dedicated unmined-history index while the more selective // live-only partial index stays available for conflict paths. ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) // Lists unspent UTXOs that match the provided filters. @@ -429,8 +430,8 @@ type Querier interface { // ownership checks happen in the same read. // - Returns leased outputs too because the API models leases separately from // UTXO existence. - // - Includes outputs whose parent transaction is still in a live unconfirmed - // state (`pending` or `published`). + // - Includes outputs whose parent transaction is still in `pending` or + // `published` status. // - Intentionally does not enforce coinbase maturity because this query models // wallet-owned UTXO existence rather than a strictly spendable subset. // Performance: diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/db/sqlc/postgres/transactions.sql.go index 2cb9a124e7..ee87bb71e0 100644 --- a/wallet/internal/db/sqlc/postgres/transactions.sql.go +++ b/wallet/internal/db/sqlc/postgres/transactions.sql.go @@ -89,7 +89,7 @@ type DeleteUnminedTransactionByHashParams struct { // // How: // - Deletes only rows whose `block_height` is still NULL and whose status is -// still in a live unconfirmed state (`pending` or `published`). +// still unmined `pending` or `published`. // - Preserves orphaned/replaced/failed history; those rows must remain visible // for audit/reorg handling instead of being treated as ordinary mempool data. // - The caller must delete or restore dependent UTXO rows first. @@ -289,7 +289,7 @@ type ListRollbackCoinbaseRootsRow struct { // How: // - Reads only confirmed coinbase rows at or above the rollback boundary. // - Returns wallet scope alongside each tx hash so callers can treat these -// coinbase transactions as rollback roots when invalidating now-dead +// coinbase transactions as rollback roots when invalidating now-invalid // descendants inside the same rollback transaction. // - This is a rollback-specific helper, not a generic "coinbase txs from one // block" listing query. @@ -437,19 +437,19 @@ type ListUnminedTransactionsRow struct { TxLabel string } -// Lists all unconfirmed transactions for a wallet. +// Lists the wallet transactions that still belong to the active unmined set. // // How: -// - Reads from transactions only and filters on blockless rows that are still -// in a live unconfirmed state (`pending` or `published`). -// - Excludes orphaned/replaced/failed history so rollback-produced coinbase -// rows do not reappear as mempool transactions. -// - Returns typed NULL block metadata explicitly because live unmined rows have -// no block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` -// keep the unmined row shape aligned with the confirmed query below. +// - Reads from transactions only and filters on unmined rows that are still +// in unmined `pending` or `published` status. +// - Excludes orphaned/replaced/failed history so delete and rollback logic do +// not treat retained invalid rows as active mempool spends. +// - Returns typed NULL block metadata explicitly because unmined rows have no +// block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +// keep the row shape aligned with other transaction queries. // // Performance: -// - Matches the dedicated blockless-history index while the more selective +// - Matches the dedicated unmined-history index while the more selective // live-only partial index stays available for conflict paths. func (q *Queries) ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) { rows, err := q.query(ctx, q.listUnminedTransactionsStmt, ListUnminedTransactions, walletID) diff --git a/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go b/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go index 354d7b34ba..607e95362e 100644 --- a/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go +++ b/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go @@ -60,7 +60,7 @@ type AcquireUtxoLeaseParams struct { // - Resolves the outpoint to a current UTXO row and writes the lease in the // same statement. // - Rechecks that the outpoint is still unspent and its parent transaction is -// still in a live state (`pending` or `published`) at write time. +// still in `pending` or `published` status at write time. // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, // and expired-lease takeover all happen atomically. // @@ -187,7 +187,7 @@ type ListActiveUtxoLeasesRow struct { // can be returned as network outpoints. // - Filters out expired rows using the caller-supplied UTC timestamp. // - Restricts the result to outputs that are still unspent and whose parent -// transaction is still in a live state (`pending` or `published`). +// transaction is still in `pending` or `published` status. // // Performance: // - Restricts first by wallet and expiration, then joins only the surviving diff --git a/wallet/internal/db/sqlc/postgres/utxos.sql.go b/wallet/internal/db/sqlc/postgres/utxos.sql.go index 2363718f03..91673e43fd 100644 --- a/wallet/internal/db/sqlc/postgres/utxos.sql.go +++ b/wallet/internal/db/sqlc/postgres/utxos.sql.go @@ -109,7 +109,8 @@ type BalanceRow struct { // active leases after the same filters are applied. // // Performance: -// - Executes as one aggregate over wallet-scoped live outputs. +// - Executes as one aggregate over wallet-scoped outputs whose parent +// transaction is still `pending` or `published`. // - Uses a filtered aggregate over active leases rather than issuing a second // query for the locked subset. // - Uses the address/account/scope joins to keep ownership validation and @@ -245,8 +246,8 @@ type GetUtxoByOutpointRow struct { // the credited address belongs to the requested wallet. // - Returns leased and unleased outputs alike because leasing affects coin // selection, not whether the UTXO exists. -// - Treats outputs from both live unconfirmed parent states (`pending` and -// `published`) as part of the wallet's current UTXO set. +// - Treats outputs from unmined `pending` and `published` parent transactions +// as part of the wallet's current UTXO set. // // Performance: // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the @@ -294,7 +295,7 @@ type GetUtxoIDByOutpointParams struct { // - Joins transactions on `id` so callers can address a UTXO by // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. // - Restricts the result to unspent outputs whose parent transaction is still -// in a live state (`pending` or `published`). +// `pending` or `published`. // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return // rows whose credited address does not actually belong to the wallet. // - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -334,7 +335,7 @@ type GetUtxoSpendByOutpointParams struct { // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish -// between an external/dead parent and a wallet-owned conflict. +// between an external/unknown parent and a wallet-owned conflict. // // Performance: // - Targets one wallet-scoped outpoint through the unique `(tx_id, @@ -533,8 +534,8 @@ type ListUtxosRow struct { // ownership checks happen in the same read. // - Returns leased outputs too because the API models leases separately from // UTXO existence. -// - Includes outputs whose parent transaction is still in a live unconfirmed -// state (`pending` or `published`). +// - Includes outputs whose parent transaction is still in `pending` or +// `published` status. // - Intentionally does not enforce coinbase maturity because this query models // wallet-owned UTXO existence rather than a strictly spendable subset. // diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 5c644cbb50..d73b514a43 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -18,7 +18,7 @@ type Querier interface { // - Resolves the outpoint to a current UTXO row and writes the lease in the // same statement. // - Rechecks that the outpoint is still unspent and its parent transaction is - // still in a live state (`pending` or `published`) at write time. + // still in `pending` or `published` status at write time. // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, // and expired-lease takeover all happen atomically. // Lease semantics: @@ -48,7 +48,8 @@ type Querier interface { // - Returns both the total matching value and the locked subset covered by // active leases after the same filters are applied. // Performance: - // - Executes as one aggregate over wallet-scoped live outputs. + // - Executes as one aggregate over wallet-scoped outputs whose parent + // transaction is still `pending` or `published`. // - Uses a filtered aggregate over active leases rather than issuing a second // query for the locked subset. // - Uses the address/account/scope joins to keep ownership validation and @@ -122,7 +123,7 @@ type Querier interface { // // How: // - Deletes only rows whose `block_height` is still NULL and whose status is - // still in a live unconfirmed state (`pending` or `published`). + // still unmined `pending` or `published`. // - Preserves orphaned/replaced/failed history; those rows must remain visible // for audit/reorg handling instead of being treated as ordinary mempool data. // - The caller must delete or restore dependent UTXO rows first. @@ -205,8 +206,8 @@ type Querier interface { // the credited address belongs to the requested wallet. // - Returns leased and unleased outputs alike because leasing affects coin // selection, not whether the UTXO exists. - // - Treats outputs from both live unconfirmed parent states (`pending` and - // `published`) as part of the wallet's current UTXO set. + // - Treats outputs from unmined `pending` and `published` parent transactions + // as part of the wallet's current UTXO set. // Performance: // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the // join fanout to at most one candidate output. @@ -217,7 +218,7 @@ type Querier interface { // - Joins transactions on `id` so callers can address a UTXO by // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. // - Restricts the result to unspent outputs whose parent transaction is still - // in a live state (`pending` or `published`). + // `pending` or `published`. // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return // rows whose credited address does not actually belong to the wallet. // - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -233,7 +234,7 @@ type Querier interface { // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish - // between an external/dead parent and a wallet-owned conflict. + // between an external/unknown parent and a wallet-owned conflict. // Performance: // - Targets one wallet-scoped outpoint through the unique `(tx_id, // output_index)` key after the parent hash lookup. @@ -314,7 +315,7 @@ type Querier interface { // can be returned as network outpoints. // - Filters out expired rows using the caller-supplied UTC timestamp. // - Restricts the result to outputs that are still unspent and whose parent - // transaction is still in a live state (`pending` or `published`). + // transaction is still in `pending` or `published` status. // Performance: // - Restricts first by wallet and expiration, then joins only the surviving // lease rows back to utxos/transactions. @@ -373,7 +374,7 @@ type Querier interface { // How: // - Reads only confirmed coinbase rows at or above the rollback boundary. // - Returns wallet scope alongside each tx hash so callers can treat these - // coinbase transactions as rollback roots when invalidating now-dead + // coinbase transactions as rollback roots when invalidating now-invalid // descendants inside the same rollback transaction. // - This is a rollback-specific helper, not a generic "coinbase txs from one // block" listing query. @@ -401,17 +402,18 @@ type Querier interface { // - The `(wallet_id, block_height)` index bounds the scan before the single-row // block join. ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) - // Lists all unconfirmed transactions for a wallet. + // Lists the wallet transactions that still belong to the active unmined set. // // How: - // - Reads from transactions only and filters on blockless rows that are still - // in a live unconfirmed state (`pending` or `published`). - // - Excludes orphaned/replaced/failed history so rollback-produced coinbase - // rows do not reappear as mempool transactions. + // - Reads from transactions only and filters on unmined rows that are still + // in unmined `pending` or `published` status. + // - Excludes orphaned/replaced/failed history so delete and rollback logic do + // not treat retained invalid rows as active mempool spends. // - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` - // so the unmined row shape stays aligned with the confirmed query below. + // so sqlc preserves the nullable block columns while the row shape stays + // aligned with other transaction queries. // Performance: - // - Matches the dedicated blockless-history index while the more selective + // - Matches the dedicated unmined-history index while the more selective // live-only partial index stays available for conflict paths. ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) // Lists unspent UTXOs that match the provided filters. @@ -424,8 +426,8 @@ type Querier interface { // ownership checks happen in the same read. // - Returns leased outputs too because the API models leases separately from // UTXO existence. - // - Includes outputs whose parent transaction is still in a live unconfirmed - // state (`pending` or `published`). + // - Includes outputs whose parent transaction is still in `pending` or + // `published` status. // - Intentionally does not enforce coinbase maturity because this query models // wallet-owned UTXO existence rather than a strictly spendable subset. // Performance: diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/db/sqlc/sqlite/transactions.sql.go index a6b28a0f11..efb46a4032 100644 --- a/wallet/internal/db/sqlc/sqlite/transactions.sql.go +++ b/wallet/internal/db/sqlc/sqlite/transactions.sql.go @@ -88,7 +88,7 @@ type DeleteUnminedTransactionByHashParams struct { // // How: // - Deletes only rows whose `block_height` is still NULL and whose status is -// still in a live unconfirmed state (`pending` or `published`). +// still unmined `pending` or `published`. // - Preserves orphaned/replaced/failed history; those rows must remain visible // for audit/reorg handling instead of being treated as ordinary mempool data. // - The caller must delete or restore dependent UTXO rows first. @@ -288,7 +288,7 @@ type ListRollbackCoinbaseRootsRow struct { // How: // - Reads only confirmed coinbase rows at or above the rollback boundary. // - Returns wallet scope alongside each tx hash so callers can treat these -// coinbase transactions as rollback roots when invalidating now-dead +// coinbase transactions as rollback roots when invalidating now-invalid // descendants inside the same rollback transaction. // - This is a rollback-specific helper, not a generic "coinbase txs from one // block" listing query. @@ -436,18 +436,19 @@ type ListUnminedTransactionsRow struct { TxLabel string } -// Lists all unconfirmed transactions for a wallet. +// Lists the wallet transactions that still belong to the active unmined set. // // How: -// - Reads from transactions only and filters on blockless rows that are still -// in a live unconfirmed state (`pending` or `published`). -// - Excludes orphaned/replaced/failed history so rollback-produced coinbase -// rows do not reappear as mempool transactions. +// - Reads from transactions only and filters on unmined rows that are still +// in unmined `pending` or `published` status. +// - Excludes orphaned/replaced/failed history so delete and rollback logic do +// not treat retained invalid rows as active mempool spends. // - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` -// so the unmined row shape stays aligned with the confirmed query below. +// so sqlc preserves the nullable block columns while the row shape stays +// aligned with other transaction queries. // // Performance: -// - Matches the dedicated blockless-history index while the more selective +// - Matches the dedicated unmined-history index while the more selective // live-only partial index stays available for conflict paths. func (q *Queries) ListUnminedTransactions(ctx context.Context, walletID int64) ([]ListUnminedTransactionsRow, error) { rows, err := q.query(ctx, q.listUnminedTransactionsStmt, ListUnminedTransactions, walletID) diff --git a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go index d5ece2d6ac..504def11a9 100644 --- a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go @@ -61,7 +61,7 @@ type AcquireUtxoLeaseParams struct { // - Resolves the outpoint to a current UTXO row and writes the lease in the // same statement. // - Rechecks that the outpoint is still unspent and its parent transaction is -// still in a live state (`pending` or `published`) at write time. +// still in `pending` or `published` status at write time. // - Uses one `INSERT .. ON CONFLICT DO UPDATE` statement so creation, renewal, // and expired-lease takeover all happen atomically. // @@ -184,7 +184,7 @@ type ListActiveUtxoLeasesRow struct { // can be returned as network outpoints. // - Filters out expired rows using the caller-supplied UTC timestamp. // - Restricts the result to outputs that are still unspent and whose parent -// transaction is still in a live state (`pending` or `published`). +// transaction is still in `pending` or `published` status. // // Performance: // - Restricts first by wallet and expiration, then joins only the surviving diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/db/sqlc/sqlite/utxos.sql.go index 101a153c09..f7279ae252 100644 --- a/wallet/internal/db/sqlc/sqlite/utxos.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxos.sql.go @@ -114,7 +114,8 @@ type BalanceRow struct { // active leases after the same filters are applied. // // Performance: -// - Executes as one aggregate over wallet-scoped live outputs. +// - Executes as one aggregate over wallet-scoped outputs whose parent +// transaction is still `pending` or `published`. // - Uses a filtered aggregate over active leases rather than issuing a second // query for the locked subset. // - Uses the address/account/scope joins to keep ownership validation and @@ -250,8 +251,8 @@ type GetUtxoByOutpointRow struct { // the credited address belongs to the requested wallet. // - Returns leased and unleased outputs alike because leasing affects coin // selection, not whether the UTXO exists. -// - Treats outputs from both live unconfirmed parent states (`pending` and -// `published`) as part of the wallet's current UTXO set. +// - Treats outputs from unmined `pending` and `published` parent transactions +// as part of the wallet's current UTXO set. // // Performance: // - The wallet-scoped tx hash lookup and unique outpoint constraint keep the @@ -299,7 +300,7 @@ type GetUtxoIDByOutpointParams struct { // - Joins transactions on `id` so callers can address a UTXO by // network outpoint (`tx_hash`, `output_index`) instead of the internal row ID. // - Restricts the result to unspent outputs whose parent transaction is still -// in a live state (`pending` or `published`). +// `pending` or `published`. // - Rejoins addresses -> accounts -> key_scopes so helper lookups do not return // rows whose credited address does not actually belong to the wallet. // - Exists separately from GetUtxoByOutpoint because mutation helpers often @@ -339,7 +340,7 @@ type GetUtxoSpendByOutpointParams struct { // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and only // considers outputs whose parent status is `pending` or `published`. // - Returns the nullable `spent_by_tx_id` column so callers can distinguish -// between an external/dead parent and a wallet-owned conflict. +// between an external/unknown parent and a wallet-owned conflict. // // Performance: // - Targets one wallet-scoped outpoint through the unique `(tx_id, @@ -538,8 +539,8 @@ type ListUtxosRow struct { // ownership checks happen in the same read. // - Returns leased outputs too because the API models leases separately from // UTXO existence. -// - Includes outputs whose parent transaction is still in a live unconfirmed -// state (`pending` or `published`). +// - Includes outputs whose parent transaction is still in `pending` or +// `published` status. // - Intentionally does not enforce coinbase maturity because this query models // wallet-owned UTXO existence rather than a strictly spendable subset. // From e006c499c1d31dab3b57efc1e05e75f2800ebd50 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 17:21:39 +0800 Subject: [PATCH 467/691] wallet: update tx store interfaces Make CreateTx insert-only, replace UpdateTxLabel with a patch-style UpdateTx API, and switch CreateTx credits to a map keyed by output index. This narrows block/state ownership to UpdateTx, gives duplicate tx inserts a dedicated public error, and simplifies the caller-side credit contract. --- wallet/internal/db/data_types.go | 68 +++++++++++++++----------------- wallet/internal/db/interface.go | 41 ++++++++++++++----- 2 files changed, 63 insertions(+), 46 deletions(-) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index a268e74a70..a70c087606 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -858,44 +858,33 @@ type CreateTxParams struct { // Label is an optional label for the transaction. Label string - // Credits lists the outputs of the transaction that are controlled by - // the wallet. - Credits []CreditData + // Credits maps wallet-owned output indexes to their display addresses. + // + // The output index is the map key, so duplicate credited outputs are + // impossible by construction. + // + // NOTE: The address value is for display only. The database layer still + // matches ownership by the output's script_pub_key + // (`params.Tx.TxOut[index].PkScript`), which is the canonical key + // used by the address schema. + Credits map[uint32]btcutil.Address } -// CreditData contains the information needed to record a transaction credit. -// It acts as an explicit instruction to the CreateTx method, identifying which -// of the transaction's outputs belongs to the wallet and should be recorded as -// a new UTXO. This serves as a performance optimization, preventing the -// database layer from needing to parse every transaction output and query the -// address manager to determine ownership. -type CreditData struct { - // Index is the output index of the credit. - Index uint32 - - // Address is the address that received the credit. - // - // NOTE: This field is for display only. The database layer should match the - // credit to an address row by the output's script_pub_key - // (`params.Tx.TxOut[Index].PkScript`), which is the canonical key - // used by the address schema. This is especially important for - // imported or script-based addresses, where wallet ownership is - // defined by the stored script material rather than by one canonical - // encoded address string. +// UpdateTxState contains one requested transaction-state change. +type UpdateTxState struct { + // Block records the transaction as confirmed in the provided block. // - // Examples: - // - A standard P2WPKH credit usually has one obvious bech32 address for UI - // display, but the wallet still keys ownership off the exact witness - // program bytes recorded in script_pub_key. - // - An imported script address (`waddrmgr.Script`, - // `waddrmgr.WitnessScript`, or `waddrmgr.TaprootScript`) is owned because - // the wallet imported the script material; the encoded address, if - // any, is only a presentation form of that script. - Address btcutil.Address + // Nil clears any current block assignment and returns the row to an unmined + // state. + Block *Block + + // Status is the wallet-relative transaction state to store together with + // the requested block assignment. + Status TxStatus } -// UpdateTxLabelParams contains the parameters for updating a transaction label. -type UpdateTxLabelParams struct { +// UpdateTxParams contains the mutable fields that UpdateTx may patch. +type UpdateTxParams struct { // WalletID is the ID of the wallet containing the transaction. // // NOTE: uint32 is used to ensure compatibility with standard SQL @@ -905,10 +894,17 @@ type UpdateTxLabelParams struct { // Txid is the hash of the transaction to update. Txid chainhash.Hash - // Label is the new label for the transaction. + // Label optionally replaces the stored user-visible label. // - // The empty string is a valid value and clears any prior label. - Label string + // Nil leaves the label unchanged. The empty string is a valid value and + // clears any prior label. + Label *string + + // State optionally replaces the stored block/status view of the + // transaction. + // + // Nil leaves the chain-state metadata unchanged. + State *UpdateTxState } // GetTxQuery contains the parameters for querying a transaction. While a diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 506d21421a..280e23d012 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -75,13 +75,32 @@ var ( // database. ErrTxNotFound = errors.New("transaction not found") + // ErrTxAlreadyExists is returned when CreateTx is asked to insert a + // wallet-scoped transaction hash that already exists. + ErrTxAlreadyExists = errors.New("transaction already exists") + + // ErrBlockNotFound is returned when a transaction operation references a + // block height that does not exist in the shared blocks table. + ErrBlockNotFound = errors.New("block not found") + + // ErrBlockMismatch is returned when a transaction operation references a + // block height whose stored hash or timestamp does not match the supplied + // block metadata. + ErrBlockMismatch = errors.New("block metadata mismatch") + // ErrUtxoNotFound is returned when a UTXO is not found in the database. ErrUtxoNotFound = errors.New("utxo not found") // ErrTxInputConflict is returned when CreateTx references a wallet-owned - // input that is already spent by another live wallet transaction. + // input that is already claimed by another recorded wallet spend. ErrTxInputConflict = errors.New( - "transaction input conflicts with live wallet spend", + "transaction input conflicts with another wallet spend", + ) + + // ErrTxInputInvalidParent is returned when CreateTx references a wallet- + // owned input whose parent transaction is already invalid. + ErrTxInputInvalidParent = errors.New( + "transaction input spends wallet output with invalid parent", ) ) @@ -259,15 +278,17 @@ type TxStore interface { // choice explicitly in CreateTxParams. CreateTx(ctx context.Context, params CreateTxParams) error - // UpdateTxLabel updates an existing transaction record in the database. It - // takes a context and UpdateTxLabelParams, returning an error if the - // transaction cannot be found or updated. + // UpdateTx patches the mutable metadata for one existing wallet-scoped + // transaction record. + // + // UpdateTx can update the user-visible label, the chain-state view + // (block/status), or both in one atomic write. It never rewrites + // immutable transaction facts such as the serialized transaction bytes, + // created credits, or spent-input edges. // - // UpdateTxLabel is intentionally narrow: it only updates the user-visible - // label. Block assignment, rollback, and status transitions belong to - // dedicated internal queries used by wallet synchronization and - // replacement handling. - UpdateTxLabel(ctx context.Context, params UpdateTxLabelParams) error + // UpdateTx is the only public tx-store API that may attach, replace, or + // clear confirming block metadata. + UpdateTx(ctx context.Context, params UpdateTxParams) error // GetTx retrieves a transaction record by its hash. It takes a context // and GetTxQuery, returning a TxInfo struct or an error if the From 062a24190ea05da5dddf7340eeac349474d2c7c5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:57:38 +0800 Subject: [PATCH 468/691] wallet: add `HasInvalidWalletUtxoByOutpoint` queries Add the wallet-owned invalid-parent lookup used by CreateTx to reject child spends of outputs whose parent transaction is already invalid. Keep the handwritten SQL and regenerated sqlc bindings together so the query contract stays reviewable in one place. --- wallet/internal/db/queries/postgres/utxos.sql | 24 ++++++++++++ wallet/internal/db/queries/sqlite/utxos.sql | 24 ++++++++++++ wallet/internal/db/sqlc/postgres/db.go | 10 +++++ wallet/internal/db/sqlc/postgres/querier.go | 12 ++++++ wallet/internal/db/sqlc/postgres/utxos.sql.go | 39 +++++++++++++++++++ wallet/internal/db/sqlc/sqlite/db.go | 10 +++++ wallet/internal/db/sqlc/sqlite/querier.go | 12 ++++++ wallet/internal/db/sqlc/sqlite/utxos.sql.go | 39 +++++++++++++++++++ 8 files changed, 170 insertions(+) diff --git a/wallet/internal/db/queries/postgres/utxos.sql b/wallet/internal/db/queries/postgres/utxos.sql index e8b7557852..140d4b610a 100644 --- a/wallet/internal/db/queries/postgres/utxos.sql +++ b/wallet/internal/db/queries/postgres/utxos.sql @@ -298,6 +298,30 @@ WHERE AND u.output_index = $3 AND t.tx_status IN (0, 1); +-- name: HasInvalidWalletUtxoByOutpoint :one +-- Reports whether an outpoint belongs to a wallet-owned UTXO whose parent +-- transaction is already invalid. +-- +-- How: +-- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks +-- for any status outside `pending`/`published`. +-- - Exists so CreateTx can reject children of wallet-owned outputs whose +-- parent transaction is already invalid. +-- Performance: +-- - Targets one wallet-scoped outpoint through the parent tx lookup plus the +-- unique `(tx_id, output_index)` key. +SELECT exists( + SELECT 1 + FROM utxos AS u + INNER JOIN transactions AS t + ON u.tx_id = t.id + WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND t.tx_status NOT IN (0, 1) +) AS has_invalid; + -- name: MarkUtxoSpent :execrows -- Marks a wallet-owned UTXO as spent by a transaction. -- diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/db/queries/sqlite/utxos.sql index 04534606e5..e361e37804 100644 --- a/wallet/internal/db/queries/sqlite/utxos.sql +++ b/wallet/internal/db/queries/sqlite/utxos.sql @@ -303,6 +303,30 @@ WHERE AND utxos.output_index = ?3 AND t.tx_status IN (0, 1); +-- name: HasInvalidWalletUtxoByOutpoint :one +-- Reports whether an outpoint belongs to a wallet-owned UTXO whose parent +-- transaction is already invalid. +-- +-- How: +-- - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks +-- for any status outside `pending`/`published`. +-- - Exists so CreateTx can reject children of wallet-owned outputs whose +-- parent transaction is already invalid. +-- Performance: +-- - Targets one wallet-scoped outpoint through the parent tx lookup plus the +-- unique `(tx_id, output_index)` key. +SELECT cast(EXISTS ( + SELECT 1 + FROM utxos + INNER JOIN transactions AS t + ON utxos.tx_id = t.id + WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND utxos.output_index = ?3 + AND t.tx_status NOT IN (0, 1) +) AS BOOLEAN) AS has_invalid; + -- name: MarkUtxoSpent :execrows -- Marks a wallet-owned UTXO as spent by a transaction. -- diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 2bd999a7bc..98386d7c7d 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -150,6 +150,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) } + if q.hasInvalidWalletUtxoByOutpointStmt, err = db.PrepareContext(ctx, HasInvalidWalletUtxoByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query HasInvalidWalletUtxoByOutpoint: %w", err) + } if q.insertAddressSecretStmt, err = db.PrepareContext(ctx, InsertAddressSecret); err != nil { return nil, fmt.Errorf("error preparing query InsertAddressSecret: %w", err) } @@ -476,6 +479,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) } } + if q.hasInvalidWalletUtxoByOutpointStmt != nil { + if cerr := q.hasInvalidWalletUtxoByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing hasInvalidWalletUtxoByOutpointStmt: %w", cerr) + } + } if q.insertAddressSecretStmt != nil { if cerr := q.insertAddressSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertAddressSecretStmt: %w", cerr) @@ -742,6 +750,7 @@ type Queries struct { getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt + hasInvalidWalletUtxoByOutpointStmt *sql.Stmt insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt @@ -827,6 +836,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, + hasInvalidWalletUtxoByOutpointStmt: q.hasInvalidWalletUtxoByOutpointStmt, insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 3f54632a62..fdc3eb100b 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -245,6 +245,18 @@ type Querier interface { GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) + // Reports whether an outpoint belongs to a wallet-owned UTXO whose parent + // transaction is already invalid. + // + // How: + // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks + // for any status outside `pending`/`published`. + // - Exists so CreateTx can reject children of wallet-owned outputs whose + // parent transaction is already invalid. + // Performance: + // - Targets one wallet-scoped outpoint through the parent tx lookup plus the + // unique `(tx_id, output_index)` key. + HasInvalidWalletUtxoByOutpoint(ctx context.Context, arg HasInvalidWalletUtxoByOutpointParams) (bool, error) // Inserts address secret information (private key, script) for imported addresses. // Not used for derived addresses (their keys are derived from account key). InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error diff --git a/wallet/internal/db/sqlc/postgres/utxos.sql.go b/wallet/internal/db/sqlc/postgres/utxos.sql.go index 91673e43fd..7eb9ab54c9 100644 --- a/wallet/internal/db/sqlc/postgres/utxos.sql.go +++ b/wallet/internal/db/sqlc/postgres/utxos.sql.go @@ -347,6 +347,45 @@ func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendBy return spent_by_tx_id, err } +const HasInvalidWalletUtxoByOutpoint = `-- name: HasInvalidWalletUtxoByOutpoint :one +SELECT exists( + SELECT 1 + FROM utxos AS u + INNER JOIN transactions AS t + ON u.tx_id = t.id + WHERE + t.wallet_id = $1 + AND t.tx_hash = $2 + AND u.output_index = $3 + AND t.tx_status NOT IN (0, 1) +) AS has_invalid +` + +type HasInvalidWalletUtxoByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int32 +} + +// Reports whether an outpoint belongs to a wallet-owned UTXO whose parent +// transaction is already invalid. +// +// How: +// - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks +// for any status outside `pending`/`published`. +// - Exists so CreateTx can reject children of wallet-owned outputs whose +// parent transaction is already invalid. +// +// Performance: +// - Targets one wallet-scoped outpoint through the parent tx lookup plus the +// unique `(tx_id, output_index)` key. +func (q *Queries) HasInvalidWalletUtxoByOutpoint(ctx context.Context, arg HasInvalidWalletUtxoByOutpointParams) (bool, error) { + row := q.queryRow(ctx, q.hasInvalidWalletUtxoByOutpointStmt, HasInvalidWalletUtxoByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var has_invalid bool + err := row.Scan(&has_invalid) + return has_invalid, err +} + const InsertUtxo = `-- name: InsertUtxo :one INSERT INTO utxos ( tx_id, diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 62018917a4..b418813772 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -150,6 +150,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletSecretsStmt, err = db.PrepareContext(ctx, GetWalletSecrets); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSecrets: %w", err) } + if q.hasInvalidWalletUtxoByOutpointStmt, err = db.PrepareContext(ctx, HasInvalidWalletUtxoByOutpoint); err != nil { + return nil, fmt.Errorf("error preparing query HasInvalidWalletUtxoByOutpoint: %w", err) + } if q.insertAddressSecretStmt, err = db.PrepareContext(ctx, InsertAddressSecret); err != nil { return nil, fmt.Errorf("error preparing query InsertAddressSecret: %w", err) } @@ -473,6 +476,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletSecretsStmt: %w", cerr) } } + if q.hasInvalidWalletUtxoByOutpointStmt != nil { + if cerr := q.hasInvalidWalletUtxoByOutpointStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing hasInvalidWalletUtxoByOutpointStmt: %w", cerr) + } + } if q.insertAddressSecretStmt != nil { if cerr := q.insertAddressSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing insertAddressSecretStmt: %w", cerr) @@ -734,6 +742,7 @@ type Queries struct { getWalletByIDStmt *sql.Stmt getWalletByNameStmt *sql.Stmt getWalletSecretsStmt *sql.Stmt + hasInvalidWalletUtxoByOutpointStmt *sql.Stmt insertAddressSecretStmt *sql.Stmt insertBlockStmt *sql.Stmt insertKeyScopeSecretsStmt *sql.Stmt @@ -818,6 +827,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getWalletByIDStmt: q.getWalletByIDStmt, getWalletByNameStmt: q.getWalletByNameStmt, getWalletSecretsStmt: q.getWalletSecretsStmt, + hasInvalidWalletUtxoByOutpointStmt: q.hasInvalidWalletUtxoByOutpointStmt, insertAddressSecretStmt: q.insertAddressSecretStmt, insertBlockStmt: q.insertBlockStmt, insertKeyScopeSecretsStmt: q.insertKeyScopeSecretsStmt, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index d73b514a43..f3c5f3cac1 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -242,6 +242,18 @@ type Querier interface { GetWalletByID(ctx context.Context, id int64) (GetWalletByIDRow, error) GetWalletByName(ctx context.Context, walletName string) (GetWalletByNameRow, error) GetWalletSecrets(ctx context.Context, walletID int64) (WalletSecret, error) + // Reports whether an outpoint belongs to a wallet-owned UTXO whose parent + // transaction is already invalid. + // + // How: + // - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks + // for any status outside `pending`/`published`. + // - Exists so CreateTx can reject children of wallet-owned outputs whose + // parent transaction is already invalid. + // Performance: + // - Targets one wallet-scoped outpoint through the parent tx lookup plus the + // unique `(tx_id, output_index)` key. + HasInvalidWalletUtxoByOutpoint(ctx context.Context, arg HasInvalidWalletUtxoByOutpointParams) (bool, error) // Inserts address secret information (private key, script) for imported addresses. // Not used for derived addresses (their keys are derived from account key). InsertAddressSecret(ctx context.Context, arg InsertAddressSecretParams) error diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/db/sqlc/sqlite/utxos.sql.go index f7279ae252..6c41db8c2c 100644 --- a/wallet/internal/db/sqlc/sqlite/utxos.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxos.sql.go @@ -352,6 +352,45 @@ func (q *Queries) GetUtxoSpendByOutpoint(ctx context.Context, arg GetUtxoSpendBy return spent_by_tx_id, err } +const HasInvalidWalletUtxoByOutpoint = `-- name: HasInvalidWalletUtxoByOutpoint :one +SELECT cast(EXISTS ( + SELECT 1 + FROM utxos + INNER JOIN transactions AS t + ON utxos.tx_id = t.id + WHERE + t.wallet_id = ?1 + AND t.tx_hash = ?2 + AND utxos.output_index = ?3 + AND t.tx_status NOT IN (0, 1) +) AS BOOLEAN) AS has_invalid +` + +type HasInvalidWalletUtxoByOutpointParams struct { + WalletID int64 + TxHash []byte + OutputIndex int64 +} + +// Reports whether an outpoint belongs to a wallet-owned UTXO whose parent +// transaction is already invalid. +// +// How: +// - Resolves the parent transaction row from `(wallet_id, tx_hash)` and checks +// for any status outside `pending`/`published`. +// - Exists so CreateTx can reject children of wallet-owned outputs whose +// parent transaction is already invalid. +// +// Performance: +// - Targets one wallet-scoped outpoint through the parent tx lookup plus the +// unique `(tx_id, output_index)` key. +func (q *Queries) HasInvalidWalletUtxoByOutpoint(ctx context.Context, arg HasInvalidWalletUtxoByOutpointParams) (bool, error) { + row := q.queryRow(ctx, q.hasInvalidWalletUtxoByOutpointStmt, HasInvalidWalletUtxoByOutpoint, arg.WalletID, arg.TxHash, arg.OutputIndex) + var has_invalid bool + err := row.Scan(&has_invalid) + return has_invalid, err +} + const InsertUtxo = `-- name: InsertUtxo :one INSERT INTO utxos ( tx_id, From 1d8e8edb1d7d566e0827d2172e5489651d257f8b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 16:54:03 +0800 Subject: [PATCH 469/691] wallet: add `UpdateTransactionStateByHash` queries Replace the narrow confirmation helper query with a generic transaction-state update query that can patch block assignment and status together. Keep the handwritten SQL and regenerated sqlc bindings together so the new UpdateTx query contract is reviewed in one commit. --- .../db/queries/postgres/transactions.sql | 23 ++--- .../db/queries/sqlite/transactions.sql | 23 ++--- wallet/internal/db/sqlc/postgres/db.go | 20 ++--- wallet/internal/db/sqlc/postgres/querier.go | 31 ++++--- .../db/sqlc/postgres/transactions.sql.go | 87 ++++++++++--------- wallet/internal/db/sqlc/sqlite/db.go | 20 ++--- wallet/internal/db/sqlc/sqlite/querier.go | 31 ++++--- .../db/sqlc/sqlite/transactions.sql.go | 87 ++++++++++--------- 8 files changed, 176 insertions(+), 146 deletions(-) diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/db/queries/postgres/transactions.sql index 7b09412ab1..06b7796a05 100644 --- a/wallet/internal/db/queries/postgres/transactions.sql +++ b/wallet/internal/db/queries/postgres/transactions.sql @@ -143,25 +143,26 @@ WHERE wallet_id = sqlc.arg('wallet_id') AND tx_hash = sqlc.arg('tx_hash'); --- name: ConfirmUnminedTransactionByHash :execrows --- Attaches a confirming block to one existing live unmined transaction row. +-- name: UpdateTransactionStateByHash :execrows +-- Updates the stored block assignment and wallet-relative status for one +-- transaction row. -- -- How: --- - Updates only rows that are still blockless and live (`pending` or --- `published`). --- - Leaves user-visible metadata such as labels untouched so confirmation can --- reuse the original transaction row instead of reinserting it. +-- - Leaves immutable transaction facts such as `raw_tx`, credits, and spent +-- inputs untouched. +-- - Leaves the user-visible label untouched so callers can patch label and +-- state independently or together inside one SQL transaction. +-- - Expects callers to validate any required block reference and state +-- invariants before issuing the update. -- Performance: -- - Updates at most one row through the wallet-scoped unique tx-hash lookup. UPDATE transactions SET - block_height = sqlc.arg('block_height')::INTEGER, - tx_status = 1 + block_height = sqlc.narg('block_height')::INTEGER, + tx_status = sqlc.arg('status') WHERE wallet_id = sqlc.arg('wallet_id') - AND tx_hash = sqlc.arg('tx_hash') - AND block_height IS NULL - AND tx_status IN (0, 1); + AND tx_hash = sqlc.arg('tx_hash'); -- name: UpdateTransactionStatusByIDs :execrows -- Updates the wallet-relative status for a set of transaction row IDs. diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/db/queries/sqlite/transactions.sql index a5663a4be6..ee9a0b5fde 100644 --- a/wallet/internal/db/queries/sqlite/transactions.sql +++ b/wallet/internal/db/queries/sqlite/transactions.sql @@ -143,25 +143,26 @@ WHERE wallet_id = sqlc.arg('wallet_id') AND tx_hash = sqlc.arg('tx_hash'); --- name: ConfirmUnminedTransactionByHash :execrows --- Attaches a confirming block to one existing live unmined transaction row. +-- name: UpdateTransactionStateByHash :execrows +-- Updates the stored block assignment and wallet-relative status for one +-- transaction row. -- -- How: --- - Updates only rows that are still blockless and live (`pending` or --- `published`). --- - Leaves user-visible metadata such as labels untouched so confirmation can --- reuse the original transaction row instead of reinserting it. +-- - Leaves immutable transaction facts such as `raw_tx`, credits, and spent +-- inputs untouched. +-- - Leaves the user-visible label untouched so callers can patch label and +-- state independently or together inside one SQL transaction. +-- - Expects callers to validate any required block reference and state +-- invariants before issuing the update. -- Performance: -- - Updates at most one row through the wallet-scoped unique tx-hash lookup. UPDATE transactions SET - block_height = cast(sqlc.arg('block_height') AS INTEGER), - tx_status = 1 + block_height = cast(sqlc.narg('block_height') AS INTEGER), + tx_status = sqlc.arg('status') WHERE wallet_id = sqlc.arg('wallet_id') - AND tx_hash = sqlc.arg('tx_hash') - AND block_height IS NULL - AND tx_status IN (0, 1); + AND tx_hash = sqlc.arg('tx_hash'); -- name: UpdateTransactionStatusByIDs :execrows -- Updates the wallet-relative status for a set of transaction row IDs. diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 98386d7c7d..8c0fcbcf38 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -33,9 +33,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.clearUtxosSpentByTxIDStmt, err = db.PrepareContext(ctx, ClearUtxosSpentByTxID); err != nil { return nil, fmt.Errorf("error preparing query ClearUtxosSpentByTxID: %w", err) } - if q.confirmUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, ConfirmUnminedTransactionByHash); err != nil { - return nil, fmt.Errorf("error preparing query ConfirmUnminedTransactionByHash: %w", err) - } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } @@ -255,6 +252,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateTransactionLabelByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionLabelByHash); err != nil { return nil, fmt.Errorf("error preparing query UpdateTransactionLabelByHash: %w", err) } + if q.updateTransactionStateByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionStateByHash); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionStateByHash: %w", err) + } if q.updateTransactionStatusByIDsStmt, err = db.PrepareContext(ctx, UpdateTransactionStatusByIDs); err != nil { return nil, fmt.Errorf("error preparing query UpdateTransactionStatusByIDs: %w", err) } @@ -284,11 +284,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing clearUtxosSpentByTxIDStmt: %w", cerr) } } - if q.confirmUnminedTransactionByHashStmt != nil { - if cerr := q.confirmUnminedTransactionByHashStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing confirmUnminedTransactionByHashStmt: %w", cerr) - } - } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -654,6 +649,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateTransactionLabelByHashStmt: %w", cerr) } } + if q.updateTransactionStateByHashStmt != nil { + if cerr := q.updateTransactionStateByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionStateByHashStmt: %w", cerr) + } + } if q.updateTransactionStatusByIDsStmt != nil { if cerr := q.updateTransactionStatusByIDsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateTransactionStatusByIDsStmt: %w", cerr) @@ -711,7 +711,6 @@ type Queries struct { acquireUtxoLeaseStmt *sql.Stmt balanceStmt *sql.Stmt clearUtxosSpentByTxIDStmt *sql.Stmt - confirmUnminedTransactionByHashStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt @@ -785,6 +784,7 @@ type Queries struct { updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateTransactionLabelByHashStmt *sql.Stmt + updateTransactionStateByHashStmt *sql.Stmt updateTransactionStatusByIDsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt @@ -797,7 +797,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, balanceStmt: q.balanceStmt, clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, - confirmUnminedTransactionByHashStmt: q.confirmUnminedTransactionByHashStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, @@ -871,6 +870,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateTransactionLabelByHashStmt: q.updateTransactionLabelByHashStmt, + updateTransactionStateByHashStmt: q.updateTransactionStateByHashStmt, updateTransactionStatusByIDsStmt: q.updateTransactionStatusByIDsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index fdc3eb100b..9c9a9ff2a4 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -64,16 +64,6 @@ type Querier interface { // - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet // ownership through the creating transaction. ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) - // Attaches a confirming block to one existing live unmined transaction row. - // - // How: - // - Updates only rows that are still blockless and live (`pending` or - // `published`). - // - Leaves user-visible metadata such as labels untouched so confirmation can - // reuse the original transaction row instead of reinserting it. - // Performance: - // - Updates at most one row through the wallet-scoped unique tx-hash lookup. - ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error // Creates a new derived account under the given scope, computing the next @@ -268,13 +258,15 @@ type Querier interface { // // How: // - Writes only the transactions table. - // - Expects the caller to have already resolved wallet scope and any optional - // block reference. + // - Expects the caller to have already resolved wallet scope. + // - Inserts one row with no confirming block by storing `NULL` in + // `block_height`. Later block assignment belongs to the state-update query + // below. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness - // checks and any optional block foreign-key validation. + // checks. InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) // Records a replacement edge between two wallet-scoped transactions. // @@ -528,6 +520,19 @@ type Querier interface { // Performance: // - Updates at most one row through the wallet-scoped unique tx-hash lookup. UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) + // Updates the stored block assignment and wallet-relative status for one + // transaction row. + // + // How: + // - Leaves immutable transaction facts such as `raw_tx`, credits, and spent + // inputs untouched. + // - Leaves the user-visible label untouched so callers can patch label and + // state independently or together inside one SQL transaction. + // - Expects callers to validate any required block reference and state + // invariants before issuing the update. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + UpdateTransactionStateByHash(ctx context.Context, arg UpdateTransactionStateByHashParams) (int64, error) // Updates the wallet-relative status for a set of transaction row IDs. // // How: diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/db/sqlc/postgres/transactions.sql.go index ee87bb71e0..90bb64d9ea 100644 --- a/wallet/internal/db/sqlc/postgres/transactions.sql.go +++ b/wallet/internal/db/sqlc/postgres/transactions.sql.go @@ -13,42 +13,6 @@ import ( "github.com/lib/pq" ) -const ConfirmUnminedTransactionByHash = `-- name: ConfirmUnminedTransactionByHash :execrows -UPDATE transactions -SET - block_height = $1::INTEGER, - tx_status = 1 -WHERE - wallet_id = $2 - AND tx_hash = $3 - AND block_height IS NULL - AND tx_status IN (0, 1) -` - -type ConfirmUnminedTransactionByHashParams struct { - BlockHeight int32 - WalletID int64 - TxHash []byte -} - -// Attaches a confirming block to one existing live unmined transaction row. -// -// How: -// - Updates only rows that are still blockless and live (`pending` or -// `published`). -// - Leaves user-visible metadata such as labels untouched so confirmation can -// reuse the original transaction row instead of reinserting it. -// -// Performance: -// - Updates at most one row through the wallet-scoped unique tx-hash lookup. -func (q *Queries) ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) { - result, err := q.exec(ctx, q.confirmUnminedTransactionByHashStmt, ConfirmUnminedTransactionByHash, arg.BlockHeight, arg.WalletID, arg.TxHash) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - const DeleteBlocksAtOrAboveHeight = `-- name: DeleteBlocksAtOrAboveHeight :execrows DELETE FROM blocks WHERE block_height >= $1 @@ -243,14 +207,16 @@ type InsertTransactionParams struct { // // How: // - Writes only the transactions table. -// - Expects the caller to have already resolved wallet scope and any optional -// block reference. +// - Expects the caller to have already resolved wallet scope. +// - Inserts one row with no confirming block by storing `NULL` in +// `block_height`. Later block assignment belongs to the state-update query +// below. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness -// checks and any optional block foreign-key validation. +// checks. func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, arg.WalletID, @@ -573,6 +539,49 @@ func (q *Queries) UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTr return result.RowsAffected() } +const UpdateTransactionStateByHash = `-- name: UpdateTransactionStateByHash :execrows +UPDATE transactions +SET + block_height = $1::INTEGER, + tx_status = $2 +WHERE + wallet_id = $3 + AND tx_hash = $4 +` + +type UpdateTransactionStateByHashParams struct { + BlockHeight sql.NullInt32 + Status int16 + WalletID int64 + TxHash []byte +} + +// Updates the stored block assignment and wallet-relative status for one +// transaction row. +// +// How: +// - Leaves immutable transaction facts such as `raw_tx`, credits, and spent +// inputs untouched. +// - Leaves the user-visible label untouched so callers can patch label and +// state independently or together inside one SQL transaction. +// - Expects callers to validate any required block reference and state +// invariants before issuing the update. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) UpdateTransactionStateByHash(ctx context.Context, arg UpdateTransactionStateByHashParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionStateByHashStmt, UpdateTransactionStateByHash, + arg.BlockHeight, + arg.Status, + arg.WalletID, + arg.TxHash, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const UpdateTransactionStatusByIDs = `-- name: UpdateTransactionStatusByIDs :execrows UPDATE transactions SET tx_status = $1 diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index b418813772..71d4200ff6 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -33,9 +33,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.clearUtxosSpentByTxIDStmt, err = db.PrepareContext(ctx, ClearUtxosSpentByTxID); err != nil { return nil, fmt.Errorf("error preparing query ClearUtxosSpentByTxID: %w", err) } - if q.confirmUnminedTransactionByHashStmt, err = db.PrepareContext(ctx, ConfirmUnminedTransactionByHash); err != nil { - return nil, fmt.Errorf("error preparing query ConfirmUnminedTransactionByHash: %w", err) - } if q.createAccountSecretStmt, err = db.PrepareContext(ctx, CreateAccountSecret); err != nil { return nil, fmt.Errorf("error preparing query CreateAccountSecret: %w", err) } @@ -252,6 +249,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateTransactionLabelByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionLabelByHash); err != nil { return nil, fmt.Errorf("error preparing query UpdateTransactionLabelByHash: %w", err) } + if q.updateTransactionStateByHashStmt, err = db.PrepareContext(ctx, UpdateTransactionStateByHash); err != nil { + return nil, fmt.Errorf("error preparing query UpdateTransactionStateByHash: %w", err) + } if q.updateTransactionStatusByIDsStmt, err = db.PrepareContext(ctx, UpdateTransactionStatusByIDs); err != nil { return nil, fmt.Errorf("error preparing query UpdateTransactionStatusByIDs: %w", err) } @@ -281,11 +281,6 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing clearUtxosSpentByTxIDStmt: %w", cerr) } } - if q.confirmUnminedTransactionByHashStmt != nil { - if cerr := q.confirmUnminedTransactionByHashStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing confirmUnminedTransactionByHashStmt: %w", cerr) - } - } if q.createAccountSecretStmt != nil { if cerr := q.createAccountSecretStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createAccountSecretStmt: %w", cerr) @@ -646,6 +641,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateTransactionLabelByHashStmt: %w", cerr) } } + if q.updateTransactionStateByHashStmt != nil { + if cerr := q.updateTransactionStateByHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateTransactionStateByHashStmt: %w", cerr) + } + } if q.updateTransactionStatusByIDsStmt != nil { if cerr := q.updateTransactionStatusByIDsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateTransactionStatusByIDsStmt: %w", cerr) @@ -703,7 +703,6 @@ type Queries struct { acquireUtxoLeaseStmt *sql.Stmt balanceStmt *sql.Stmt clearUtxosSpentByTxIDStmt *sql.Stmt - confirmUnminedTransactionByHashStmt *sql.Stmt createAccountSecretStmt *sql.Stmt createDerivedAccountStmt *sql.Stmt createDerivedAccountWithNumberStmt *sql.Stmt @@ -776,6 +775,7 @@ type Queries struct { updateAccountNameByWalletScopeAndNameStmt *sql.Stmt updateAccountNameByWalletScopeAndNumberStmt *sql.Stmt updateTransactionLabelByHashStmt *sql.Stmt + updateTransactionStateByHashStmt *sql.Stmt updateTransactionStatusByIDsStmt *sql.Stmt updateWalletSecretsStmt *sql.Stmt updateWalletSyncStateStmt *sql.Stmt @@ -788,7 +788,6 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { acquireUtxoLeaseStmt: q.acquireUtxoLeaseStmt, balanceStmt: q.balanceStmt, clearUtxosSpentByTxIDStmt: q.clearUtxosSpentByTxIDStmt, - confirmUnminedTransactionByHashStmt: q.confirmUnminedTransactionByHashStmt, createAccountSecretStmt: q.createAccountSecretStmt, createDerivedAccountStmt: q.createDerivedAccountStmt, createDerivedAccountWithNumberStmt: q.createDerivedAccountWithNumberStmt, @@ -861,6 +860,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { updateAccountNameByWalletScopeAndNameStmt: q.updateAccountNameByWalletScopeAndNameStmt, updateAccountNameByWalletScopeAndNumberStmt: q.updateAccountNameByWalletScopeAndNumberStmt, updateTransactionLabelByHashStmt: q.updateTransactionLabelByHashStmt, + updateTransactionStateByHashStmt: q.updateTransactionStateByHashStmt, updateTransactionStatusByIDsStmt: q.updateTransactionStatusByIDsStmt, updateWalletSecretsStmt: q.updateWalletSecretsStmt, updateWalletSyncStateStmt: q.updateWalletSyncStateStmt, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index f3c5f3cac1..9f422a4ab0 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -64,16 +64,6 @@ type Querier interface { // - Uses the `(spent_by_tx_id)` index to find affected rows and rechecks wallet // ownership through the creating transaction. ClearUtxosSpentByTxID(ctx context.Context, arg ClearUtxosSpentByTxIDParams) (int64, error) - // Attaches a confirming block to one existing live unmined transaction row. - // - // How: - // - Updates only rows that are still blockless and live (`pending` or - // `published`). - // - Leaves user-visible metadata such as labels untouched so confirmation can - // reuse the original transaction row instead of reinserting it. - // Performance: - // - Updates at most one row through the wallet-scoped unique tx-hash lookup. - ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) // Inserts the encrypted private key material for an account. CreateAccountSecret(ctx context.Context, arg CreateAccountSecretParams) error // Creates a new derived account under the given scope, computing the next @@ -265,13 +255,15 @@ type Querier interface { // // How: // - Writes only the transactions table. - // - Expects the caller to have already resolved wallet scope and any optional - // block reference. + // - Expects the caller to have already resolved wallet scope. + // - Inserts one row with no confirming block by storing `NULL` in + // `block_height`. Later block assignment belongs to the state-update query + // below. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness - // checks and any optional block foreign-key validation. + // checks. InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) // Records a replacement edge between two wallet-scoped transactions. // @@ -500,6 +492,19 @@ type Querier interface { // Performance: // - Updates at most one row through the wallet-scoped unique tx-hash lookup. UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTransactionLabelByHashParams) (int64, error) + // Updates the stored block assignment and wallet-relative status for one + // transaction row. + // + // How: + // - Leaves immutable transaction facts such as `raw_tx`, credits, and spent + // inputs untouched. + // - Leaves the user-visible label untouched so callers can patch label and + // state independently or together inside one SQL transaction. + // - Expects callers to validate any required block reference and state + // invariants before issuing the update. + // Performance: + // - Updates at most one row through the wallet-scoped unique tx-hash lookup. + UpdateTransactionStateByHash(ctx context.Context, arg UpdateTransactionStateByHashParams) (int64, error) // Updates the wallet-relative status for a set of transaction row IDs. // // How: diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/db/sqlc/sqlite/transactions.sql.go index efb46a4032..4067594bec 100644 --- a/wallet/internal/db/sqlc/sqlite/transactions.sql.go +++ b/wallet/internal/db/sqlc/sqlite/transactions.sql.go @@ -12,42 +12,6 @@ import ( "time" ) -const ConfirmUnminedTransactionByHash = `-- name: ConfirmUnminedTransactionByHash :execrows -UPDATE transactions -SET - block_height = cast(?1 AS INTEGER), - tx_status = 1 -WHERE - wallet_id = ?2 - AND tx_hash = ?3 - AND block_height IS NULL - AND tx_status IN (0, 1) -` - -type ConfirmUnminedTransactionByHashParams struct { - BlockHeight int64 - WalletID int64 - TxHash []byte -} - -// Attaches a confirming block to one existing live unmined transaction row. -// -// How: -// - Updates only rows that are still blockless and live (`pending` or -// `published`). -// - Leaves user-visible metadata such as labels untouched so confirmation can -// reuse the original transaction row instead of reinserting it. -// -// Performance: -// - Updates at most one row through the wallet-scoped unique tx-hash lookup. -func (q *Queries) ConfirmUnminedTransactionByHash(ctx context.Context, arg ConfirmUnminedTransactionByHashParams) (int64, error) { - result, err := q.exec(ctx, q.confirmUnminedTransactionByHashStmt, ConfirmUnminedTransactionByHash, arg.BlockHeight, arg.WalletID, arg.TxHash) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - const DeleteBlocksAtOrAboveHeight = `-- name: DeleteBlocksAtOrAboveHeight :execrows DELETE FROM blocks WHERE block_height >= ? @@ -242,14 +206,16 @@ type InsertTransactionParams struct { // // How: // - Writes only the transactions table. -// - Expects the caller to have already resolved wallet scope and any optional -// block reference. +// - Expects the caller to have already resolved wallet scope. +// - Inserts one row with no confirming block by storing `NULL` in +// `block_height`. Later block assignment belongs to the state-update query +// below. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness -// checks and any optional block foreign-key validation. +// checks. func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, arg.WalletID, @@ -572,6 +538,49 @@ func (q *Queries) UpdateTransactionLabelByHash(ctx context.Context, arg UpdateTr return result.RowsAffected() } +const UpdateTransactionStateByHash = `-- name: UpdateTransactionStateByHash :execrows +UPDATE transactions +SET + block_height = cast(?1 AS INTEGER), + tx_status = ?2 +WHERE + wallet_id = ?3 + AND tx_hash = ?4 +` + +type UpdateTransactionStateByHashParams struct { + BlockHeight sql.NullInt64 + Status int64 + WalletID int64 + TxHash []byte +} + +// Updates the stored block assignment and wallet-relative status for one +// transaction row. +// +// How: +// - Leaves immutable transaction facts such as `raw_tx`, credits, and spent +// inputs untouched. +// - Leaves the user-visible label untouched so callers can patch label and +// state independently or together inside one SQL transaction. +// - Expects callers to validate any required block reference and state +// invariants before issuing the update. +// +// Performance: +// - Updates at most one row through the wallet-scoped unique tx-hash lookup. +func (q *Queries) UpdateTransactionStateByHash(ctx context.Context, arg UpdateTransactionStateByHashParams) (int64, error) { + result, err := q.exec(ctx, q.updateTransactionStateByHashStmt, UpdateTransactionStateByHash, + arg.BlockHeight, + arg.Status, + arg.WalletID, + arg.TxHash, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const UpdateTransactionStatusByIDs = `-- name: UpdateTransactionStatusByIDs :execrows UPDATE transactions SET tx_status = ?1 From 56dac5acb98c38b69f60f6aca5e97bd11cd2a6a2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 14:04:33 +0800 Subject: [PATCH 470/691] wallet: add `ListTransactionsWithoutBlock` queries Add the dedicated no-block transaction query used by ListTxns when callers request wallet history without a confirming block. Keeping the handwritten SQL and regenerated sqlc bindings together makes the new read path reviewable without mixing in the Go listing logic. --- .../db/queries/postgres/transactions.sql | 30 +++++++ .../db/queries/sqlite/transactions.sql | 31 +++++++ wallet/internal/db/sqlc/postgres/db.go | 10 +++ wallet/internal/db/sqlc/postgres/querier.go | 21 +++-- .../db/sqlc/postgres/transactions.sql.go | 87 ++++++++++++++++-- wallet/internal/db/sqlc/sqlite/db.go | 10 +++ wallet/internal/db/sqlc/sqlite/querier.go | 21 +++-- .../db/sqlc/sqlite/transactions.sql.go | 88 +++++++++++++++++-- 8 files changed, 278 insertions(+), 20 deletions(-) diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/db/queries/postgres/transactions.sql index 06b7796a05..027553bdba 100644 --- a/wallet/internal/db/queries/postgres/transactions.sql +++ b/wallet/internal/db/queries/postgres/transactions.sql @@ -66,6 +66,36 @@ FROM transactions AS t LEFT JOIN blocks AS b ON t.block_height = b.block_height WHERE t.wallet_id = $1 AND t.tx_hash = $2; +-- name: ListTransactionsWithoutBlock :many +-- Lists every wallet transaction row that currently has no confirming block. +-- +-- How: +-- - Reads from transactions only and filters on rows with no confirming block. +-- - Includes the active unmined set (`pending` and `published`) together with +-- retained invalid history such as `failed`, `replaced`, or `orphaned` +-- rows. +-- - Returns typed NULL block metadata explicitly because unmined rows have no +-- block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +-- keep the row shape aligned with the confirmed query below. +-- Performance: +-- - Matches the dedicated no-confirming-block history index. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + NULL::BYTEA AS block_hash, + NULL::BIGINT AS block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.block_height IS NULL +ORDER BY t.received_time DESC, t.id DESC; + -- name: ListUnminedTransactions :many -- Lists the wallet transactions that still belong to the active unmined set. -- diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/db/queries/sqlite/transactions.sql index ee9a0b5fde..2b1b14f4e6 100644 --- a/wallet/internal/db/queries/sqlite/transactions.sql +++ b/wallet/internal/db/queries/sqlite/transactions.sql @@ -66,6 +66,37 @@ FROM transactions AS t LEFT JOIN blocks AS b ON t.block_height = b.block_height WHERE t.wallet_id = ? AND t.tx_hash = ?; +-- name: ListTransactionsWithoutBlock :many +-- Lists every wallet transaction row that currently has no confirming block. +-- +-- How: +-- - Reads from transactions only and filters on rows with no confirming block. +-- - Includes the active unmined set (`pending` and `published`) together with +-- retained invalid history such as `failed`, `replaced`, or `orphaned` +-- rows. +-- - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` +-- so sqlc preserves the nullable block columns while the row shape stays +-- aligned with the confirmed query below. +-- Performance: +-- - Matches the dedicated no-confirming-block history index. +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON 1 = 0 +WHERE + t.wallet_id = ? + AND t.block_height IS NULL +ORDER BY t.received_time DESC, t.id DESC; + -- name: ListUnminedTransactions :many -- Lists the wallet transactions that still belong to the active unmined set. -- diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/db/sqlc/postgres/db.go index 8c0fcbcf38..19e8837f3d 100644 --- a/wallet/internal/db/sqlc/postgres/db.go +++ b/wallet/internal/db/sqlc/postgres/db.go @@ -222,6 +222,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listTransactionsByHeightRangeStmt, err = db.PrepareContext(ctx, ListTransactionsByHeightRange); err != nil { return nil, fmt.Errorf("error preparing query ListTransactionsByHeightRange: %w", err) } + if q.listTransactionsWithoutBlockStmt, err = db.PrepareContext(ctx, ListTransactionsWithoutBlock); err != nil { + return nil, fmt.Errorf("error preparing query ListTransactionsWithoutBlock: %w", err) + } if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) } @@ -599,6 +602,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listTransactionsByHeightRangeStmt: %w", cerr) } } + if q.listTransactionsWithoutBlockStmt != nil { + if cerr := q.listTransactionsWithoutBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTransactionsWithoutBlockStmt: %w", cerr) + } + } if q.listUnminedTransactionsStmt != nil { if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) @@ -774,6 +782,7 @@ type Queries struct { listRollbackCoinbaseRootsStmt *sql.Stmt listSpendingTxIDsByParentTxIDStmt *sql.Stmt listTransactionsByHeightRangeStmt *sql.Stmt + listTransactionsWithoutBlockStmt *sql.Stmt listUnminedTransactionsStmt *sql.Stmt listUtxosStmt *sql.Stmt listWalletsStmt *sql.Stmt @@ -860,6 +869,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listRollbackCoinbaseRootsStmt: q.listRollbackCoinbaseRootsStmt, listSpendingTxIDsByParentTxIDStmt: q.listSpendingTxIDsByParentTxIDStmt, listTransactionsByHeightRangeStmt: q.listTransactionsByHeightRangeStmt, + listTransactionsWithoutBlockStmt: q.listTransactionsWithoutBlockStmt, listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, listUtxosStmt: q.listUtxosStmt, listWalletsStmt: q.listWalletsStmt, diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 9c9a9ff2a4..94d35afc83 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -258,15 +258,13 @@ type Querier interface { // // How: // - Writes only the transactions table. - // - Expects the caller to have already resolved wallet scope. - // - Inserts one row with no confirming block by storing `NULL` in - // `block_height`. Later block assignment belongs to the state-update query - // below. + // - Expects the caller to have already resolved wallet scope and any optional + // block reference. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness - // checks. + // checks and any optional block foreign-key validation. InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) // Records a replacement edge between two wallet-scoped transactions. // @@ -410,6 +408,19 @@ type Querier interface { // - The `(wallet_id, block_height)` index bounds the scan before the single-row // block join. ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) + // Lists every wallet transaction row that currently has no confirming block. + // + // How: + // - Reads from transactions only and filters on rows with no confirming block. + // - Includes the active unmined set (`pending` and `published`) together with + // retained invalid history such as `failed`, `replaced`, or `orphaned` + // rows. + // - Returns typed NULL block metadata explicitly because unmined rows have no + // block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` + // keep the row shape aligned with the confirmed query below. + // Performance: + // - Matches the dedicated no-confirming-block history index. + ListTransactionsWithoutBlock(ctx context.Context, walletID int64) ([]ListTransactionsWithoutBlockRow, error) // Lists the wallet transactions that still belong to the active unmined set. // // How: diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/db/sqlc/postgres/transactions.sql.go index 90bb64d9ea..c4406f509d 100644 --- a/wallet/internal/db/sqlc/postgres/transactions.sql.go +++ b/wallet/internal/db/sqlc/postgres/transactions.sql.go @@ -207,16 +207,14 @@ type InsertTransactionParams struct { // // How: // - Writes only the transactions table. -// - Expects the caller to have already resolved wallet scope. -// - Inserts one row with no confirming block by storing `NULL` in -// `block_height`. Later block assignment belongs to the state-update query -// below. +// - Expects the caller to have already resolved wallet scope and any optional +// block reference. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness -// checks. +// checks and any optional block foreign-key validation. func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, arg.WalletID, @@ -370,6 +368,85 @@ func (q *Queries) ListTransactionsByHeightRange(ctx context.Context, arg ListTra return items, nil } +const ListTransactionsWithoutBlock = `-- name: ListTransactionsWithoutBlock :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + NULL::BYTEA AS block_hash, + NULL::BIGINT AS block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +WHERE + t.wallet_id = $1 + AND t.block_height IS NULL +ORDER BY t.received_time DESC, t.id DESC +` + +type ListTransactionsWithoutBlockRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt32 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int16 + TxLabel string +} + +// Lists every wallet transaction row that currently has no confirming block. +// +// How: +// - Reads from transactions only and filters on rows with no confirming block. +// - Includes the active unmined set (`pending` and `published`) together with +// retained invalid history such as `failed`, `replaced`, or `orphaned` +// rows. +// - Returns typed NULL block metadata explicitly because unmined rows have no +// block. `NULL::BYTEA AS block_hash` and `NULL::BIGINT AS block_timestamp` +// keep the row shape aligned with the confirmed query below. +// +// Performance: +// - Matches the dedicated no-confirming-block history index. +func (q *Queries) ListTransactionsWithoutBlock(ctx context.Context, walletID int64) ([]ListTransactionsWithoutBlockRow, error) { + rows, err := q.query(ctx, q.listTransactionsWithoutBlockStmt, ListTransactionsWithoutBlock, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTransactionsWithoutBlockRow + for rows.Next() { + var i ListTransactionsWithoutBlockRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); 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 ListUnminedTransactions = `-- name: ListUnminedTransactions :many SELECT t.id, diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/db/sqlc/sqlite/db.go index 71d4200ff6..d5fa946fc3 100644 --- a/wallet/internal/db/sqlc/sqlite/db.go +++ b/wallet/internal/db/sqlc/sqlite/db.go @@ -222,6 +222,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listTransactionsByHeightRangeStmt, err = db.PrepareContext(ctx, ListTransactionsByHeightRange); err != nil { return nil, fmt.Errorf("error preparing query ListTransactionsByHeightRange: %w", err) } + if q.listTransactionsWithoutBlockStmt, err = db.PrepareContext(ctx, ListTransactionsWithoutBlock); err != nil { + return nil, fmt.Errorf("error preparing query ListTransactionsWithoutBlock: %w", err) + } if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) } @@ -596,6 +599,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listTransactionsByHeightRangeStmt: %w", cerr) } } + if q.listTransactionsWithoutBlockStmt != nil { + if cerr := q.listTransactionsWithoutBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listTransactionsWithoutBlockStmt: %w", cerr) + } + } if q.listUnminedTransactionsStmt != nil { if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) @@ -766,6 +774,7 @@ type Queries struct { listRollbackCoinbaseRootsStmt *sql.Stmt listSpendingTxIDsByParentTxIDStmt *sql.Stmt listTransactionsByHeightRangeStmt *sql.Stmt + listTransactionsWithoutBlockStmt *sql.Stmt listUnminedTransactionsStmt *sql.Stmt listUtxosStmt *sql.Stmt listWalletsStmt *sql.Stmt @@ -851,6 +860,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listRollbackCoinbaseRootsStmt: q.listRollbackCoinbaseRootsStmt, listSpendingTxIDsByParentTxIDStmt: q.listSpendingTxIDsByParentTxIDStmt, listTransactionsByHeightRangeStmt: q.listTransactionsByHeightRangeStmt, + listTransactionsWithoutBlockStmt: q.listTransactionsWithoutBlockStmt, listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, listUtxosStmt: q.listUtxosStmt, listWalletsStmt: q.listWalletsStmt, diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 9f422a4ab0..a7345d54e8 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -255,15 +255,13 @@ type Querier interface { // // How: // - Writes only the transactions table. - // - Expects the caller to have already resolved wallet scope. - // - Inserts one row with no confirming block by storing `NULL` in - // `block_height`. Later block assignment belongs to the state-update query - // below. + // - Expects the caller to have already resolved wallet scope and any optional + // block reference. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness - // checks. + // checks and any optional block foreign-key validation. InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) // Records a replacement edge between two wallet-scoped transactions. // @@ -406,6 +404,19 @@ type Querier interface { // - The `(wallet_id, block_height)` index bounds the scan before the single-row // block join. ListTransactionsByHeightRange(ctx context.Context, arg ListTransactionsByHeightRangeParams) ([]ListTransactionsByHeightRangeRow, error) + // Lists every wallet transaction row that currently has no confirming block. + // + // How: + // - Reads from transactions only and filters on rows with no confirming block. + // - Includes the active unmined set (`pending` and `published`) together with + // retained invalid history such as `failed`, `replaced`, or `orphaned` + // rows. + // - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` + // so sqlc preserves the nullable block columns while the row shape stays + // aligned with the confirmed query below. + // Performance: + // - Matches the dedicated no-confirming-block history index. + ListTransactionsWithoutBlock(ctx context.Context, walletID int64) ([]ListTransactionsWithoutBlockRow, error) // Lists the wallet transactions that still belong to the active unmined set. // // How: diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/db/sqlc/sqlite/transactions.sql.go index 4067594bec..07ef498aa1 100644 --- a/wallet/internal/db/sqlc/sqlite/transactions.sql.go +++ b/wallet/internal/db/sqlc/sqlite/transactions.sql.go @@ -206,16 +206,14 @@ type InsertTransactionParams struct { // // How: // - Writes only the transactions table. -// - Expects the caller to have already resolved wallet scope. -// - Inserts one row with no confirming block by storing `NULL` in -// `block_height`. Later block assignment belongs to the state-update query -// below. +// - Expects the caller to have already resolved wallet scope and any optional +// block reference. // - Expects the caller to supply the initial status explicitly so unmined rows // do not have to guess between `pending` and `published`. // // Performance: // - Single-row insert. The cost is dominated by the wallet/hash uniqueness -// checks. +// checks and any optional block foreign-key validation. func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) (int64, error) { row := q.queryRow(ctx, q.insertTransactionStmt, InsertTransaction, arg.WalletID, @@ -368,6 +366,86 @@ func (q *Queries) ListTransactionsByHeightRange(ctx context.Context, arg ListTra return items, nil } +const ListTransactionsWithoutBlock = `-- name: ListTransactionsWithoutBlock :many +SELECT + t.id, + t.tx_hash, + t.raw_tx, + t.received_time, + t.block_height, + b.header_hash AS block_hash, + b.block_timestamp, + t.is_coinbase, + t.tx_status, + t.tx_label +FROM transactions AS t +LEFT JOIN blocks AS b ON 1 = 0 +WHERE + t.wallet_id = ? + AND t.block_height IS NULL +ORDER BY t.received_time DESC, t.id DESC +` + +type ListTransactionsWithoutBlockRow struct { + ID int64 + TxHash []byte + RawTx []byte + ReceivedTime time.Time + BlockHeight sql.NullInt64 + BlockHash []byte + BlockTimestamp sql.NullInt64 + IsCoinbase bool + TxStatus int64 + TxLabel string +} + +// Lists every wallet transaction row that currently has no confirming block. +// +// How: +// - Reads from transactions only and filters on rows with no confirming block. +// - Includes the active unmined set (`pending` and `published`) together with +// retained invalid history such as `failed`, `replaced`, or `orphaned` +// rows. +// - Projects typed NULL block metadata through `LEFT JOIN blocks AS b ON 1 = 0` +// so sqlc preserves the nullable block columns while the row shape stays +// aligned with the confirmed query below. +// +// Performance: +// - Matches the dedicated no-confirming-block history index. +func (q *Queries) ListTransactionsWithoutBlock(ctx context.Context, walletID int64) ([]ListTransactionsWithoutBlockRow, error) { + rows, err := q.query(ctx, q.listTransactionsWithoutBlockStmt, ListTransactionsWithoutBlock, walletID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTransactionsWithoutBlockRow + for rows.Next() { + var i ListTransactionsWithoutBlockRow + if err := rows.Scan( + &i.ID, + &i.TxHash, + &i.RawTx, + &i.ReceivedTime, + &i.BlockHeight, + &i.BlockHash, + &i.BlockTimestamp, + &i.IsCoinbase, + &i.TxStatus, + &i.TxLabel, + ); 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 ListUnminedTransactions = `-- name: ListUnminedTransactions :many SELECT t.id, From 2249a7e85baeb2ebb1741512b7411d6a7b67cac1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 24 Mar 2026 14:03:24 +0800 Subject: [PATCH 471/691] wallet: fix sqlite lease query parameters Fix the sqlite AcquireUtxoLease query so the now_utc parameter is expanded inside the ON CONFLICT update predicate. Without this change sqlc leaves a raw sqlc.arg expression in the generated statement and sqlite rejects the lease write with a syntax error. Keeping the query fix in its own SQL commit makes the following LeaseOutput implementation commit a pure backend-wiring and test change. --- wallet/internal/db/queries/sqlite/utxo_leases.sql | 5 ++++- wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/wallet/internal/db/queries/sqlite/utxo_leases.sql b/wallet/internal/db/queries/sqlite/utxo_leases.sql index ca7be71137..f780bc3cde 100644 --- a/wallet/internal/db/queries/sqlite/utxo_leases.sql +++ b/wallet/internal/db/queries/sqlite/utxo_leases.sql @@ -49,7 +49,10 @@ SET WHERE utxo_leases.wallet_id = excluded.wallet_id AND ( - utxo_leases.expires_at <= sqlc.arg('now_utc') + -- ?6 is the generated bind slot for now_utc; sqlc leaves a literal + -- sqlc.arg('now_utc') here, so this predicate must stay aligned with + -- the arg ordering above. + utxo_leases.expires_at <= ?6 OR utxo_leases.lock_id = excluded.lock_id ) RETURNING expires_at; diff --git a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go index 504def11a9..3fc475ca72 100644 --- a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go @@ -39,7 +39,10 @@ SET WHERE utxo_leases.wallet_id = excluded.wallet_id AND ( - utxo_leases.expires_at <= sqlc.arg('now_utc') + -- ?6 is the generated bind slot for now_utc; sqlc leaves a literal + -- sqlc.arg('now_utc') here, so this predicate must stay aligned with + -- the arg ordering above. + utxo_leases.expires_at <= ?6 OR utxo_leases.lock_id = excluded.lock_id ) RETURNING expires_at From 11a12e43f366a462c2c0098d11763e3134104df3 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:54:12 +0800 Subject: [PATCH 472/691] wallet: add tx store itest helpers Add backend-specific tx store itest helpers for looking up stored tx rows, checking spend edges, mutating internal status, and asserting wallet UTXO presence. These helpers keep the later tx store integration tests focused on behavior instead of repeating low-level query plumbing. --- wallet/internal/db/itest/pg_test.go | 124 +++++++++++++++++++++++ wallet/internal/db/itest/sqlite_test.go | 125 ++++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index c400b5855a..1e4a4eb3ca 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -5,6 +5,7 @@ package itest import ( "context" "database/sql" + "errors" "flag" "fmt" "os" @@ -16,7 +17,10 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -232,3 +236,123 @@ func NewTestStore(t *testing.T) *db.PostgresStore { return store } + +// childSpendingTxIDs returns the direct child transaction IDs recorded for the +// provided parent transaction hash. +func childSpendingTxIDs(t *testing.T, store *db.PostgresStore, walletID uint32, + txHash chainhash.Hash) []int64 { + + t.Helper() + + meta, err := store.Queries().GetTransactionMetaByHash( + t.Context(), sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + require.NoError(t, err) + + childIDs, err := store.Queries().ListSpendingTxIDsByParentTxID( + t.Context(), sqlcpg.ListSpendingTxIDsByParentTxIDParams{ + WalletID: int64(walletID), + TxID: meta.ID, + }, + ) + require.NoError(t, err) + + ids := make([]int64, 0, len(childIDs)) + for _, childID := range childIDs { + require.True(t, childID.Valid) + ids = append(ids, childID.Int64) + } + + return ids +} + +// txIDByHash returns the database row ID for the given wallet-scoped +// transaction hash and reports whether the row exists. +func txIDByHash(t *testing.T, store *db.PostgresStore, walletID uint32, + txHash chainhash.Hash) (int64, bool) { + + t.Helper() + + meta, err := store.Queries().GetTransactionMetaByHash( + t.Context(), sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, false + } + + require.NoError(t, err) + } + + return meta.ID, true +} + +// rawTxByHash returns the serialized transaction bytes for the given +// wallet-scoped transaction hash. +func rawTxByHash(t *testing.T, store *db.PostgresStore, walletID uint32, + txHash chainhash.Hash) []byte { + + t.Helper() + + row, err := store.Queries().GetTransactionByHash( + t.Context(), sqlcpg.GetTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + require.NoError(t, err) + + return row.RawTx +} + +// setTxStatus rewrites one wallet-scoped transaction row to the provided +// status using the internal status-update query. +func setTxStatus(t *testing.T, store *db.PostgresStore, walletID uint32, + txHash chainhash.Hash, status db.TxStatus) { + + t.Helper() + + txID, ok := txIDByHash(t, store, walletID, txHash) + require.True(t, ok) + + rows, err := store.Queries().UpdateTransactionStatusByIDs( + t.Context(), sqlcpg.UpdateTransactionStatusByIDsParams{ + WalletID: int64(walletID), + Status: int16(status), + TxIds: []int64{txID}, + }, + ) + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// walletUtxoExists reports whether one wallet-scoped outpoint is currently +// present in the UTXO set. +func walletUtxoExists(t *testing.T, store *db.PostgresStore, walletID uint32, + outPoint wire.OutPoint) bool { + + t.Helper() + + _, err := store.Queries().GetUtxoIDByOutpoint( + t.Context(), sqlcpg.GetUtxoIDByOutpointParams{ + WalletID: int64(walletID), + TxHash: outPoint.Hash[:], + OutputIndex: int32(outPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false + } + + require.NoError(t, err) + } + + return true +} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 30bc4f0797..9d1e3c3fa1 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -3,10 +3,15 @@ package itest import ( + "database/sql" + "errors" "path/filepath" "testing" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" ) @@ -32,3 +37,123 @@ func NewTestStore(t *testing.T) *db.SqliteStore { return store } + +// childSpendingTxIDs returns the direct child transaction IDs recorded for the +// provided parent transaction hash. +func childSpendingTxIDs(t *testing.T, store *db.SqliteStore, walletID uint32, + txHash chainhash.Hash) []int64 { + + t.Helper() + + meta, err := store.Queries().GetTransactionMetaByHash( + t.Context(), sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + require.NoError(t, err) + + childIDs, err := store.Queries().ListSpendingTxIDsByParentTxID( + t.Context(), sqlcsqlite.ListSpendingTxIDsByParentTxIDParams{ + WalletID: int64(walletID), + TxID: meta.ID, + }, + ) + require.NoError(t, err) + + ids := make([]int64, 0, len(childIDs)) + for _, childID := range childIDs { + require.True(t, childID.Valid) + ids = append(ids, childID.Int64) + } + + return ids +} + +// txIDByHash returns the database row ID for the given wallet-scoped +// transaction hash and reports whether the row exists. +func txIDByHash(t *testing.T, store *db.SqliteStore, walletID uint32, + txHash chainhash.Hash) (int64, bool) { + + t.Helper() + + meta, err := store.Queries().GetTransactionMetaByHash( + t.Context(), sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, false + } + + require.NoError(t, err) + } + + return meta.ID, true +} + +// rawTxByHash returns the serialized transaction bytes for the given +// wallet-scoped transaction hash. +func rawTxByHash(t *testing.T, store *db.SqliteStore, walletID uint32, + txHash chainhash.Hash) []byte { + + t.Helper() + + row, err := store.Queries().GetTransactionByHash( + t.Context(), sqlcsqlite.GetTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + require.NoError(t, err) + + return row.RawTx +} + +// setTxStatus rewrites one wallet-scoped transaction row to the provided +// status using the internal status-update query. +func setTxStatus(t *testing.T, store *db.SqliteStore, walletID uint32, + txHash chainhash.Hash, status db.TxStatus) { + + t.Helper() + + txID, ok := txIDByHash(t, store, walletID, txHash) + require.True(t, ok) + + rows, err := store.Queries().UpdateTransactionStatusByIDs( + t.Context(), sqlcsqlite.UpdateTransactionStatusByIDsParams{ + WalletID: int64(walletID), + Status: int64(status), + TxIds: []int64{txID}, + }, + ) + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// walletUtxoExists reports whether one wallet-scoped outpoint is currently +// present in the UTXO set. +func walletUtxoExists(t *testing.T, store *db.SqliteStore, walletID uint32, + outPoint wire.OutPoint) bool { + + t.Helper() + + _, err := store.Queries().GetUtxoIDByOutpoint( + t.Context(), sqlcsqlite.GetUtxoIDByOutpointParams{ + WalletID: int64(walletID), + TxHash: outPoint.Hash[:], + OutputIndex: int64(outPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false + } + + require.NoError(t, err) + } + + return true +} From 240aacbb43167119d503ed239a7bcdc9784c36cc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 12:56:33 +0800 Subject: [PATCH 473/691] wallet: add tx store common helpers Add the shared transaction serialization, status parsing, and TxInfo conversion helpers used by both SQL backends. Keeping these small helpers and their unit tests in one place lets the later tx store method commits focus on backend behavior instead of repeating the same normalization logic. --- wallet/internal/db/tx_store_common.go | 99 +++++++++++++++ wallet/internal/db/tx_store_common_test.go | 139 +++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 wallet/internal/db/tx_store_common.go create mode 100644 wallet/internal/db/tx_store_common_test.go diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go new file mode 100644 index 0000000000..86ae527fff --- /dev/null +++ b/wallet/internal/db/tx_store_common.go @@ -0,0 +1,99 @@ +package db + +import ( + "bytes" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" +) + +var ( + // ErrInvalidParam is returned when a TxStore method receives invalid input. + ErrInvalidParam = errors.New("invalid param") + + // ErrInvalidStatus is returned when a transaction status is unknown or not + // allowed for the requested operation. + ErrInvalidStatus = errors.New("invalid transaction status") +) + +// serializeMsgTx serializes a wire.MsgTx so it can be stored in the +// transactions table. +func serializeMsgTx(tx *wire.MsgTx) ([]byte, error) { + if tx == nil { + return nil, fmt.Errorf("%w: tx is required", ErrInvalidParam) + } + + var buf bytes.Buffer + + err := tx.Serialize(&buf) + if err != nil { + return nil, fmt.Errorf("serialize tx: %w", err) + } + + return buf.Bytes(), nil +} + +// deserializeMsgTx deserializes a stored transaction payload back into a +// wire.MsgTx. +func deserializeMsgTx(rawTx []byte) (*wire.MsgTx, error) { + var tx wire.MsgTx + + err := tx.Deserialize(bytes.NewReader(rawTx)) + if err != nil { + return nil, fmt.Errorf("deserialize tx: %w", err) + } + + return &tx, nil +} + +// parseTxStatus converts a stored numeric status code into the strongly typed +// TxStatus enum used by the public db API. +func parseTxStatus(status int64) (TxStatus, error) { + txStatus, err := int64ToUint8(status) + if err != nil { + return TxStatus(0), fmt.Errorf("status %d: %w", status, + ErrInvalidStatus) + } + + switch TxStatus(txStatus) { + case TxStatusPending, + TxStatusPublished, + TxStatusReplaced, + TxStatusFailed, + TxStatusOrphaned: + + return TxStatus(txStatus), nil + + default: + return TxStatus(0), fmt.Errorf("status %d: %w", status, + ErrInvalidStatus) + } +} + +// buildTxInfo converts normalized transaction fields into the public TxInfo +// shape returned by the db interfaces. +func buildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, + status int64, label string) (*TxInfo, error) { + + txHash, err := chainhash.NewHash(hash) + if err != nil { + return nil, fmt.Errorf("tx hash: %w", err) + } + + txStatus, err := parseTxStatus(status) + if err != nil { + return nil, err + } + + return &TxInfo{ + Hash: *txHash, + SerializedTx: rawTx, + Received: received.UTC(), + Block: block, + Status: txStatus, + Label: label, + }, nil +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go new file mode 100644 index 0000000000..3f51a3eb91 --- /dev/null +++ b/wallet/internal/db/tx_store_common_test.go @@ -0,0 +1,139 @@ +package db + +import ( + "bytes" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// TestSerializeDeserializeMsgTx verifies that the common serialization helpers +// preserve transaction bytes across a round trip. +func TestSerializeDeserializeMsgTx(t *testing.T) { + t.Parallel() + + tx := testRegularMsgTx() + + rawTx, err := serializeMsgTx(tx) + require.NoError(t, err) + + decoded, err := deserializeMsgTx(rawTx) + require.NoError(t, err) + + var got bytes.Buffer + + err = decoded.Serialize(&got) + require.NoError(t, err) + + require.Equal(t, rawTx, got.Bytes()) +} + +// TestSerializeMsgTxNil verifies that serializeMsgTx rejects a missing +// transaction pointer with the public invalid-param error. +func TestSerializeMsgTxNil(t *testing.T) { + t.Parallel() + + _, err := serializeMsgTx(nil) + require.ErrorIs(t, err, ErrInvalidParam) +} + +// TestParseTxStatus verifies that stored numeric values map back to the public +// TxStatus enum and that unknown values fail loudly. +func TestParseTxStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int64 + want TxStatus + wantErr error + }{ + {name: "pending", status: 0, want: TxStatusPending}, + {name: "published", status: 1, want: TxStatusPublished}, + {name: "replaced", status: 2, want: TxStatusReplaced}, + {name: "failed", status: 3, want: TxStatusFailed}, + {name: "orphaned", status: 4, want: TxStatusOrphaned}, + {name: "invalid", status: 9, wantErr: ErrInvalidStatus}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := parseTxStatus(tc.status) + require.ErrorIs(t, err, tc.wantErr) + require.Equal(t, tc.want, got) + }) + } +} + +// TestBuildTxInfo verifies the shared row-to-domain conversion used by both +// SQL backends when returning a valid TxInfo value. +func TestBuildTxInfo(t *testing.T) { + t.Parallel() + + tx := testRegularMsgTx() + hash := tx.TxHash() + rawTx, err := serializeMsgTx(tx) + require.NoError(t, err) + + blockHash := chainhash.Hash{1, 2, 3} + block := &Block{ + Hash: blockHash, + Height: 77, + Timestamp: time.Unix(500, 0), + } + + info, err := buildTxInfo( + hash[:], rawTx, time.Unix(600, 0).In(time.FixedZone("X", 3600)), + block, int64(TxStatusPublished), "note", + ) + require.NoError(t, err) + require.Equal(t, hash, info.Hash) + require.Equal(t, rawTx, info.SerializedTx) + require.Equal(t, TxStatusPublished, info.Status) + require.Equal(t, "note", info.Label) + require.Equal(t, time.UTC, info.Received.Location()) + require.Equal(t, block, info.Block) +} + +// TestBuildTxInfoInvalidHash verifies that buildTxInfo rejects malformed hash +// bytes. +func TestBuildTxInfoInvalidHash(t *testing.T) { + t.Parallel() + + tx := testRegularMsgTx() + rawTx, err := serializeMsgTx(tx) + require.NoError(t, err) + + _, err = buildTxInfo([]byte{1, 2, 3}, rawTx, time.Now(), nil, + int64(TxStatusPending), "") + require.Error(t, err) +} + +// TestBuildTxInfoInvalidStatus verifies that buildTxInfo rejects unknown status +// codes. +func TestBuildTxInfoInvalidStatus(t *testing.T) { + t.Parallel() + + tx := testRegularMsgTx() + hash := tx.TxHash() + rawTx, err := serializeMsgTx(tx) + require.NoError(t, err) + + _, err = buildTxInfo(hash[:], rawTx, time.Now(), nil, 9, "") + require.ErrorIs(t, err, ErrInvalidStatus) +} + +// testRegularMsgTx builds a minimal non-coinbase transaction fixture for the +// shared TxStore helper tests. +func testRegularMsgTx() *wire.MsgTx { + tx := wire.NewMsgTx(wire.TxVersion) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}}}) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + + return tx +} From 7e742e94c2c3715503112f28522418025c535659 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 17:56:43 +0800 Subject: [PATCH 474/691] wallet: add CreateTx ops Add the shared CreateTx validation and orchestration helpers used by both SQL backends. This version validates block-aware create invariants up front so confirmed inserts and confirmed coinbase history can be recorded in one atomic write without a later patch step. --- wallet/internal/db/tx_store_common.go | 232 +++++++++++++++ wallet/internal/db/tx_store_common_test.go | 327 ++++++++++++++++++++- 2 files changed, 558 insertions(+), 1 deletion(-) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 86ae527fff..9ea4af3d9d 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -2,10 +2,12 @@ package db import ( "bytes" + "context" "errors" "fmt" "time" + "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" ) @@ -17,6 +19,14 @@ var ( // ErrInvalidStatus is returned when a transaction status is unknown or not // allowed for the requested operation. ErrInvalidStatus = errors.New("invalid transaction status") + + // ErrIndexOutOfRange is returned when a referenced transaction input or + // output index does not exist. + ErrIndexOutOfRange = errors.New("index out of range") + + // ErrDuplicateInputOutPoint is returned when CreateTx receives the same + // previous outpoint more than once. + ErrDuplicateInputOutPoint = errors.New("duplicate input outpoint") ) // serializeMsgTx serializes a wire.MsgTx so it can be stored in the @@ -97,3 +107,225 @@ func buildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, Label: label, }, nil } + +// validateCreateTxParams enforces the CreateTx invariants shared by both SQL +// backends after serializeMsgTx has already verified that params.Tx is non-nil. +func validateCreateTxParams(params CreateTxParams) error { + isCoinbase := blockchain.IsCoinBaseTx(params.Tx) + + err := validateCreateTxStatus( + params.Status, params.Block != nil, isCoinbase, + ) + if err != nil { + return err + } + + maxIndex := uint64(len(params.Tx.TxOut)) + + for index := range params.Credits { + if uint64(index) >= maxIndex { + return fmt.Errorf("%w: credit index %d is out of range: %w", + ErrInvalidParam, index, ErrIndexOutOfRange) + } + } + + // Coinbase transactions only enter wallet history once a block already + // anchors them, so CreateTx requires the caller to provide that block up + // front instead of storing a fake unmined intermediate row first. + if isCoinbase { + return nil + } + + seenInputs := make(map[wire.OutPoint]struct{}, len(params.Tx.TxIn)) + for inputIndex, txIn := range params.Tx.TxIn { + // One transaction cannot spend the same previous outpoint twice. + // Rejecting duplicate inputs here keeps the later wallet-spend walk + // simple and avoids writing contradictory spend metadata. + if _, ok := seenInputs[txIn.PreviousOutPoint]; ok { + return fmt.Errorf("%w: input %d duplicates a previous outpoint: %w", + ErrInvalidParam, inputIndex, ErrDuplicateInputOutPoint) + } + + seenInputs[txIn.PreviousOutPoint] = struct{}{} + } + + return nil +} + +// validateCreateTxStatus checks the status/block combinations that CreateTx may +// store directly. +func validateCreateTxStatus(status TxStatus, hasBlock bool, + isCoinbase bool) error { + + _, err := parseTxStatus(int64(status)) + if err != nil { + return fmt.Errorf("%w: status %d is not supported: %w", + ErrInvalidParam, status, ErrInvalidStatus) + } + + // Orphaned rows only arise later when rollback disconnects a confirmed + // coinbase transaction. CreateTx records the initial observed facts, so it + // never inserts orphaned history directly. + if status == TxStatusOrphaned { + return fmt.Errorf("%w: CreateTx cannot insert orphaned txns: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + if !hasBlock { + // Coinbase transactions cannot exist without a confirming block from + // the store's point of view, so callers must supply that block up + // front. + if isCoinbase { + return fmt.Errorf("%w: coinbase txns require a block: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + // Unmined non-coinbase inserts still represent current unmined wallet + // history, so CreateTx only accepts the two active unmined statuses + // there. + if status != TxStatusPending && status != TxStatusPublished { + return fmt.Errorf("%w: CreateTx requires pending or published: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + return nil + } + + // A non-nil block means the caller already knows the transaction is mined. + // Mined rows must be published immediately to satisfy the transaction-state + // invariants enforced by the schema. + if status != TxStatusPublished { + return fmt.Errorf("%w: confirmed txns must be published: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + return nil +} + +// createTxRequest captures the backend-independent CreateTx inputs after the +// shared validation and normalization step has already succeeded. +type createTxRequest struct { + // params keeps the original public request available for backend helpers + // that still need the caller-supplied CreateTx metadata. + params CreateTxParams + + // rawTx stores the serialized transaction bytes once so both backends reuse + // the same payload throughout the write. + rawTx []byte + + // txHash avoids recomputing the transaction hash across the shared flow and + // backend adapters. + txHash chainhash.Hash + + // received is normalized to UTC before any backend insert logic runs. + received time.Time + + // isCoinbase caches the consensus coinbase check for backend insert params. + isCoinbase bool +} + +// newCreateTxRequest performs the backend-independent CreateTx preparation +// shared by both SQL stores before they open a write transaction. +func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { + rawTx, err := serializeMsgTx(params.Tx) + if err != nil { + return createTxRequest{}, err + } + + err = validateCreateTxParams(params) + if err != nil { + return createTxRequest{}, err + } + + return createTxRequest{ + params: params, + rawTx: rawTx, + txHash: params.Tx.TxHash(), + received: params.Received.UTC(), + isCoinbase: blockchain.IsCoinBaseTx(params.Tx), + }, nil +} + +// createTxOps is the small semantic adapter CreateTx needs from one SQL +// backend. +// +// The shared CreateTx algorithm is intentionally linear: +// - load any existing wallet-scoped row for the same tx hash first +// - if the same tx is being reconfirmed, update that existing row instead of +// inserting a duplicate +// - validate and cache any confirming block metadata before later writes use +// it +// - when the incoming tx is confirmed, discover and invalidate any direct +// conflict roots before the new row claims their wallet-owned inputs +// - insert the base transaction row exactly once when no existing row can be +// reused +// - insert every wallet-owned credited output as a UTXO +// - attach any wallet-owned spent inputs to that final transaction row +// +// Each backend implements those steps with its own sqlc-generated query types +// while createTxWithOps keeps the high-level sequencing in one place. +// That sequencing matters because confirmation, conflict invalidation, credit +// creation, and spent-input claims must either all observe the same tx row or +// all roll back together. +type createTxOps interface { + // hasExisting reports whether CreateTx would collide with an existing + // wallet-scoped transaction row for the same hash. + hasExisting(ctx context.Context, req createTxRequest) (bool, error) + + // prepareBlock validates and caches any optional confirming block metadata + // the later insert step needs. + prepareBlock(ctx context.Context, req createTxRequest) error + + // insert writes the base transaction row and returns its new primary key. + insert(ctx context.Context, req createTxRequest) (int64, error) + + // insertCredits records every wallet-owned output that the caller + // marked as a credit for this transaction. + insertCredits(ctx context.Context, req createTxRequest, txID int64) error + + // markInputsSpent attaches wallet-owned parent outpoints to this + // transaction row and rejects conflicts or invalid wallet parents. + markInputsSpent(ctx context.Context, req createTxRequest, txID int64) error +} + +// createTxWithOps runs the backend-independent CreateTx orchestration once the +// caller has opened a backend-specific SQL transaction. +// +// The helper inserts the base transaction row before it records credits or +// spent inputs so every later step can point at one stable row ID. If any later +// step fails, ExecuteTx rolls the whole write back. +func createTxWithOps(ctx context.Context, req createTxRequest, + ops createTxOps) error { + + exists, err := ops.hasExisting(ctx, req) + if err != nil { + return fmt.Errorf("prepare create tx: check existing tx: %w", err) + } + + if exists { + return fmt.Errorf("prepare create tx: tx %s: %w", req.txHash, + ErrTxAlreadyExists) + } + + err = ops.prepareBlock(ctx, req) + if err != nil { + return fmt.Errorf("prepare create block assignment: %w", err) + } + + txID, err := ops.insert(ctx, req) + if err != nil { + return fmt.Errorf("insert tx: %w", err) + } + + err = ops.insertCredits(ctx, req, txID) + if err != nil { + return fmt.Errorf("create tx credits: %w", err) + } + + err = ops.markInputsSpent(ctx, req, txID) + if err != nil { + return fmt.Errorf("create tx spends: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 3f51a3eb91..6835ac8847 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -2,9 +2,11 @@ package db import ( "bytes" + "context" "testing" "time" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/stretchr/testify/require" @@ -15,8 +17,10 @@ import ( func TestSerializeDeserializeMsgTx(t *testing.T) { t.Parallel() + // Arrange: Build one regular transaction fixture. tx := testRegularMsgTx() + // Act: Serialize it and deserialize the result. rawTx, err := serializeMsgTx(tx) require.NoError(t, err) @@ -28,6 +32,7 @@ func TestSerializeDeserializeMsgTx(t *testing.T) { err = decoded.Serialize(&got) require.NoError(t, err) + // Assert: The decoded transaction serializes back to the same bytes. require.Equal(t, rawTx, got.Bytes()) } @@ -42,6 +47,9 @@ func TestSerializeMsgTxNil(t *testing.T) { // TestParseTxStatus verifies that stored numeric values map back to the public // TxStatus enum and that unknown values fail loudly. +// +// The table keeps the setup identical for every case so the loop only varies +// the input status code and the expected result. func TestParseTxStatus(t *testing.T) { t.Parallel() @@ -75,6 +83,7 @@ func TestParseTxStatus(t *testing.T) { func TestBuildTxInfo(t *testing.T) { t.Parallel() + // Arrange: Build one serialized transaction and one block fixture. tx := testRegularMsgTx() hash := tx.TxHash() rawTx, err := serializeMsgTx(tx) @@ -87,11 +96,14 @@ func TestBuildTxInfo(t *testing.T) { Timestamp: time.Unix(500, 0), } + // Act: Convert the normalized row fields into TxInfo. info, err := buildTxInfo( hash[:], rawTx, time.Unix(600, 0).In(time.FixedZone("X", 3600)), block, int64(TxStatusPublished), "note", ) require.NoError(t, err) + + // Assert: The resulting TxInfo preserves the expected public fields. require.Equal(t, hash, info.Hash) require.Equal(t, rawTx, info.SerializedTx) require.Equal(t, TxStatusPublished, info.Status) @@ -128,12 +140,325 @@ func TestBuildTxInfoInvalidStatus(t *testing.T) { require.ErrorIs(t, err, ErrInvalidStatus) } +// TestValidateCreateTxParams verifies the shared CreateTx invariants that both +// SQL backends rely on before opening a write transaction. +// +// The cases vary only the CreateTx input data so the table-driven structure +// stays clear and does not need conditional setup inside the loop. +func TestValidateCreateTxParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params CreateTxParams + wantErr error + wantParamErr error + }{ + { + name: "coinbase must be published", + params: CreateTxParams{ + Tx: testCoinbaseMsgTx(), + Block: testBlock(100), + Status: TxStatusPending, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "coinbase requires block", + params: CreateTxParams{ + Tx: testCoinbaseMsgTx(), + Status: TxStatusPublished, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "orphaned status rejected on create", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Status: TxStatusOrphaned, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "failed status rejected on create", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Status: TxStatusFailed, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "replaced status rejected on create", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Status: TxStatusReplaced, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "credit index out of range", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Credits: map[uint32]btcutil.Address{2: nil}, + Status: TxStatusPending, + }, + wantErr: ErrIndexOutOfRange, + wantParamErr: ErrInvalidParam, + }, + { + name: "duplicate input outpoint", + params: CreateTxParams{ + Tx: &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + }, { + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + }}, + TxOut: []*wire.TxOut{{Value: 1, PkScript: []byte{0x51}}}, + }, + Status: TxStatusPending, + }, + wantErr: ErrDuplicateInputOutPoint, + wantParamErr: ErrInvalidParam, + }, + { + name: "confirmed create must be published", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Block: testBlock(101), + Status: TxStatusPending, + }, + wantErr: ErrInvalidStatus, + wantParamErr: ErrInvalidParam, + }, + { + name: "valid pending unmined transaction", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Status: TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + }, + { + name: "valid published confirmed transaction", + params: CreateTxParams{ + Tx: testRegularMsgTx(), + Block: testBlock(102), + Status: TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + }, + { + name: "valid published coinbase facts", + params: CreateTxParams{ + Tx: testCoinbaseMsgTx(), + Block: testBlock(103), + Status: TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateCreateTxParams(tc.params) + require.ErrorIs(t, err, tc.wantErr) + require.ErrorIs(t, err, tc.wantParamErr) + }) + } +} + +// TestNewCreateTxRequest verifies that the shared CreateTx preparation step +// normalizes the request before either backend opens a write transaction. +func TestNewCreateTxRequest(t *testing.T) { + t.Parallel() + + // Arrange: Build one valid request with a non-UTC received timestamp. + params := CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(123, 0).In(time.FixedZone("X", 3600)), + Block: testBlock(77), + Status: TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + Label: "note", + } + + // Act: Normalize it through newCreateTxRequest. + req, err := newCreateTxRequest(params) + require.NoError(t, err) + + wantRawTx, err := serializeMsgTx(params.Tx) + require.NoError(t, err) + + // Assert: The prepared request caches the normalized transaction facts. + require.Equal(t, params, req.params) + require.Equal(t, params.Tx.TxHash(), req.txHash) + require.Equal(t, wantRawTx, req.rawTx) + require.Equal(t, time.UTC, req.received.Location()) + require.False(t, req.isCoinbase) +} + +// TestCreateTxWithOpsInsert verifies that the shared CreateTx orchestration +// performs the full insert path in order for one fresh transaction row. +func TestCreateTxWithOpsInsert(t *testing.T) { + t.Parallel() + + // Arrange: Build one prepared CreateTx request and one stub adapter. + req := testCreateTxRequest(t) + ops := &stubCreateTxOps{insertTxID: 11} + + // Act: Run createTxWithOps. + err := createTxWithOps(context.Background(), req, ops) + require.NoError(t, err) + + // Assert: The shared flow executes the expected write sequence. + require.Equal(t, + []string{"exists", "prepare-block", "insert", "credits", "inputs"}, + ops.calls, + ) + require.Equal(t, int64(11), ops.creditsTxID) + require.Equal(t, int64(11), ops.inputsTxID) + require.Equal(t, req.txHash, ops.insertReq.txHash) +} + +// TestCreateTxWithOpsDuplicate verifies that the shared CreateTx helper maps an +// existing wallet-scoped tx hash to ErrTxAlreadyExists. +func TestCreateTxWithOpsDuplicate(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ops := &stubCreateTxOps{hasExistingResult: true} + + err := createTxWithOps(context.Background(), req, ops) + require.ErrorIs(t, err, ErrTxAlreadyExists) + require.Equal(t, []string{"exists"}, ops.calls) +} + +// testBlock builds a simple block fixture for CreateTx validation tests. +func testBlock(height uint32) *Block { + return &Block{ + Hash: chainhash.Hash{byte(height)}, + Height: height, + Timestamp: time.Unix(int64(height), 0), + } +} + // testRegularMsgTx builds a minimal non-coinbase transaction fixture for the // shared TxStore helper tests. func testRegularMsgTx() *wire.MsgTx { tx := wire.NewMsgTx(wire.TxVersion) - tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}}}) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}}, + }) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + + return tx +} + +// testCoinbaseMsgTx builds a minimal coinbase transaction fixture. +func testCoinbaseMsgTx() *wire.MsgTx { + tx := wire.NewMsgTx(wire.TxVersion) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: ^uint32(0)}}) tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) return tx } + +// stubCreateTxOps records how the shared CreateTx helper drives one backend +// adapter while letting each test control the returned transaction IDs. +type stubCreateTxOps struct { + hasExistingResult bool + insertTxID int64 + + calls []string + insertReq createTxRequest + creditsTxID int64 + inputsTxID int64 +} + +var _ createTxOps = (*stubCreateTxOps)(nil) + +// hasExisting records that the shared flow checked whether the tx hash already +// exists and returns the test-controlled result. +func (s *stubCreateTxOps) hasExisting(_ context.Context, + _ createTxRequest) (bool, error) { + + s.calls = append(s.calls, "exists") + + return s.hasExistingResult, nil +} + +// prepareBlock records that the shared flow validated any optional block +// assignment before insert. +func (s *stubCreateTxOps) prepareBlock(_ context.Context, + _ createTxRequest) error { + + s.calls = append(s.calls, "prepare-block") + + return nil +} + +// insert records the request that the shared flow would store as a fresh +// transaction row. +func (s *stubCreateTxOps) insert(_ context.Context, + req createTxRequest) (int64, error) { + + s.calls = append(s.calls, "insert") + s.insertReq = req + + return s.insertTxID, nil +} + +// insertCredits records the transaction ID the shared flow used when +// reconciling wallet-owned outputs. +func (s *stubCreateTxOps) insertCredits(_ context.Context, + _ createTxRequest, txID int64) error { + + s.calls = append(s.calls, "credits") + s.creditsTxID = txID + + return nil +} + +// markInputsSpent records the transaction ID the shared flow used when +// attaching wallet-owned spent inputs. +func (s *stubCreateTxOps) markInputsSpent(_ context.Context, + _ createTxRequest, txID int64) error { + + s.calls = append(s.calls, "inputs") + s.inputsTxID = txID + + return nil +} + +// testCreateTxRequest builds one valid normalized CreateTx request for the +// shared CreateTx orchestration tests. +func testCreateTxRequest(t *testing.T) createTxRequest { + t.Helper() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 5, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Status: TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + return req +} From 932b59576796dda48c27f1d0693be7c53617a7f2 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 17:59:26 +0800 Subject: [PATCH 475/691] wallet: add CreateTx Add the postgres and sqlite CreateTx implementations together with CreateTx integration coverage. CreateTx now validates any supplied confirming block during insert so confirmed rows, including coinbase history, can be stored atomically without a later patch step. --- wallet/internal/db/block_pg.go | 35 ++ wallet/internal/db/block_sqlite.go | 32 ++ .../internal/db/itest/tx_utxo_store_test.go | 332 ++++++++++++++++++ .../internal/db/postgres_txstore_createtx.go | 311 ++++++++++++++++ wallet/internal/db/sqlite_txstore_createtx.go | 299 ++++++++++++++++ 5 files changed, 1009 insertions(+) create mode 100644 wallet/internal/db/itest/tx_utxo_store_test.go create mode 100644 wallet/internal/db/postgres_txstore_createtx.go create mode 100644 wallet/internal/db/sqlite_txstore_createtx.go diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/block_pg.go index e49647dd50..7fee38f67c 100644 --- a/wallet/internal/db/block_pg.go +++ b/wallet/internal/db/block_pg.go @@ -1,8 +1,10 @@ package db import ( + "bytes" "context" "database/sql" + "errors" "fmt" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -43,3 +45,36 @@ func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } + +// requireBlockMatchesPg loads the shared block row for the provided height and +// verifies that its stored metadata matches the supplied block reference. +func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, + block *Block) (int32, error) { + + height, err := uint32ToInt32(block.Height) + if err != nil { + return 0, fmt.Errorf("convert block height: %w", err) + } + + storedBlock, err := qtx.GetBlockByHeight(ctx, height) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("block %d: %w", block.Height, + ErrBlockNotFound) + } + + return 0, fmt.Errorf("get block by height: %w", err) + } + + if !bytes.Equal(storedBlock.HeaderHash, block.Hash[:]) { + return 0, fmt.Errorf("block %d header hash: %w", block.Height, + ErrBlockMismatch) + } + + if storedBlock.BlockTimestamp != block.Timestamp.Unix() { + return 0, fmt.Errorf("block %d timestamp: %w", block.Height, + ErrBlockMismatch) + } + + return height, nil +} diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/block_sqlite.go index 8fd83c22bf..ffa5413dd7 100644 --- a/wallet/internal/db/block_sqlite.go +++ b/wallet/internal/db/block_sqlite.go @@ -1,8 +1,10 @@ package db import ( + "bytes" "context" "database/sql" + "errors" "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -41,3 +43,33 @@ func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } + +// requireBlockMatchesSqlite loads the shared block row for the provided height +// and verifies that its stored metadata matches the supplied block reference. +func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + block *Block) (int64, error) { + + height := int64(block.Height) + + storedBlock, err := qtx.GetBlockByHeight(ctx, height) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("block %d: %w", block.Height, + ErrBlockNotFound) + } + + return 0, fmt.Errorf("get block by height: %w", err) + } + + if !bytes.Equal(storedBlock.HeaderHash, block.Hash[:]) { + return 0, fmt.Errorf("block %d header hash: %w", block.Height, + ErrBlockMismatch) + } + + if storedBlock.BlockTimestamp != block.Timestamp.Unix() { + return 0, fmt.Errorf("block %d timestamp: %w", block.Height, + ErrBlockMismatch) + } + + return height, nil +} diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go new file mode 100644 index 0000000000..f39f207713 --- /dev/null +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -0,0 +1,332 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateTxStoresWalletCredit verifies that CreateTx stores the transaction +// row and the requested wallet-owned output in one atomic write. +// +// Scenario: +// - One wallet records a new unmined transaction with one wallet-owned +// credited output. +// +// Setup: +// - Create one wallet, one derived account, and one wallet-owned address. +// - Build one transaction that pays that address. +// +// Action: +// - Insert the transaction through CreateTx. +// +// Assertions: +// - The transaction row exists. +// - The credited output exists in the wallet UTXO set. +func TestCreateTxStoresWalletCredit(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-tx-credit") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000300, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + _, ok := txIDByHash(t, store, walletID, tx.TxHash()) + require.True(t, ok) + require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{ + Hash: tx.TxHash(), Index: 0, + })) +} + +// TestCreateTxStoresConfirmedCoinbase verifies that CreateTx can record one +// coinbase transaction directly in its confirmed state when the block is +// already known. +// +// Scenario: +// - One wallet learns about one coinbase credit together with its confirming +// block. +// +// Setup: +// - Create one wallet, one derived account, one wallet-owned address, and one +// matching block fixture. +// - Build one coinbase transaction that pays that wallet-owned address. +// +// Action: +// - Insert the coinbase through CreateTx with the block assignment present. +// +// Assertions: +// - The transaction row exists. +// - The wallet-owned coinbase output exists in the current UTXO set. +func TestCreateTxStoresConfirmedCoinbase(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-confirmed-coinbase") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + block := CreateBlockFixture(t, store.Queries(), 210) + coinbaseTx := newCoinbaseTx(addr.ScriptPubKey) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710000350, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + _, ok := txIDByHash(t, store, walletID, coinbaseTx.TxHash()) + require.True(t, ok) + require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{ + Hash: coinbaseTx.TxHash(), Index: 0, + })) +} + +// TestCreateTxRejectsInvalidParentWalletOutput verifies that CreateTx rejects a +// child that spends a wallet-owned output whose parent transaction is already +// invalid. +// +// Scenario: +// - One wallet output exists, but its parent transaction has already been +// marked failed. +// +// Setup: +// - Create one wallet-owned parent credit. +// - Rewrite the parent transaction status to failed. +// - Build one child transaction that spends that wallet-owned output. +// +// Action: +// - Insert the child through CreateTx. +// +// Assertions: +// - CreateTx returns ErrTxInputInvalidParent. +// - No child row or child spend edge is persisted. +// - The original parent row remains stored. +func TestCreateTxRejectsInvalidParentWalletOutput(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalid-parent") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 50000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710000400, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + setTxStatus(t, store, walletID, parentTx.TxHash(), db.TxStatusFailed) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 49000, PkScript: []byte{0x51}}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710000410, 0), + Status: db.TxStatusPending, + }) + require.ErrorIs(t, err, db.ErrTxInputInvalidParent) + require.Empty(t, childSpendingTxIDs(t, store, walletID, parentTx.TxHash())) + _, ok := txIDByHash(t, store, walletID, childTx.TxHash()) + require.False(t, ok) + _, ok = txIDByHash(t, store, walletID, parentTx.TxHash()) + require.True(t, ok) +} + +// TestCreateTxRejectsSecondPendingSpend verifies that CreateTx rejects a second +// pending transaction that spends the same wallet-owned output. +// +// Scenario: +// - One wallet-owned output already has one pending child spender. +// +// Setup: +// - Create one wallet-owned parent credit. +// - Insert one first child transaction that spends it. +// - Build one second child that spends the same outpoint. +// +// Action: +// - Insert the second child through CreateTx. +// +// Assertions: +// - CreateTx returns ErrTxInputConflict. +// - Only the first child remains recorded as the spender. +// - The second child row is not inserted. +func TestCreateTxRejectsSecondPendingSpend(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-second-spend-conflict") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710000500, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + spentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} + firstChild := newRegularTx( + []wire.OutPoint{spentOutPoint}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: firstChild, + Received: time.Unix(1710000510, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + secondChild := newRegularTx( + []wire.OutPoint{spentOutPoint}, + []*wire.TxOut{{Value: 3000, PkScript: []byte{0x52}}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: secondChild, + Received: time.Unix(1710000520, 0), + Status: db.TxStatusPending, + }) + require.ErrorIs(t, err, db.ErrTxInputConflict) + + childIDs := childSpendingTxIDs(t, store, walletID, parentTx.TxHash()) + require.Len(t, childIDs, 1) + + _, ok := txIDByHash(t, store, walletID, firstChild.TxHash()) + require.True(t, ok) + _, ok = txIDByHash(t, store, walletID, secondChild.TxHash()) + require.False(t, ok) +} + +// TestCreateTxRejectsDuplicateTx verifies that CreateTx inserts one wallet- +// scoped transaction row only once. +// +// Scenario: +// - One wallet transaction hash is already present in the store. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// +// Action: +// - Attempt to insert the same transaction hash again. +// +// Assertions: +// - CreateTx returns ErrTxAlreadyExists. +// - The original row remains stored once. +func TestCreateTxRejectsDuplicateTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-tx-duplicate") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000580, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000590, 0), + Status: db.TxStatusPending, + }) + require.ErrorIs(t, err, db.ErrTxAlreadyExists) + + _, ok := txIDByHash(t, store, walletID, tx.TxHash()) + require.True(t, ok) +} + +// newCoinbaseTx builds a simple coinbase fixture transaction. +func newCoinbaseTx(pkScript []byte) *wire.MsgTx { + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: ^uint32(0)}}) + tx.AddTxOut(&wire.TxOut{Value: 5000, PkScript: pkScript}) + + return tx +} + +// newRegularTx builds a simple fixture transaction with the provided inputs and +// outputs. +func newRegularTx(inputs []wire.OutPoint, outputs []*wire.TxOut) *wire.MsgTx { + tx := wire.NewMsgTx(2) + + for _, prevOut := range inputs { + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: prevOut}) + } + + for _, txOut := range outputs { + tx.AddTxOut(txOut) + } + + return tx +} + +// randomOutPoint returns one fixture outpoint backed by a random hash. +func randomOutPoint() wire.OutPoint { + return wire.OutPoint{Hash: RandomHash(), Index: 0} +} diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go new file mode 100644 index 0000000000..b940563ca2 --- /dev/null +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -0,0 +1,311 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// CreateTx atomically records a wallet-scoped transaction row, its +// wallet-owned credits, and any spend edges created by its inputs. +// +// The full write runs inside ExecuteTx so the transaction row, created UTXOs, +// and spent-parent markers are either committed together or not at all. +// Received timestamps are normalized to UTC before insert. CreateTx is +// insert-only and returns ErrTxAlreadyExists if the wallet already stores the +// tx hash. +func (s *PostgresStore) CreateTx(ctx context.Context, + params CreateTxParams) error { + + req, err := newCreateTxRequest(params) + if err != nil { + return err + } + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return createTxWithOps(ctx, req, &pgCreateTxOps{qtx: qtx}) + }) +} + +// pgCreateTxOps adapts postgres sqlc queries to the shared CreateTx flow. +type pgCreateTxOps struct { + qtx *sqlcpg.Queries + + blockHeight sql.NullInt32 +} + +var _ createTxOps = (*pgCreateTxOps)(nil) + +// hasExisting reports whether the wallet already stores the requested tx hash. +func (o *pgCreateTxOps) hasExisting(ctx context.Context, + req createTxRequest) (bool, error) { + + _, err := o.qtx.GetTransactionMetaByHash( + ctx, + sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + }, + ) + if err == nil { + return true, nil + } + + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("get tx metadata: %w", err) +} + +// prepareBlock validates the optional confirming block and caches the postgres +// block-height value that the later insert query will store. +func (o *pgCreateTxOps) prepareBlock(ctx context.Context, + req createTxRequest) error { + + o.blockHeight = sql.NullInt32{} + + if req.params.Block == nil { + return nil + } + + height, err := requireBlockMatchesPg(ctx, o.qtx, req.params.Block) + if err != nil { + return err + } + + o.blockHeight = sql.NullInt32{Int32: height, Valid: true} + + return nil +} + +// insert stores one new postgres transaction row for CreateTx. +func (o *pgCreateTxOps) insert(ctx context.Context, + req createTxRequest) (int64, error) { + + txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + RawTx: req.rawTx, + BlockHeight: o.blockHeight, + TxStatus: int16(req.params.Status), + ReceivedTime: req.received, + IsCoinbase: req.isCoinbase, + TxLabel: req.params.Label, + }) + if err != nil { + return 0, fmt.Errorf("insert tx row: %w", err) + } + + return txID, nil +} + +// insertCredits stores any wallet-owned outputs created by the transaction. +func (o *pgCreateTxOps) insertCredits(ctx context.Context, + req createTxRequest, txID int64) error { + + return insertCreditsPg(ctx, o.qtx, req.params, txID) +} + +// markInputsSpent records wallet-owned inputs spent by the transaction. +func (o *pgCreateTxOps) markInputsSpent(ctx context.Context, + req createTxRequest, txID int64) error { + + return markInputsSpentPg(ctx, o.qtx, req.params, txID) +} + +// insertCreditsPg inserts one wallet-owned UTXO row for each credited output of +// the transaction being stored. +func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, + params CreateTxParams, txID int64) error { + + for index := range params.Credits { + creditExists, err := creditExistsPg( + ctx, qtx, params.WalletID, params.Tx.TxHash(), index, + ) + if err != nil { + return err + } + + if creditExists { + continue + } + + pkScript := params.Tx.TxOut[index].PkScript + + addrRow, err := qtx.GetAddressByScriptPubKey( + ctx, sqlcpg.GetAddressByScriptPubKeyParams{ + ScriptPubKey: pkScript, + WalletID: int64(params.WalletID), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("credit output %d: %w", index, + ErrAddressNotFound) + } + + return fmt.Errorf("resolve credit address %d: %w", index, err) + } + + outputIndex, err := uint32ToInt32(index) + if err != nil { + return fmt.Errorf("convert credit index %d: %w", index, err) + } + + _, err = qtx.InsertUtxo(ctx, sqlcpg.InsertUtxoParams{ + WalletID: int64(params.WalletID), + TxID: txID, + OutputIndex: outputIndex, + Amount: params.Tx.TxOut[index].Value, + AddressID: addrRow.ID, + }) + if err != nil { + return fmt.Errorf("insert credit output %d: %w", index, err) + } + } + + return nil +} + +// creditExistsPg reports whether the wallet already has a UTXO row for the +// given credited output, even if that output is now spent by a child tx. +func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { + + convertedIndex, err := uint32ToInt32(outputIndex) + if err != nil { + return false, fmt.Errorf("convert credit index %d: %w", outputIndex, + err) + } + + _, err = qtx.GetUtxoSpendByOutpoint( + ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: convertedIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("lookup credit output %d: %w", outputIndex, + err) + } + + return true, nil +} + +// markInputsSpentPg attaches wallet-owned outpoints spent by the stored +// transaction to its row ID and input indexes. +// +// If another wallet transaction already owns the spend edge for a +// wallet-controlled input, the create path fails with ErrTxInputConflict +// instead of silently storing a second spender. Inputs that reference a +// wallet-owned output whose parent transaction is already invalid fail with +// ErrTxInputInvalidParent. +func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, + params CreateTxParams, txID int64) error { + + if blockchain.IsCoinBaseTx(params.Tx) { + return nil + } + + for inputIndex, txIn := range params.Tx.TxIn { + outputIndex, err := uint32ToInt32(txIn.PreviousOutPoint.Index) + if err != nil { + return fmt.Errorf("convert input outpoint index %d: %w", inputIndex, + err) + } + + spentInputIndex, err := int64ToInt32(int64(inputIndex)) + if err != nil { + return fmt.Errorf("convert input index %d: %w", inputIndex, err) + } + + rowsAffected, err := qtx.MarkUtxoSpent(ctx, sqlcpg.MarkUtxoSpentParams{ + WalletID: int64(params.WalletID), + TxHash: txIn.PreviousOutPoint.Hash[:], + OutputIndex: outputIndex, + SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, + SpentInputIndex: sql.NullInt32{Int32: spentInputIndex, Valid: true}, + }) + if err != nil { + return fmt.Errorf("mark spent input %d: %w", inputIndex, err) + } + + if rowsAffected == 0 { + err = ensureSpendConflictPg( + ctx, qtx, params.WalletID, txIn.PreviousOutPoint.Hash, + outputIndex, txID, + ) + if err != nil { + return fmt.Errorf("mark spent input %d: %w", inputIndex, err) + } + } + } + + return nil +} + +// ensureSpendConflictPg reports ErrTxInputConflict when the referenced outpoint +// is wallet-owned, still eligible for spending, and already attached to another +// transaction. If the wallet owns the parent output but that parent is already +// invalid, the helper returns ErrTxInputInvalidParent instead. +func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash, outputIndex int32, + txID int64) error { + + spendByTxID, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ensureWalletParentValidPg( + ctx, qtx, walletID, txHash, outputIndex, + ) + } + + return fmt.Errorf("check spend conflict: %w", err) + } + + if spendByTxID.Valid && spendByTxID.Int64 != txID { + return ErrTxInputConflict + } + + return nil +} + +// ensureWalletParentValidPg reports ErrTxInputInvalidParent when the wallet +// owns the referenced outpoint but its parent transaction is already invalid. +func ensureWalletParentValidPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash, outputIndex int32) error { + + hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( + ctx, sqlcpg.HasInvalidWalletUtxoByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + return fmt.Errorf("check invalid wallet parent: %w", err) + } + + if hasInvalid { + return ErrTxInputInvalidParent + } + + return nil +} diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go new file mode 100644 index 0000000000..9549dc1138 --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -0,0 +1,299 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// CreateTx atomically records a wallet-scoped transaction row, its wallet-owned +// credits, and any spend edges created by its inputs. +// +// The full write runs inside ExecuteTx so the transaction row, created UTXOs, +// and spent-parent markers are either committed together or not at all. +// Received timestamps are normalized to UTC before insert. CreateTx is +// insert-only and returns ErrTxAlreadyExists if the wallet already stores the +// tx hash. +func (s *SqliteStore) CreateTx(ctx context.Context, + params CreateTxParams) error { + + req, err := newCreateTxRequest(params) + if err != nil { + return err + } + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return createTxWithOps(ctx, req, &sqliteCreateTxOps{qtx: qtx}) + }) +} + +// sqliteCreateTxOps adapts sqlite sqlc queries to the shared CreateTx flow. +type sqliteCreateTxOps struct { + qtx *sqlcsqlite.Queries + + blockHeight sql.NullInt64 +} + +var _ createTxOps = (*sqliteCreateTxOps)(nil) + +// hasExisting reports whether the wallet already stores the requested tx hash. +func (o *sqliteCreateTxOps) hasExisting(ctx context.Context, + req createTxRequest) (bool, error) { + + _, err := o.qtx.GetTransactionMetaByHash( + ctx, + sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + }, + ) + + // Exit early if there is no error. + if err == nil { + return true, nil + } + + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("get tx metadata: %w", err) +} + +// prepareBlock validates the optional confirming block and caches the sqlite +// block-height value that the later insert query will store. +func (o *sqliteCreateTxOps) prepareBlock(ctx context.Context, + req createTxRequest) error { + + o.blockHeight = sql.NullInt64{} + + if req.params.Block == nil { + return nil + } + + height, err := requireBlockMatchesSqlite(ctx, o.qtx, req.params.Block) + if err != nil { + return err + } + + o.blockHeight = sql.NullInt64{Int64: height, Valid: true} + + return nil +} + +// insert stores one new sqlite transaction row for CreateTx. +func (o *sqliteCreateTxOps) insert(ctx context.Context, + req createTxRequest) (int64, error) { + + txID, err := o.qtx.InsertTransaction( + ctx, + sqlcsqlite.InsertTransactionParams{ + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + RawTx: req.rawTx, + BlockHeight: o.blockHeight, + TxStatus: int64(req.params.Status), + ReceivedTime: req.received, + IsCoinbase: req.isCoinbase, + TxLabel: req.params.Label, + }, + ) + if err != nil { + return 0, fmt.Errorf("insert tx row: %w", err) + } + + return txID, nil +} + +// insertCredits stores any wallet-owned outputs created by the transaction. +func (o *sqliteCreateTxOps) insertCredits(ctx context.Context, + req createTxRequest, txID int64) error { + + return insertCreditsSqlite(ctx, o.qtx, req.params, txID) +} + +// markInputsSpent records wallet-owned inputs spent by the transaction. +func (o *sqliteCreateTxOps) markInputsSpent(ctx context.Context, + req createTxRequest, txID int64) error { + + return markInputsSpentSqlite(ctx, o.qtx, req.params, txID) +} + +// insertCreditsSqlite inserts one wallet-owned UTXO row for each credited +// output of the transaction being stored. +func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + params CreateTxParams, txID int64) error { + + for index := range params.Credits { + creditExists, err := creditExistsSqlite( + ctx, qtx, params.WalletID, params.Tx.TxHash(), index, + ) + if err != nil { + return err + } + + if creditExists { + continue + } + + pkScript := params.Tx.TxOut[index].PkScript + + addrRow, err := qtx.GetAddressByScriptPubKey( + ctx, sqlcsqlite.GetAddressByScriptPubKeyParams{ + ScriptPubKey: pkScript, + WalletID: int64(params.WalletID), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("credit output %d: %w", index, + ErrAddressNotFound) + } + + return fmt.Errorf("resolve credit address %d: %w", index, err) + } + + _, err = qtx.InsertUtxo(ctx, sqlcsqlite.InsertUtxoParams{ + WalletID: int64(params.WalletID), + TxID: txID, + OutputIndex: int64(index), + Amount: params.Tx.TxOut[index].Value, + AddressID: addrRow.ID, + }) + if err != nil { + return fmt.Errorf("insert credit output %d: %w", index, err) + } + } + + return nil +} + +// creditExistsSqlite reports whether the wallet already has a UTXO row for the +// given credited output, even if that output is now spent by a child tx. +func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { + + _, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: int64(outputIndex), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("lookup credit output %d: %w", outputIndex, + err) + } + + return true, nil +} + +// markInputsSpentSqlite attaches wallet-owned outpoints spent by the stored +// transaction to its row ID and input indexes. +// +// If another wallet transaction already owns the spend edge for a +// wallet-controlled input, the create path fails with ErrTxInputConflict +// instead of silently storing a second spender. Inputs that reference a +// wallet-owned output whose parent transaction is already invalid fail with +// ErrTxInputInvalidParent. +func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + params CreateTxParams, txID int64) error { + + if blockchain.IsCoinBaseTx(params.Tx) { + return nil + } + + for inputIndex, txIn := range params.Tx.TxIn { + spentInputIndex := sql.NullInt64{Int64: int64(inputIndex), Valid: true} + + rowsAffected, err := qtx.MarkUtxoSpent(ctx, + sqlcsqlite.MarkUtxoSpentParams{ + WalletID: int64(params.WalletID), + TxHash: txIn.PreviousOutPoint.Hash[:], + OutputIndex: int64(txIn.PreviousOutPoint.Index), + SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, + SpentInputIndex: spentInputIndex, + }) + if err != nil { + return fmt.Errorf("mark spent input %d: %w", inputIndex, err) + } + + if rowsAffected == 0 { + err = ensureSpendConflictSqlite( + ctx, qtx, params.WalletID, txIn.PreviousOutPoint.Hash, + int64(txIn.PreviousOutPoint.Index), txID, + ) + if err != nil { + return fmt.Errorf("mark spent input %d: %w", inputIndex, err) + } + } + } + + return nil +} + +// ensureSpendConflictSqlite reports ErrTxInputConflict when the referenced +// outpoint is wallet-owned, still eligible for spending, and already attached +// to another transaction. If the wallet owns the parent output but that parent +// is already invalid, the helper returns ErrTxInputInvalidParent instead. +func ensureSpendConflictSqlite(ctx context.Context, + qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, + outputIndex int64, txID int64) error { + + spendByTxID, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ensureWalletParentValidSqlite( + ctx, qtx, walletID, txHash, outputIndex, + ) + } + + return fmt.Errorf("check spend conflict: %w", err) + } + + if spendByTxID.Valid && spendByTxID.Int64 != txID { + return ErrTxInputConflict + } + + return nil +} + +// ensureWalletParentValidSqlite reports ErrTxInputInvalidParent when the +// wallet owns the referenced outpoint but its parent transaction is already +// invalid. +func ensureWalletParentValidSqlite(ctx context.Context, + qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, + outputIndex int64) error { + + hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( + ctx, sqlcsqlite.HasInvalidWalletUtxoByOutpointParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + return fmt.Errorf("check invalid wallet parent: %w", err) + } + + if hasInvalid { + return ErrTxInputInvalidParent + } + + return nil +} From d76ef396b951aed172e3a7216f1956692c4e1508 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:01:09 +0800 Subject: [PATCH 476/691] wallet: add GetTx Add the postgres and sqlite GetTx implementations together with GetTx integration coverage. Keeping the row-to-domain rebuild logic with the method commit makes the read path easy to review without mixing in unrelated shared ops work. --- .../internal/db/itest/tx_utxo_store_test.go | 72 +++++++++++++++++++ wallet/internal/db/postgres_txstore_gettx.go | 61 ++++++++++++++++ wallet/internal/db/sqlite_txstore_gettx.go | 61 ++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_gettx.go create mode 100644 wallet/internal/db/sqlite_txstore_gettx.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index f39f207713..45579c1001 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -301,6 +301,78 @@ func TestCreateTxRejectsDuplicateTx(t *testing.T) { require.True(t, ok) } +// TestGetTxReturnsStoredPendingTx verifies that GetTx rebuilds the public +// transaction view for one stored unmined row. +// +// Scenario: +// - One pending wallet transaction has already been inserted. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// +// Action: +// - Retrieve the transaction through GetTx. +// +// Assertions: +// - GetTx returns the stored hash, status, label, and nil block metadata. +func TestGetTxReturnsStoredPendingTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-tx") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000600, 0), + Status: db.TxStatusPending, + Label: "pending-note", + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, tx.TxHash(), info.Hash) + require.Equal(t, db.TxStatusPending, info.Status) + require.Equal(t, "pending-note", info.Label) + require.Nil(t, info.Block) +} + +// TestGetTxNotFound verifies that GetTx returns ErrTxNotFound when the wallet +// has no matching transaction row. +// +// Scenario: +// - One wallet has no stored transaction for the requested hash. +// +// Setup: +// - Create one wallet and choose one random transaction hash. +// +// Action: +// - Query the missing hash through GetTx. +// +// Assertions: +// - GetTx returns ErrTxNotFound. +func TestGetTxNotFound(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-tx-missing") + + _, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: RandomHash(), + }) + require.ErrorIs(t, err, db.ErrTxNotFound) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_gettx.go b/wallet/internal/db/postgres_txstore_gettx.go new file mode 100644 index 0000000000..80f0c56955 --- /dev/null +++ b/wallet/internal/db/postgres_txstore_gettx.go @@ -0,0 +1,61 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// GetTx retrieves one wallet-scoped transaction snapshot by hash. +// +// The returned TxInfo is rebuilt from normalized SQL columns; missing rows map +// to ErrTxNotFound for the requested wallet/hash pair. +func (s *PostgresStore) GetTx(ctx context.Context, + query GetTxQuery) (*TxInfo, error) { + + row, err := s.queries.GetTransactionByHash( + ctx, sqlcpg.GetTransactionByHashParams{ + WalletID: int64(query.WalletID), + TxHash: query.Txid[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("tx %s: %w", query.Txid, ErrTxNotFound) + } + + return nil, fmt.Errorf("get tx: %w", err) + } + + return txInfoFromPgRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, + ) +} + +// txInfoFromPgRow converts one normalized postgres query row into the public +// TxInfo shape. +func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, + blockHeight sql.NullInt32, blockHash []byte, blockTimestamp sql.NullInt64, + status int64, label string) (*TxInfo, error) { + + var ( + block *Block + err error + ) + + // Unmined rows legitimately have no block metadata, so only build the Block + // shape when the row still carries a valid height. + if blockHeight.Valid { + block, err = buildPgBlock(blockHeight, blockHash, blockTimestamp) + if err != nil { + return nil, err + } + } + + return buildTxInfo(hash, rawTx, received, block, status, label) +} diff --git a/wallet/internal/db/sqlite_txstore_gettx.go b/wallet/internal/db/sqlite_txstore_gettx.go new file mode 100644 index 0000000000..c276800380 --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_gettx.go @@ -0,0 +1,61 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// GetTx retrieves one wallet-scoped transaction snapshot by hash. +// +// The returned TxInfo is rebuilt from normalized SQL columns; missing rows map +// to ErrTxNotFound for the requested wallet/hash pair. +func (s *SqliteStore) GetTx(ctx context.Context, + query GetTxQuery) (*TxInfo, error) { + + row, err := s.queries.GetTransactionByHash( + ctx, sqlcsqlite.GetTransactionByHashParams{ + WalletID: int64(query.WalletID), + TxHash: query.Txid[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("tx %s: %w", query.Txid, ErrTxNotFound) + } + + return nil, fmt.Errorf("get tx: %w", err) + } + + return txInfoFromSqliteRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, + ) +} + +// txInfoFromSqliteRow converts one normalized sqlite query row into the public +// TxInfo shape. +func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, + blockHeight sql.NullInt64, blockHash []byte, blockTimestamp sql.NullInt64, + status int64, label string) (*TxInfo, error) { + + var ( + block *Block + err error + ) + + // Unmined rows legitimately have no block metadata, so only build the Block + // shape when the row still carries a valid height. + if blockHeight.Valid { + block, err = buildSqliteBlock(blockHeight, blockHash, blockTimestamp) + if err != nil { + return nil, err + } + } + + return buildTxInfo(hash, rawTx, received, block, status, label) +} From c88c6811a70f6c530750cda4d6a636dadf22b1ef Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:02:21 +0800 Subject: [PATCH 477/691] wallet: add UpdateTx ops Add the shared UpdateTx validation and patch orchestration used by both SQL backends. Landing the common patch flow first keeps the later backend-specific state and label writes isolated in the UpdateTx method commit. --- wallet/internal/db/tx_store_common.go | 131 +++++++++++++++++++++ wallet/internal/db/tx_store_common_test.go | 99 ++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 9ea4af3d9d..a591212c64 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -329,3 +329,134 @@ func createTxWithOps(ctx context.Context, req createTxRequest, return nil } + +// validateUpdateTxParams checks that UpdateTx received at least one mutable +// field and that any requested state transition satisfies the transaction table +// invariants. +func validateUpdateTxParams(params UpdateTxParams, isCoinbase bool) error { + if params.Label == nil && params.State == nil { + return fmt.Errorf("%w: UpdateTx requires at least one field", + ErrInvalidParam) + } + + if params.State != nil { + return validateUpdateTxState(*params.State, isCoinbase) + } + + return nil +} + +// validateUpdateTxState checks the block/status combinations UpdateTx may store +// on an existing row. +func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { + _, err := parseTxStatus(int64(state.Status)) + if err != nil { + return fmt.Errorf("%w: status %d is not supported: %w", + ErrInvalidParam, state.Status, ErrInvalidStatus) + } + + // Only disconnected coinbase rows become orphaned. Ordinary + // transactions use the replaced/failed states instead, so UpdateTx + // must reject orphaned transitions for non-coinbase history. + if !isCoinbase && state.Status == TxStatusOrphaned { + return fmt.Errorf("%w: non-coinbase txns cannot be orphaned: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + // Any row with a confirming block represents mined history, and mined + // wallet history is always published from the wallet's point of view. + if state.Block != nil && state.Status != TxStatusPublished { + return fmt.Errorf("%w: confirmed txns must be published: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + // A unmined coinbase row only appears after rollback disconnects its block, + // at which point the row must be marked orphaned rather than treated as an + // active unmined transaction. + if isCoinbase && state.Block == nil && state.Status != TxStatusOrphaned { + return fmt.Errorf("%w: unmined coinbase txns must be orphaned: %w", + ErrInvalidParam, ErrInvalidStatus) + } + + return nil +} + +// updateTxOps is the minimal backend adapter the shared UpdateTx workflow +// needs. +// +// The shared UpdateTx algorithm is intentionally ordered: +// - load the target row metadata first +// - validate the requested label/state patch against that metadata +// - prepare any backend-specific block/status params next +// - apply the label patch if requested +// - apply the state patch last +// +// Keeping that sequence documented here matters because UpdateTx is +// deliberately row-local. The backend adapters only supply query wiring. The +// shared helper owns the mutation ordering and the invariants that keep +// block/status patches from accidentally turning into graph-level +// reconciliation. +type updateTxOps interface { + // loadIsCoinbase returns whether the existing row is coinbase history + // so the shared validation can enforce orphaning rules correctly. + loadIsCoinbase(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (bool, error) + + // prepareState validates and caches any backend-specific block/status + // params needed for the later row update. + prepareState(ctx context.Context, state UpdateTxState) error + + // updateLabel applies one user-visible label patch. + updateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, + label string) error + + // updateState applies one block/status patch after prepareState succeeds. + updateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, + state UpdateTxState) error +} + +// updateTxWithOps runs the shared UpdateTx patch workflow inside one backend- +// specific SQL transaction. +// +// The helper validates the existing row first, prepares any requested state +// patch next, and then applies the label patch and state patch in that order. +// That keeps block validation and row mutation inside one transaction while +// still allowing callers to update either field independently. +func updateTxWithOps(ctx context.Context, params UpdateTxParams, + ops updateTxOps) error { + + isCoinbase, err := ops.loadIsCoinbase( + ctx, params.WalletID, params.Txid, + ) + if err != nil { + return fmt.Errorf("load update tx target: %w", err) + } + + err = validateUpdateTxParams(params, isCoinbase) + if err != nil { + return err + } + + if params.State != nil { + err = ops.prepareState(ctx, *params.State) + if err != nil { + return fmt.Errorf("prepare tx state update: %w", err) + } + } + + if params.Label != nil { + err = ops.updateLabel(ctx, params.WalletID, params.Txid, *params.Label) + if err != nil { + return fmt.Errorf("update tx label: %w", err) + } + } + + if params.State != nil { + err = ops.updateState(ctx, params.WalletID, params.Txid, *params.State) + if err != nil { + return fmt.Errorf("update tx state: %w", err) + } + } + + return nil +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 6835ac8847..e2d1910f50 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -462,3 +462,102 @@ func testCreateTxRequest(t *testing.T) createTxRequest { return req } + +// TestUpdateTxWithOpsLabelAndState verifies that the shared UpdateTx workflow +// can apply both a label patch and a state patch in one atomic sequence. +func TestUpdateTxWithOpsLabelAndState(t *testing.T) { + t.Parallel() + + // Arrange: Build one request with both a label patch and a state patch. + label := "note" + params := UpdateTxParams{ + WalletID: 5, + Txid: chainhash.Hash{1}, + Label: &label, + State: &UpdateTxState{ + Status: TxStatusPublished, + }, + } + ops := &stubUpdateTxOps{isCoinbase: false} + + // Act: Run updateTxWithOps against a stub backend adapter. + err := updateTxWithOps(context.Background(), params, ops) + require.NoError(t, err) + + // Assert: The shared flow loads, prepares, and applies both patches. + require.Equal(t, + []string{"load", "prepare-state", "label", "state"}, + ops.calls, + ) + require.Equal(t, label, ops.updatedLabel) + require.Equal(t, TxStatusPublished, ops.updatedState.Status) +} + +// TestUpdateTxWithOpsEmptyPatch verifies that the shared UpdateTx helper +// rejects requests that do not ask to mutate any field. +func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { + t.Parallel() + + params := UpdateTxParams{ + WalletID: 5, + Txid: chainhash.Hash{1}, + } + ops := &stubUpdateTxOps{isCoinbase: false} + + err := updateTxWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrInvalidParam) + require.Equal(t, []string{"load"}, ops.calls) +} + +// stubUpdateTxOps records how the shared UpdateTx helper drives one backend +// adapter while letting tests control the loaded metadata. +type stubUpdateTxOps struct { + isCoinbase bool + calls []string + updatedLabel string + updatedState UpdateTxState +} + +var _ updateTxOps = (*stubUpdateTxOps)(nil) + +// loadIsCoinbase records that the shared flow loaded the existing transaction +// row metadata. +func (s *stubUpdateTxOps) loadIsCoinbase(_ context.Context, _ uint32, + _ chainhash.Hash) (bool, error) { + + s.calls = append(s.calls, "load") + + return s.isCoinbase, nil +} + +// prepareState records that the shared flow validated and prepared one state +// patch before applying it. +func (s *stubUpdateTxOps) prepareState(_ context.Context, + _ UpdateTxState) error { + + s.calls = append(s.calls, "prepare-state") + + return nil +} + +// updateLabel records the label value the shared flow asked the backend to +// write. +func (s *stubUpdateTxOps) updateLabel(_ context.Context, _ uint32, + _ chainhash.Hash, label string) error { + + s.calls = append(s.calls, "label") + s.updatedLabel = label + + return nil +} + +// updateState records the state patch the shared flow asked the backend to +// write. +func (s *stubUpdateTxOps) updateState(_ context.Context, _ uint32, + _ chainhash.Hash, state UpdateTxState) error { + + s.calls = append(s.calls, "state") + s.updatedState = state + + return nil +} From 47fc36184783f6a85ca9e598bee76e2fe44df30c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:03:22 +0800 Subject: [PATCH 478/691] wallet: add UpdateTx Add the postgres and sqlite UpdateTx implementations together with UpdateTx integration coverage. This keeps the backend-specific state writes and label updates in one place now that block validation helpers are already shared with CreateTx. --- .../internal/db/itest/tx_utxo_store_test.go | 301 ++++++++++++++++++ .../internal/db/postgres_txstore_updatetx.go | 134 ++++++++ wallet/internal/db/sqlite_txstore_updatetx.go | 134 ++++++++ 3 files changed, 569 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_updatetx.go create mode 100644 wallet/internal/db/sqlite_txstore_updatetx.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 45579c1001..273e1e7fe8 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -373,6 +373,307 @@ func TestGetTxNotFound(t *testing.T) { require.ErrorIs(t, err, db.ErrTxNotFound) } +// TestUpdateTxRequiresExistingConfirmedBlock verifies that UpdateTx rejects a +// state patch whose referenced block height is missing from the shared blocks +// table. +// +// Scenario: +// - One stored pending transaction is later patched with a missing block. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// - Build one block reference without inserting that block row. +// +// Action: +// - Apply the confirmation patch through UpdateTx. +// +// Assertions: +// - UpdateTx returns ErrBlockNotFound. +// - The transaction remains unconfirmed. +func TestUpdateTxRequiresExistingConfirmedBlock(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-confirmed-tx-missing-block") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + block := db.Block{ + Hash: RandomHash(), + Height: 240, + Timestamp: time.Unix(1710000560, 0), + } + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000570, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + State: &db.UpdateTxState{ + Block: &block, + Status: db.TxStatusPublished, + }, + }) + require.ErrorIs(t, err, db.ErrBlockNotFound) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.Nil(t, info.Block) +} + +// TestUpdateTxRejectsMismatchedConfirmedBlock verifies that UpdateTx rejects a +// state patch when the supplied block metadata does not match the stored block +// row for that height. +// +// Scenario: +// - One stored pending transaction is later patched with mismatched block +// metadata for an existing height. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// - Insert the real block row for the target height. +// - Build a second block reference with the same height but different hash. +// +// Action: +// - Apply the mismatched confirmation patch through UpdateTx. +// +// Assertions: +// - UpdateTx returns ErrBlockMismatch. +// - The existing transaction row remains unconfirmed and pending. +func TestUpdateTxRejectsMismatchedConfirmedBlock(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-tx-block-mismatch") + queries := store.Queries() + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000550, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + block := CreateBlockFixture(t, queries, 240) + mismatchBlock := block + mismatchBlock.Hash = RandomHash() + + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + State: &db.UpdateTxState{ + Block: &mismatchBlock, + Status: db.TxStatusPublished, + }, + }) + require.ErrorIs(t, err, db.ErrBlockMismatch) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.Nil(t, info.Block) + require.Equal(t, db.TxStatusPending, info.Status) +} + +// TestUpdateTxUpdatesStoredLabel verifies that UpdateTx can patch the stored +// user-visible label without mutating chain-state metadata. +// +// Scenario: +// - One pending wallet transaction already exists with an old label. +// +// Setup: +// - Create one wallet and insert one pending transaction row with a label. +// +// Action: +// - Patch only the label through UpdateTx. +// +// Assertions: +// - The stored label changes. +// - The transaction stays pending and unconfirmed. +func TestUpdateTxUpdatesStoredLabel(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-tx-label") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000700, 0), + Status: db.TxStatusPending, + Label: "old-label", + }) + require.NoError(t, err) + + label := "new-label" + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + Label: &label, + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, "new-label", info.Label) + require.Equal(t, db.TxStatusPending, info.Status) +} + +// TestUpdateTxConfirmsStoredPendingTx verifies that UpdateTx can attach a +// confirming block to an already-stored unmined row. +// +// Scenario: +// - One pending wallet transaction is later observed in a block. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// - Insert one matching block row. +// +// Action: +// - Apply a published state patch with that block through UpdateTx. +// +// Assertions: +// - The transaction now carries the block metadata. +// - The status becomes published. +func TestUpdateTxConfirmsStoredPendingTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-tx-confirm") + queries := store.Queries() + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 6000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000710, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + block := CreateBlockFixture(t, queries, 220) + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + State: &db.UpdateTxState{ + Block: &block, + Status: db.TxStatusPublished, + }, + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.NotNil(t, info.Block) + require.Equal(t, block.Height, info.Block.Height) + require.Equal(t, block.Hash, info.Block.Hash) + require.Equal(t, db.TxStatusPublished, info.Status) +} + +// TestUpdateTxNotFound verifies that UpdateTx returns ErrTxNotFound when the +// wallet has no matching transaction row. +// +// Scenario: +// - One wallet has no stored transaction for the requested hash. +// +// Setup: +// - Create one wallet and one label patch. +// +// Action: +// - Apply the patch to a random missing tx hash. +// +// Assertions: +// - UpdateTx returns ErrTxNotFound. +func TestUpdateTxNotFound(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-label-missing") + + label := "new-label" + err := store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: RandomHash(), + Label: &label, + }) + require.ErrorIs(t, err, db.ErrTxNotFound) +} + +// TestUpdateTxRejectsEmptyPatch verifies that UpdateTx rejects a request that +// does not ask to mutate any transaction field. +// +// Scenario: +// - One wallet transaction exists, but the caller provides no label or state +// mutation. +// +// Setup: +// - Create one wallet and insert one pending transaction row. +// +// Action: +// - Call UpdateTx with an empty patch. +// +// Assertions: +// - UpdateTx returns ErrInvalidParam. +func TestUpdateTxRejectsEmptyPatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-empty-patch") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 6000, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000720, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.ErrorIs(t, err, db.ErrInvalidParam) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_updatetx.go b/wallet/internal/db/postgres_txstore_updatetx.go new file mode 100644 index 0000000000..72bcb609f1 --- /dev/null +++ b/wallet/internal/db/postgres_txstore_updatetx.go @@ -0,0 +1,134 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// UpdateTx patches the mutable metadata for one wallet-scoped transaction. +// +// UpdateTx may edit the user-visible label, the block/status view, or both in +// one SQL transaction. Immutable transaction facts such as raw_tx, credits, and +// spent-input edges stay owned by CreateTx and the internal rollback/delete +// flows. +func (s *PostgresStore) UpdateTx(ctx context.Context, + params UpdateTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return updateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) + }) +} + +// pgUpdateTxOps adapts postgres sqlc queries to the shared UpdateTx flow. +type pgUpdateTxOps struct { + // qtx is the transaction-scoped postgres query set used by UpdateTx. + qtx *sqlcpg.Queries + + // blockHeight caches the validated postgres block-height wrapper prepared + // for the later state update query. + blockHeight sql.NullInt32 + + // status caches the postgres status code prepared for the later state + // update query. + status int16 +} + +var _ updateTxOps = (*pgUpdateTxOps)(nil) + +// loadIsCoinbase loads the existing row metadata UpdateTx needs before it can +// validate one patch. +func (o *pgUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (bool, error) { + + meta, err := o.qtx.GetTransactionMetaByHash( + ctx, + sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return false, fmt.Errorf("get tx metadata: %w", err) + } + + return meta.IsCoinbase, nil +} + +// prepareState validates any referenced confirming block and captures the +// postgres-specific state params for the later row update. +func (o *pgUpdateTxOps) prepareState(ctx context.Context, + state UpdateTxState) error { + + blockHeight := sql.NullInt32{} + + if state.Block != nil { + height, err := requireBlockMatchesPg(ctx, o.qtx, state.Block) + if err != nil { + return fmt.Errorf("require confirming block: %w", err) + } + + blockHeight = sql.NullInt32{Int32: height, Valid: true} + } + + o.blockHeight = blockHeight + o.status = int16(state.Status) + + return nil +} + +// updateState writes one block/status patch after prepareState has validated +// any referenced block metadata. +func (o *pgUpdateTxOps) updateState(ctx context.Context, walletID uint32, + txHash chainhash.Hash, _ UpdateTxState) error { + + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, + sqlcpg.UpdateTransactionStateByHashParams{ + BlockHeight: o.blockHeight, + Status: o.status, + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return nil +} + +// updateLabel writes one user-visible label change. +func (o *pgUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, + txHash chainhash.Hash, label string) error { + + rows, err := o.qtx.UpdateTransactionLabelByHash( + ctx, + sqlcpg.UpdateTransactionLabelByHashParams{ + Label: label, + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx label query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return nil +} diff --git a/wallet/internal/db/sqlite_txstore_updatetx.go b/wallet/internal/db/sqlite_txstore_updatetx.go new file mode 100644 index 0000000000..3444543f97 --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_updatetx.go @@ -0,0 +1,134 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// UpdateTx patches the mutable metadata for one wallet-scoped transaction. +// +// UpdateTx may edit the user-visible label, the block/status view, or both in +// one SQL transaction. Immutable transaction facts such as raw_tx, credits, and +// spent-input edges stay owned by CreateTx and the internal rollback/delete +// flows. +func (s *SqliteStore) UpdateTx(ctx context.Context, + params UpdateTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return updateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) + }) +} + +// sqliteUpdateTxOps adapts sqlite sqlc queries to the shared UpdateTx flow. +type sqliteUpdateTxOps struct { + // qtx is the transaction-scoped sqlite query set used by UpdateTx. + qtx *sqlcsqlite.Queries + + // blockHeight caches the validated sqlite block-height wrapper prepared for + // the later state update query. + blockHeight sql.NullInt64 + + // status caches the sqlite status code prepared for the later state update + // query. + status int64 +} + +var _ updateTxOps = (*sqliteUpdateTxOps)(nil) + +// loadIsCoinbase loads the existing row metadata UpdateTx needs before it can +// validate one patch. +func (o *sqliteUpdateTxOps) loadIsCoinbase(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (bool, error) { + + meta, err := o.qtx.GetTransactionMetaByHash( + ctx, + sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return false, fmt.Errorf("get tx metadata: %w", err) + } + + return meta.IsCoinbase, nil +} + +// prepareState validates any referenced confirming block and captures the +// sqlite-specific state params for the later row update. +func (o *sqliteUpdateTxOps) prepareState(ctx context.Context, + state UpdateTxState) error { + + blockHeight := sql.NullInt64{} + + if state.Block != nil { + height, err := requireBlockMatchesSqlite(ctx, o.qtx, state.Block) + if err != nil { + return fmt.Errorf("require confirming block: %w", err) + } + + blockHeight = sql.NullInt64{Int64: height, Valid: true} + } + + o.blockHeight = blockHeight + o.status = int64(state.Status) + + return nil +} + +// updateLabel writes one user-visible label change. +func (o *sqliteUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, + txHash chainhash.Hash, label string) error { + + rows, err := o.qtx.UpdateTransactionLabelByHash( + ctx, + sqlcsqlite.UpdateTransactionLabelByHashParams{ + Label: label, + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx label query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return nil +} + +// updateState writes one block/status patch after prepareState has validated +// any referenced block metadata. +func (o *sqliteUpdateTxOps) updateState(ctx context.Context, walletID uint32, + txHash chainhash.Hash, _ UpdateTxState) error { + + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, + sqlcsqlite.UpdateTransactionStateByHashParams{ + BlockHeight: o.blockHeight, + Status: o.status, + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return nil +} From c11950bdbaaf75b8316de4f450dd541bebb48814 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:04:15 +0800 Subject: [PATCH 479/691] wallet: add ListTxns Add the postgres and sqlite ListTxns implementations together with ListTxns integration coverage. This keeps the confirmed-range and unmined-only read paths grouped with the tests that exercise their wallet-visible results. --- .../internal/db/itest/tx_utxo_store_test.go | 185 ++++++++++++++++++ .../internal/db/postgres_txstore_listtxns.go | 104 ++++++++++ wallet/internal/db/sqlite_txstore_listtxns.go | 94 +++++++++ 3 files changed, 383 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_listtxns.go create mode 100644 wallet/internal/db/sqlite_txstore_listtxns.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 273e1e7fe8..5af407a592 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -674,6 +674,191 @@ func TestUpdateTxRejectsEmptyPatch(t *testing.T) { require.ErrorIs(t, err, db.ErrInvalidParam) } +// TestListTxnsReturnsRowsWithoutBlock verifies that the no-confirming-block +// query path excludes confirmed rows while still surfacing retained invalid +// history that no longer has block metadata. +// +// Scenario: +// - One wallet has confirmed history, active unmined history, and retained +// invalid history without blocks. +// +// Setup: +// - Insert one confirmed transaction, one pending transaction, and one failed +// transaction whose block is nil. +// +// Action: +// - Query ListTxns with UnminedOnly set. +// +// Assertions: +// - Only unmined rows are returned. +// - Both the active pending row and the failed history row are present. +func TestListTxnsReturnsRowsWithoutBlock(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-txns-without-block") + queries := store.Queries() + + confirmedBlock := CreateBlockFixture(t, queries, 200) + confirmedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 7000, PkScript: []byte{0x51}}}, + ) + unminedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8000, PkScript: []byte{0x52}}}, + ) + failedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8100, PkScript: []byte{0x53}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedTx, + Received: time.Unix(1710000800, 0), + Status: db.TxStatusPublished, + }) + require.NoError(t, err) + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: confirmedTx.TxHash(), + State: &db.UpdateTxState{ + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: unminedTx, + Received: time.Unix(1710000810, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: failedTx, + Received: time.Unix(1710000815, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + setTxStatus(t, store, walletID, failedTx.TxHash(), db.TxStatusFailed) + + infos, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }) + require.NoError(t, err) + require.Len(t, infos, 2) + + statusesByHash := make(map[chainhash.Hash]db.TxStatus, len(infos)) + for _, info := range infos { + require.Nil(t, info.Block) + statusesByHash[info.Hash] = info.Status + } + + require.Equal(t, db.TxStatusPending, statusesByHash[unminedTx.TxHash()]) + require.Equal(t, db.TxStatusFailed, statusesByHash[failedTx.TxHash()]) +} + +// TestListTxnsReturnsConfirmedTxsByHeightRange verifies that the +// confirmed-range query path excludes unmined rows and respects the height +// bounds. +// +// Scenario: +// - One wallet has confirmed transactions at multiple heights plus one +// unmined row. +// +// Setup: +// - Insert two confirmed transactions at different heights and one pending +// transaction without a block. +// +// Action: +// - Query ListTxns for one exact confirmed height range. +// +// Assertions: +// - Only the matching confirmed transaction is returned. +// - The unmined row is excluded. +func TestListTxnsReturnsConfirmedTxsByHeightRange(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-txns-confirmed") + queries := store.Queries() + + blockOne := CreateBlockFixture(t, queries, 210) + blockTwo := CreateBlockFixture(t, queries, 211) + + txOne := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 9000, PkScript: []byte{0x51}}}, + ) + txTwo := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 10000, PkScript: []byte{0x52}}}, + ) + unminedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 11000, PkScript: []byte{0x53}}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txOne, + Received: time.Unix(1710000900, 0), + Status: db.TxStatusPublished, + }) + require.NoError(t, err) + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: txOne.TxHash(), + State: &db.UpdateTxState{ + Block: &blockOne, + Status: db.TxStatusPublished, + }, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txTwo, + Received: time.Unix(1710000910, 0), + Status: db.TxStatusPublished, + }) + require.NoError(t, err) + err = store.UpdateTx(t.Context(), db.UpdateTxParams{ + WalletID: walletID, + Txid: txTwo.TxHash(), + State: &db.UpdateTxState{ + Block: &blockTwo, + Status: db.TxStatusPublished, + }, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: unminedTx, + Received: time.Unix(1710000920, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + infos, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: 211, + EndHeight: 211, + }) + require.NoError(t, err) + require.Len(t, infos, 1) + require.Equal(t, txTwo.TxHash(), infos[0].Hash) + require.NotNil(t, infos[0].Block) + require.Equal(t, uint32(211), infos[0].Block.Height) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_listtxns.go b/wallet/internal/db/postgres_txstore_listtxns.go new file mode 100644 index 0000000000..d6977c3364 --- /dev/null +++ b/wallet/internal/db/postgres_txstore_listtxns.go @@ -0,0 +1,104 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// ListTxns lists wallet-scoped transactions using either the confirmed-range +// or unmined-only read path. +// +// The no-confirming-block path returns every row without a confirming block, +// including retained invalid history such as orphaned or failed transactions, +// while the confirmed path is bounded by the requested height range. +func (s *PostgresStore) ListTxns(ctx context.Context, + query ListTxnsQuery) ([]TxInfo, error) { + + if query.UnminedOnly { + return s.listTxnsWithoutBlockPg(ctx, query.WalletID) + } + + return s.listConfirmedTxnsPg(ctx, query) +} + +// listTxnsWithoutBlockPg loads every transaction row that currently has no +// confirming block. This includes the active unmined set together with any +// retained invalid history that rollback or invalidation flows left without a +// confirming block. +func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, + walletID uint32) ([]TxInfo, error) { + + rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) + if err != nil { + return nil, fmt.Errorf("list txns without block: %w", err) + } + + infos := make([]TxInfo, len(rows)) + for i, row := range rows { + info, err := txInfoFromPgRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, + ) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} + +// listConfirmedTxnsPg loads the confirmed height-range view used by ListTxns +// when callers query mined history. +func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, + query ListTxnsQuery) ([]TxInfo, error) { + + startHeight, err := uint32ToInt32(query.StartHeight) + if err != nil { + return nil, fmt.Errorf("convert start height: %w", err) + } + + endHeight, err := uint32ToInt32(query.EndHeight) + if err != nil { + return nil, fmt.Errorf("convert end height: %w", err) + } + + rows, err := s.queries.ListTransactionsByHeightRange( + ctx, sqlcpg.ListTransactionsByHeightRangeParams{ + WalletID: int64(query.WalletID), + StartHeight: startHeight, + EndHeight: endHeight, + }, + ) + if err != nil { + return nil, fmt.Errorf("list txns by height: %w", err) + } + + infos := make([]TxInfo, len(rows)) + for i, row := range rows { + block, err := buildPgBlock( + row.BlockHeight, + row.BlockHash, + sql.NullInt64{Int64: row.BlockTimestamp, Valid: true}, + ) + if err != nil { + return nil, err + } + + info, err := buildTxInfo( + row.TxHash, row.RawTx, row.ReceivedTime, block, + int64(row.TxStatus), row.TxLabel, + ) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} diff --git a/wallet/internal/db/sqlite_txstore_listtxns.go b/wallet/internal/db/sqlite_txstore_listtxns.go new file mode 100644 index 0000000000..cc6fb642ef --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_listtxns.go @@ -0,0 +1,94 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// ListTxns lists wallet-scoped transactions using either the confirmed-range +// or unmined-only read path. +// +// The no-confirming-block path returns every row without a confirming block, +// including retained invalid history such as orphaned or failed transactions, +// while the confirmed path is bounded by the requested height range. +func (s *SqliteStore) ListTxns(ctx context.Context, + query ListTxnsQuery) ([]TxInfo, error) { + + if query.UnminedOnly { + return s.listTxnsWithoutBlockSqlite(ctx, query.WalletID) + } + + return s.listConfirmedTxnsSqlite(ctx, query) +} + +// listTxnsWithoutBlockSqlite loads every transaction row that currently has no +// confirming block. This includes the active unmined set together with any +// retained invalid history that rollback or invalidation flows left without a +// confirming block. +func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, + walletID uint32) ([]TxInfo, error) { + + rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) + if err != nil { + return nil, fmt.Errorf("list txns without block: %w", err) + } + + infos := make([]TxInfo, len(rows)) + for i, row := range rows { + info, err := txInfoFromSqliteRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, + ) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} + +// listConfirmedTxnsSqlite loads the confirmed height-range view used by +// ListTxns when callers query mined history. +func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, + query ListTxnsQuery) ([]TxInfo, error) { + + rows, err := s.queries.ListTransactionsByHeightRange( + ctx, sqlcsqlite.ListTransactionsByHeightRangeParams{ + WalletID: int64(query.WalletID), + StartHeight: int64(query.StartHeight), + EndHeight: int64(query.EndHeight), + }, + ) + if err != nil { + return nil, fmt.Errorf("list txns by height: %w", err) + } + + infos := make([]TxInfo, len(rows)) + for i, row := range rows { + block, err := buildSqliteBlock( + row.BlockHeight, + row.BlockHash, + sql.NullInt64{Int64: row.BlockTimestamp, Valid: true}, + ) + if err != nil { + return nil, err + } + + info, err := buildTxInfo( + row.TxHash, row.RawTx, row.ReceivedTime, block, row.TxStatus, + row.TxLabel, + ) + if err != nil { + return nil, err + } + + infos[i] = *info + } + + return infos, nil +} From 20e0a3be0d3861bc4094cf2a6787811fc542cffe Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:04:42 +0800 Subject: [PATCH 480/691] wallet: add DeleteTx ops Add the shared DeleteTx orchestration and unmined dependency helpers used by both SQL backends. Landing the common leaf-check and cleanup flow first keeps the backend DeleteTx commit focused on query wiring and integration behavior. --- wallet/internal/db/tx_store_common.go | 187 ++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index a591212c64..654217caa6 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -27,6 +27,16 @@ var ( // ErrDuplicateInputOutPoint is returned when CreateTx receives the same // previous outpoint more than once. ErrDuplicateInputOutPoint = errors.New("duplicate input outpoint") + + // ErrDeleteRequiresUnmined indicates that DeleteTx only accepts unmined + // transactions. + ErrDeleteRequiresUnmined = errors.New( + "delete requires an unmined transaction", + ) + + // ErrDeleteRequiresLeaf indicates that DeleteTx only accepts unmined + // transactions with no child spenders. + ErrDeleteRequiresLeaf = errors.New("delete requires a leaf transaction") ) // serializeMsgTx serializes a wire.MsgTx so it can be stored in the @@ -460,3 +470,180 @@ func updateTxWithOps(ctx context.Context, params UpdateTxParams, return nil } + +// deleteTxOps is the minimal backend adapter the shared DeleteTx workflow +// needs. +// +// The shared delete sequence is: +// - load and validate the target unmined row +// - reject deletes that would orphan direct child spenders +// - restore any wallet-owned parents the tx had marked spent +// - delete wallet-owned outputs created by the tx itself +// - delete the transaction row last +// +// DeleteTx is the ordinary leaf-cleanup path, not the invalidation path. The +// shared helper therefore proves the leaf invariant first and only then unwinds +// the wallet-owned state that points at the target row. +type deleteTxOps interface { + // loadDeleteTarget returns the row ID of the unmined transaction + // DeleteTx is allowed to remove. + loadDeleteTarget(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (int64, error) + + // ensureLeaf rejects DeleteTx when the target still has direct + // unmined child spenders. + ensureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, + txID int64) error + + // clearSpentUtxos restores any wallet-owned parent outputs the + // transaction had marked spent. + clearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error + + // deleteCreatedUtxos removes any wallet-owned outputs created by the + // transaction being deleted. + deleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error + + // deleteUnminedTransaction removes the target row after its dependent + // wallet state has been cleaned up. + deleteUnminedTransaction(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (int64, error) +} + +// deleteTxWithOps runs the shared DeleteTx sequence inside a backend-specific +// SQL transaction. +// +// The helper restores wallet-owned parent state before deleting created wallet +// outputs and only removes the transaction row last, so a failed delete cannot +// leave partial wallet bookkeeping behind. +func deleteTxWithOps(ctx context.Context, params DeleteTxParams, + ops deleteTxOps) error { + + txID, err := ops.loadDeleteTarget(ctx, params.WalletID, params.Txid) + if err != nil { + return fmt.Errorf("load delete tx target: %w", err) + } + + err = ops.ensureLeaf(ctx, params.WalletID, params.Txid, txID) + if err != nil { + return fmt.Errorf("check delete tx leaf: %w", err) + } + + err = ops.clearSpentUtxos(ctx, params.WalletID, txID) + if err != nil { + return fmt.Errorf("clear spent utxos: %w", err) + } + + err = ops.deleteCreatedUtxos(ctx, params.WalletID, txID) + if err != nil { + return fmt.Errorf("delete created utxos: %w", err) + } + + rows, err := ops.deleteUnminedTransaction(ctx, params.WalletID, params.Txid) + if err != nil { + return fmt.Errorf("delete unmined tx: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", params.Txid, ErrTxNotFound) + } + + return nil +} + +// unminedTxRecord is the decoded view of one unmined transaction row used by +// shared descendant checks. +type unminedTxRecord struct { + id int64 + hash chainhash.Hash + tx *wire.MsgTx +} + +// extractUnminedTxFn projects one backend-specific unmined transaction row into +// the shared `(id, tx_hash, raw_tx)` shape used by the invalidation walk. +type extractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) + +// newUnminedTxRecord decodes one normalized unmined transaction row into the +// shared dependency-walk shape. +func newUnminedTxRecord(id int64, hash []byte, + rawTx []byte) (unminedTxRecord, error) { + + txHash, err := chainhash.NewHash(hash) + if err != nil { + return unminedTxRecord{}, fmt.Errorf("tx hash: %w", err) + } + + tx, err := deserializeMsgTx(rawTx) + if err != nil { + return unminedTxRecord{}, err + } + + return unminedTxRecord{id: id, hash: *txHash, tx: tx}, nil +} + +// buildUnminedTxRecords decodes backend-specific unmined transaction rows into +// the shared dependency-walk shape. +func buildUnminedTxRecords[T any](rows []T, + extract extractUnminedTxFn[T]) ([]unminedTxRecord, error) { + + records := make([]unminedTxRecord, 0, len(rows)) + for _, row := range rows { + id, hash, rawTx := extract(row) + + record, err := newUnminedTxRecord(id, hash, rawTx) + if err != nil { + return nil, fmt.Errorf("decode unmined tx %d: %w", id, err) + } + + records = append(records, record) + } + + return records, nil +} + +// collectDirectChildTxIDs returns the IDs of unmined transactions that directly +// spend any output created by the provided parent hash. +func collectDirectChildTxIDs(parentHash chainhash.Hash, + candidates []unminedTxRecord) []int64 { + + parentHashes := map[chainhash.Hash]struct{}{ + parentHash: {}, + } + + childIDs := make([]int64, 0, len(candidates)) + for _, candidate := range candidates { + if txSpendsAnyParent(candidate.tx, parentHashes) { + childIDs = append(childIDs, candidate.id) + } + } + + return childIDs +} + +// txSpendsAnyParent reports whether the transaction spends any hash in the +// provided parent set. +func txSpendsAnyParent(tx *wire.MsgTx, + parentHashes map[chainhash.Hash]struct{}) bool { + + for _, txIn := range tx.TxIn { + if _, ok := parentHashes[txIn.PreviousOutPoint.Hash]; ok { + return true + } + } + + return false +} + +// isUnminedStatus reports whether a status still represents an unmined +// transaction that DeleteTx may erase. +func isUnminedStatus(status TxStatus) bool { + switch status { + case TxStatusPending, TxStatusPublished: + return true + + case TxStatusReplaced, TxStatusFailed, TxStatusOrphaned: + return false + + default: + return false + } +} From 1b3dcd3df4d817c7523edfb5bf82cf720579c54c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:05:37 +0800 Subject: [PATCH 481/691] wallet: add DeleteTx Add the postgres and sqlite DeleteTx implementations together with DeleteTx integration coverage. This keeps the backend-specific leaf validation and row cleanup wiring in one commit once the shared delete flow already exists. --- .../internal/db/itest/tx_utxo_store_test.go | 133 +++++++++++++ .../internal/db/postgres_txstore_deletetx.go | 184 +++++++++++++++++ wallet/internal/db/sqlite_txstore_deletetx.go | 187 ++++++++++++++++++ 3 files changed, 504 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_deletetx.go create mode 100644 wallet/internal/db/sqlite_txstore_deletetx.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 5af407a592..971b5317df 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -859,6 +859,139 @@ func TestListTxnsReturnsConfirmedTxsByHeightRange(t *testing.T) { require.Equal(t, uint32(211), infos[0].Block.Height) } +// TestDeleteTxRemovesLeafUnminedTx verifies that DeleteTx removes a leaf +// unmined row and restores any parent spend markers it introduced. +// +// Scenario: +// - One unmined child transaction is the only spender of one wallet-owned +// parent output. +// +// Setup: +// - Create one wallet-owned parent credit and one unmined child spender. +// +// Action: +// - Delete the child through DeleteTx. +// +// Assertions: +// - The child row is removed. +// - The parent output becomes spendable again. +// - No child spend edges remain. +func TestDeleteTxRemovesLeafUnminedTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-leaf") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001000, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001010, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + err = store.DeleteTx(t.Context(), db.DeleteTxParams{ + WalletID: walletID, + Txid: childTx.TxHash(), + }) + require.NoError(t, err) + require.Empty(t, childSpendingTxIDs(t, store, walletID, parentTx.TxHash())) + _, ok := txIDByHash(t, store, walletID, childTx.TxHash()) + require.False(t, ok) + require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{ + Hash: parentTx.TxHash(), Index: 0, + })) +} + +// TestDeleteTxRejectsNonLeafTx verifies that DeleteTx refuses to erase an +// unmined transaction that still has direct child spenders. +// +// Scenario: +// - One parent transaction still has one direct unmined child spender. +// +// Setup: +// - Create one wallet-owned parent credit and one child that spends it. +// +// Action: +// - Attempt to delete the parent through DeleteTx. +// +// Assertions: +// - DeleteTx returns ErrDeleteRequiresLeaf. +// - Both parent and child rows remain stored. +func TestDeleteTxRejectsNonLeafTx(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-non-leaf") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001100, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: addr.ScriptPubKey}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001110, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.DeleteTx(t.Context(), db.DeleteTxParams{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }) + require.ErrorIs(t, err, db.ErrDeleteRequiresLeaf) + _, ok := txIDByHash(t, store, walletID, parentTx.TxHash()) + require.True(t, ok) + _, ok = txIDByHash(t, store, walletID, childTx.TxHash()) + require.True(t, ok) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_deletetx.go b/wallet/internal/db/postgres_txstore_deletetx.go new file mode 100644 index 0000000000..2817b6185d --- /dev/null +++ b/wallet/internal/db/postgres_txstore_deletetx.go @@ -0,0 +1,184 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// DeleteTx atomically removes one unmined transaction and restores any wallet +// UTXO rows that it had spent. +// +// DeleteTx is limited to unmined pending/published rows; confirmed rows and +// terminal invalid-history rows remain part of the wallet timeline. The +// transaction must also be a leaf among the wallet's unmined transactions so +// the delete cannot detach child spenders from their parent history. +func (s *PostgresStore) DeleteTx(ctx context.Context, + params DeleteTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return deleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) + }) +} + +// pgDeleteTxOps adapts postgres sqlc queries to the shared DeleteTx flow. +type pgDeleteTxOps struct { + qtx *sqlcpg.Queries +} + +var _ deleteTxOps = (*pgDeleteTxOps)(nil) + +// loadDeleteTarget loads and validates the unmined transaction row DeleteTx is +// allowed to remove. +func (o pgDeleteTxOps) loadDeleteTarget(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (int64, error) { + + meta, err := getDeleteTxMetaPg(ctx, o.qtx, walletID, txHash) + if err != nil { + return 0, err + } + + return meta.ID, nil +} + +// ensureLeaf rejects DeleteTx when the target still has direct unmined child +// spenders. +func (o pgDeleteTxOps) ensureLeaf(ctx context.Context, walletID uint32, + txHash chainhash.Hash, txID int64) error { + + return ensureDeleteLeafPg(ctx, o.qtx, walletID, txHash, txID) +} + +// clearSpentUtxos restores any wallet-owned parent outputs the transaction had +// marked spent. +func (o pgDeleteTxOps) clearSpentUtxos(ctx context.Context, walletID uint32, + txID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, + sqlcpg.ClearUtxosSpentByTxIDParams{ + WalletID: int64(walletID), + SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, + }, + ) + if err != nil { + return fmt.Errorf("clear spent utxo rows: %w", err) + } + + return nil +} + +// deleteCreatedUtxos removes any wallet-owned outputs created by the +// transaction being deleted. +func (o pgDeleteTxOps) deleteCreatedUtxos(ctx context.Context, + walletID uint32, txID int64) error { + + _, err := o.qtx.DeleteUtxosByTxID( + ctx, + sqlcpg.DeleteUtxosByTxIDParams{ + WalletID: int64(walletID), + TxID: txID, + }, + ) + if err != nil { + return fmt.Errorf("delete created utxo rows: %w", err) + } + + return nil +} + +// deleteUnminedTransaction removes the target unmined row after its dependent +// wallet state has been cleaned up. +func (o pgDeleteTxOps) deleteUnminedTransaction(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (int64, error) { + + rows, err := o.qtx.DeleteUnminedTransactionByHash( + ctx, + sqlcpg.DeleteUnminedTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return 0, fmt.Errorf("delete unmined tx row: %w", err) + } + + return rows, nil +} + +// ensureDeleteLeafPg rejects DeleteTx requests for transactions that still have +// direct unmined child spenders, including children that spend non-credit +// parent outputs. +func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash, txID int64) error { + + rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) + if err != nil { + return fmt.Errorf("list unmined txns: %w", err) + } + + candidates, err := buildUnminedTxRecords(rows, + func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + return row.ID, row.TxHash, row.RawTx + }, + ) + if err != nil { + return err + } + + filtered := candidates[:0] + for _, candidate := range candidates { + if candidate.id == txID { + continue + } + + filtered = append(filtered, candidate) + } + + if len(collectDirectChildTxIDs(txHash, filtered)) > 0 { + return fmt.Errorf("delete tx %s: %w", txHash, + ErrDeleteRequiresLeaf) + } + + return nil +} + +// getDeleteTxMetaPg loads the transaction metadata DeleteTx needs and enforces +// the unmined precondition up front. +func getDeleteTxMetaPg(ctx context.Context, qtx *sqlcpg.Queries, + walletID uint32, txHash chainhash.Hash) ( + sqlcpg.GetTransactionMetaByHashRow, error) { + + meta, err := qtx.GetTransactionMetaByHash( + ctx, sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sqlcpg.GetTransactionMetaByHashRow{}, + fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return sqlcpg.GetTransactionMetaByHashRow{}, + fmt.Errorf("get tx metadata: %w", err) + } + + status, err := parseTxStatus(int64(meta.TxStatus)) + if err != nil { + return sqlcpg.GetTransactionMetaByHashRow{}, err + } + + if meta.BlockHeight.Valid || !isUnminedStatus(status) { + return sqlcpg.GetTransactionMetaByHashRow{}, + fmt.Errorf("delete tx %s: %w", txHash, + ErrDeleteRequiresUnmined) + } + + return meta, nil +} diff --git a/wallet/internal/db/sqlite_txstore_deletetx.go b/wallet/internal/db/sqlite_txstore_deletetx.go new file mode 100644 index 0000000000..7ad8be7b51 --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_deletetx.go @@ -0,0 +1,187 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// DeleteTx atomically removes one unmined transaction and restores any wallet +// UTXO rows that it had spent. +// +// DeleteTx is limited to unmined pending/published rows; confirmed rows and +// terminal invalid-history rows remain part of the wallet timeline. The +// transaction must also be a leaf among the wallet's unmined transactions so +// the delete cannot detach child spenders from their parent history. +func (s *SqliteStore) DeleteTx(ctx context.Context, + params DeleteTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return deleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) + }) +} + +// sqliteDeleteTxOps adapts sqlite sqlc queries to the shared DeleteTx flow. +type sqliteDeleteTxOps struct { + qtx *sqlcsqlite.Queries +} + +var _ deleteTxOps = (*sqliteDeleteTxOps)(nil) + +// loadDeleteTarget loads and validates the unmined transaction row DeleteTx is +// allowed to remove. +func (o sqliteDeleteTxOps) loadDeleteTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (int64, error) { + + meta, err := getDeleteTxMetaSqlite(ctx, o.qtx, walletID, txHash) + if err != nil { + return 0, err + } + + return meta.ID, nil +} + +// ensureLeaf rejects DeleteTx when the target still has direct unmined child +// spenders. +func (o sqliteDeleteTxOps) ensureLeaf(ctx context.Context, walletID uint32, + txHash chainhash.Hash, txID int64) error { + + return ensureDeleteLeafSqlite(ctx, o.qtx, walletID, txHash, txID) +} + +// clearSpentUtxos restores any wallet-owned parent outputs the transaction had +// marked spent. +func (o sqliteDeleteTxOps) clearSpentUtxos(ctx context.Context, + walletID uint32, txID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, + sqlcsqlite.ClearUtxosSpentByTxIDParams{ + WalletID: int64(walletID), + SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, + }, + ) + if err != nil { + return fmt.Errorf("clear spent utxo rows: %w", err) + } + + return nil +} + +// deleteCreatedUtxos removes any wallet-owned outputs created by the +// transaction being deleted. +func (o sqliteDeleteTxOps) deleteCreatedUtxos(ctx context.Context, + walletID uint32, txID int64) error { + + _, err := o.qtx.DeleteUtxosByTxID( + ctx, + sqlcsqlite.DeleteUtxosByTxIDParams{ + WalletID: int64(walletID), + TxID: txID, + }, + ) + if err != nil { + return fmt.Errorf("delete created utxo rows: %w", err) + } + + return nil +} + +// deleteUnminedTransaction removes the target unmined row after its dependent +// wallet state has been cleaned up. +func (o sqliteDeleteTxOps) deleteUnminedTransaction(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (int64, error) { + + rows, err := o.qtx.DeleteUnminedTransactionByHash( + ctx, + sqlcsqlite.DeleteUnminedTransactionByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return 0, fmt.Errorf("delete unmined tx row: %w", err) + } + + return rows, nil +} + +// ensureDeleteLeafSqlite rejects DeleteTx requests for transactions that still +// have direct unmined child spenders, including children that spend non-credit +// parent outputs. +func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + walletID uint32, txHash chainhash.Hash, txID int64) error { + + rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) + if err != nil { + return fmt.Errorf("list unmined txns: %w", err) + } + + candidates, err := buildUnminedTxRecords( + rows, + func(row sqlcsqlite.ListUnminedTransactionsRow) (int64, + []byte, []byte) { + + return row.ID, row.TxHash, row.RawTx + }, + ) + if err != nil { + return err + } + + filtered := candidates[:0] + for _, candidate := range candidates { + if candidate.id == txID { + continue + } + + filtered = append(filtered, candidate) + } + + if len(collectDirectChildTxIDs(txHash, filtered)) > 0 { + return fmt.Errorf("delete tx %s: %w", txHash, + ErrDeleteRequiresLeaf) + } + + return nil +} + +// getDeleteTxMetaSqlite loads the transaction metadata DeleteTx needs and +// enforces the unmined precondition up front. +func getDeleteTxMetaSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, + walletID uint32, txHash chainhash.Hash) ( + sqlcsqlite.GetTransactionMetaByHashRow, error) { + + meta, err := qtx.GetTransactionMetaByHash( + ctx, sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sqlcsqlite.GetTransactionMetaByHashRow{}, + fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + + return sqlcsqlite.GetTransactionMetaByHashRow{}, + fmt.Errorf("get tx metadata: %w", err) + } + + status, err := parseTxStatus(meta.TxStatus) + if err != nil { + return sqlcsqlite.GetTransactionMetaByHashRow{}, err + } + + if meta.BlockHeight.Valid || !isUnminedStatus(status) { + return sqlcsqlite.GetTransactionMetaByHashRow{}, + fmt.Errorf("delete tx %s: %w", txHash, + ErrDeleteRequiresUnmined) + } + + return meta, nil +} From 1a5b78e0e3aac3761ba79584e377e85b268edf30 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:06:03 +0800 Subject: [PATCH 482/691] wallet: add RollbackToBlock ops Add the shared RollbackToBlock orchestration and descendant invalidation helpers used by both SQL backends. Landing the common rollback walk first keeps the backend method commit focused on query adapters and rollback-specific SQL plumbing. --- wallet/internal/db/tx_store_common.go | 154 ++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 654217caa6..24c7e7dbe7 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -562,6 +562,50 @@ type unminedTxRecord struct { // the shared `(id, tx_hash, raw_tx)` shape used by the invalidation walk. type extractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) +// rollbackToBlockOps adapts one SQL backend to the full RollbackToBlock +// sequence, including sync-state rewinds, block deletion, and descendant +// invalidation. +// +// The shared rollback algorithm is intentionally ordered: +// - collect rollback root hashes before block rows are removed +// - rewind sync-state heights that still point at the removed blocks +// - delete the shared block rows at or above the rollback height +// - mark the disconnected coinbase roots orphaned once their confirming +// blocks have been removed +// - walk the disconnected roots and invalidate dependent descendants +// +// The adapter methods map directly to those stages so the shared helper can own +// the rollback sequencing while the backends keep only query details. +type rollbackToBlockOps interface { + // listRollbackRootHashes returns the coinbase roots disconnected by the + // rollback, grouped by wallet for the later descendant walk. + listRollbackRootHashes(ctx context.Context, + height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) + + // rewindWalletSyncStateHeights clamps wallet sync-state references + // below the rollback boundary before block rows are removed. + rewindWalletSyncStateHeights(ctx context.Context, height uint32) error + + // deleteBlocksAtOrAboveHeight removes the shared block rows at or above the + // rollback boundary after sync-state references have been rewound. + deleteBlocksAtOrAboveHeight(ctx context.Context, height uint32) error + + // listUnminedTxRecords loads the wallet's current unmined transaction + // rows in the normalized shape the descendant walk expects. + listUnminedTxRecords(ctx context.Context, + walletID int64) ([]unminedTxRecord, error) + + // clearDescendantSpends removes any wallet-owned spend edges claimed by one + // invalid descendant before its status is rewritten. + clearDescendantSpends(ctx context.Context, walletID int64, + descendantID int64) error + + // markDescendantsFailed batch-marks the discovered descendants as + // failed once every dependent spend edge has been cleared. + markDescendantsFailed(ctx context.Context, walletID int64, + descendantIDs []int64) error +} + // newUnminedTxRecord decodes one normalized unmined transaction row into the // shared dependency-walk shape. func newUnminedTxRecord(id int64, hash []byte, @@ -619,6 +663,116 @@ func collectDirectChildTxIDs(parentHash chainhash.Hash, return childIDs } +// collectDescendantTxIDs returns every unmined transaction that depends on any +// of the provided root hashes, including indirect descendants discovered +// through newly invalidated child hashes. +func collectDescendantTxIDs(rootHashes map[chainhash.Hash]struct{}, + candidates []unminedTxRecord) []int64 { + + invalidHashes := make(map[chainhash.Hash]struct{}, len(rootHashes)) + for hash := range rootHashes { + invalidHashes[hash] = struct{}{} + } + + invalidIDs := make(map[int64]struct{}, len(candidates)) + for changed := true; changed; { + changed = false + + for _, candidate := range candidates { + if _, ok := invalidIDs[candidate.id]; ok { + continue + } + + if !txSpendsAnyParent(candidate.tx, invalidHashes) { + continue + } + + invalidIDs[candidate.id] = struct{}{} + invalidHashes[candidate.hash] = struct{}{} + changed = true + } + } + + descendantIDs := make([]int64, 0, len(invalidIDs)) + for _, candidate := range candidates { + if _, ok := invalidIDs[candidate.id]; ok { + descendantIDs = append(descendantIDs, candidate.id) + } + } + + return descendantIDs +} + +// invalidateRollbackDescendants clears spend edges and marks failed every +// unmined descendant discovered from the provided wallet-scoped rollback roots. +func invalidateRollbackDescendants(ctx context.Context, + rootHashesByWallet map[uint32]map[chainhash.Hash]struct{}, + ops rollbackToBlockOps) error { + + for walletID, rootHashes := range rootHashesByWallet { + walletID64 := int64(walletID) + + candidates, err := ops.listUnminedTxRecords(ctx, walletID64) + if err != nil { + return fmt.Errorf("list unmined rollback descendants for "+ + "wallet %d: %w", walletID, err) + } + + descendantIDs := collectDescendantTxIDs(rootHashes, candidates) + if len(descendantIDs) == 0 { + continue + } + + for _, descendantID := range descendantIDs { + err = ops.clearDescendantSpends(ctx, walletID64, descendantID) + if err != nil { + return fmt.Errorf("clear rollback descendant spends for "+ + "wallet %d: %w", walletID, err) + } + } + + err = ops.markDescendantsFailed(ctx, walletID64, descendantIDs) + if err != nil { + return fmt.Errorf("mark rollback descendants failed for "+ + "wallet %d: %w", walletID, err) + } + } + + return nil +} + +// rollbackToBlockWithOps runs the shared RollbackToBlock sequence inside one +// backend-specific SQL transaction. +// +// The helper rewinds sync-state heights before deleting blocks, then clears and +// fails any now-invalid unmined descendants rooted in disconnected coinbase +// history so rollback cannot leave dangling references behind. +func rollbackToBlockWithOps(ctx context.Context, height uint32, + ops rollbackToBlockOps) error { + + rootHashesByWallet, err := ops.listRollbackRootHashes(ctx, height) + if err != nil { + return fmt.Errorf("list rollback coinbase roots: %w", err) + } + + err = ops.rewindWalletSyncStateHeights(ctx, height) + if err != nil { + return fmt.Errorf("rewind wallet sync state heights: %w", err) + } + + err = ops.deleteBlocksAtOrAboveHeight(ctx, height) + if err != nil { + return fmt.Errorf("delete blocks at or above height: %w", err) + } + + err = invalidateRollbackDescendants(ctx, rootHashesByWallet, ops) + if err != nil { + return err + } + + return nil +} + // txSpendsAnyParent reports whether the transaction spends any hash in the // provided parent set. func txSpendsAnyParent(tx *wire.MsgTx, From bf5af73b31e45ee8005f0f51cbfc8a5e61cc1e85 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:07:12 +0800 Subject: [PATCH 483/691] wallet: add RollbackToBlock Add the postgres and sqlite RollbackToBlock implementations together with rollback integration coverage. The rollback tests now exercise confirmed coinbase history through the public CreateTx path so the branch no longer relies on a temporary helper-only workaround. --- .../internal/db/itest/tx_utxo_store_test.go | 164 +++++++++++++++ .../internal/db/postgres_txstore_rollback.go | 191 ++++++++++++++++++ wallet/internal/db/sqlite_txstore_rollback.go | 168 +++++++++++++++ 3 files changed, 523 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_rollback.go create mode 100644 wallet/internal/db/sqlite_txstore_rollback.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 971b5317df..1890cf6365 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -992,6 +992,170 @@ func TestDeleteTxRejectsNonLeafTx(t *testing.T) { require.True(t, ok) } +// TestDeleteTxRemovesParentWithFailedChild verifies that DeleteTx only treats +// still-active unmined children as leaf blockers. +// +// Scenario: +// - One parent transaction still has one direct child row, but that child has +// already been marked failed. +// +// Setup: +// - Create one wallet-owned parent credit and one child that spends it. +// - Mark the child failed to simulate an already-invalid branch. +// +// Action: +// - Delete the parent through DeleteTx. +// +// Assertions: +// - DeleteTx succeeds because the failed child is no longer part of the +// active unmined graph. +// - The parent row is removed. +func TestDeleteTxRemovesParentWithFailedChild(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-parent-failed-child") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001115, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: addr.ScriptPubKey}}, + ) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001120, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + setTxStatus(t, store, walletID, childTx.TxHash(), db.TxStatusFailed) + + err = store.DeleteTx(t.Context(), db.DeleteTxParams{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }) + require.NoError(t, err) + + _, ok := txIDByHash(t, store, walletID, parentTx.TxHash()) + require.False(t, ok) +} + +// TestRollbackToBlockFailsCoinbaseDescendants verifies that RollbackToBlock +// marks every unmined descendant of a disconnected coinbase root as failed and +// clears the recorded spend edges they had claimed. +// +// Scenario: +// - One confirmed coinbase credit has one unmined child spender and one +// unmined grandchild spender beneath it. +// +// Setup: +// - Create one wallet-owned coinbase output and confirm it in one block. +// - Insert one child transaction that spends that output and creates one new +// wallet-owned credit. +// - Insert one grandchild that spends the child's wallet-owned output. +// +// Action: +// - Roll back the block that confirmed the coinbase root. +// +// Assertions: +// - Both unmined descendants become failed. +// - The spend edges from the coinbase root and child are cleared. +func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-rollback-coinbase-descendants") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + queries := store.Queries() + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + coinbaseTx := newCoinbaseTx(addr.ScriptPubKey) + + block := CreateBlockFixture(t, queries, 300) + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710001200, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: coinbaseTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: addr.ScriptPubKey}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001210, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + grandchildTx := newRegularTx( + []wire.OutPoint{{Hash: childTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 3000, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: grandchildTx, + Received: time.Unix(1710001220, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + require.Len(t, childSpendingTxIDs(t, store, walletID, coinbaseTx.TxHash()), + 1) + require.Len(t, childSpendingTxIDs(t, store, walletID, childTx.TxHash()), 1) + + err = store.RollbackToBlock(t.Context(), block.Height) + require.NoError(t, err) + + childInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: childTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, childInfo.Status) + require.Nil(t, childInfo.Block) + + grandchildInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: grandchildTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, grandchildInfo.Status) + require.Nil(t, grandchildInfo.Block) + + require.Empty(t, childSpendingTxIDs(t, store, walletID, coinbaseTx.TxHash())) + require.Empty(t, childSpendingTxIDs(t, store, walletID, childTx.TxHash())) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/postgres_txstore_rollback.go new file mode 100644 index 0000000000..42564b6bbc --- /dev/null +++ b/wallet/internal/db/postgres_txstore_rollback.go @@ -0,0 +1,191 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// RollbackToBlock atomically removes every block at or above the provided +// height and rewrites wallet sync-state references so the block delete can +// succeed. +func (s *PostgresStore) RollbackToBlock(ctx context.Context, + height uint32) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return rollbackToBlockWithOps(ctx, height, + pgRollbackToBlockOps{qtx: qtx}) + }) +} + +// pgRollbackToBlockOps adapts postgres sqlc queries to the shared rollback +// sequence. +type pgRollbackToBlockOps struct { + qtx *sqlcpg.Queries +} + +var _ rollbackToBlockOps = (*pgRollbackToBlockOps)(nil) + +// listRollbackRootHashes loads the coinbase roots that a rollback disconnects +// and groups them by wallet. +func (o pgRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, + height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) { + + rollbackHeight, err := uint32ToInt32(height) + if err != nil { + return nil, fmt.Errorf("convert rollback height: %w", err) + } + + rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, rollbackHeight) + if err != nil { + return nil, fmt.Errorf("query rollback coinbase roots: %w", err) + } + + return groupRollbackCoinbaseRootsPg(rows) +} + +// rewindWalletSyncStateHeights clamps wallet sync-state references below the +// rollback boundary before the block rows are deleted. +func (o pgRollbackToBlockOps) rewindWalletSyncStateHeights( + ctx context.Context, height uint32) error { + + // PostgreSQL stores block heights as INTEGER today, so rollback still needs + // a checked cast into the current int32-backed schema. On networks with + // Bitcoin's 10-minute target spacing, MaxInt32 would not be reached until + // around year 42839. Regtest can exceed that sooner because blocks are + // mined on demand. + // + // TODO(yy): Fix it when we are in year 42000, which will give us 800 years + // before it's reached. + rollbackHeight, err := uint32ToInt32(height) + if err != nil { + return fmt.Errorf("convert rollback height: %w", err) + } + + newHeight := sql.NullInt32{} + if height > 0 { + newHeight, err = uint32ToNullInt32(height - 1) + if err != nil { + return fmt.Errorf("convert new height: %w", err) + } + } + + _, err = o.qtx.RewindWalletSyncStateHeightsForRollback( + ctx, sqlcpg.RewindWalletSyncStateHeightsForRollbackParams{ + RollbackHeight: rollbackHeight, + NewHeight: newHeight, + }, + ) + if err != nil { + return fmt.Errorf("rewind wallet sync state heights query: %w", err) + } + + return nil +} + +// deleteBlocksAtOrAboveHeight removes the shared block rows after sync-state +// references have been rewound. +func (o pgRollbackToBlockOps) deleteBlocksAtOrAboveHeight( + ctx context.Context, height uint32) error { + + rollbackHeight, err := uint32ToInt32(height) + if err != nil { + return fmt.Errorf("convert rollback height: %w", err) + } + + _, err = o.qtx.DeleteBlocksAtOrAboveHeight(ctx, rollbackHeight) + if err != nil { + return fmt.Errorf("delete blocks at or above height query: %w", err) + } + + return nil +} + +// listUnminedTxRecords loads and decodes every unmined transaction row for the +// wallet so the shared helper can inspect raw inputs for descendant edges. +func (o pgRollbackToBlockOps) listUnminedTxRecords( + ctx context.Context, walletID int64) ([]unminedTxRecord, error) { + + rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) + if err != nil { + return nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildUnminedTxRecords(rows, + func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + return row.ID, row.TxHash, row.RawTx + }, + ) +} + +// clearDescendantSpends removes any wallet-owned spend edges claimed by one +// invalid descendant transaction before its status is rewritten. +func (o pgRollbackToBlockOps) clearDescendantSpends( + ctx context.Context, walletID int64, descendantID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, sqlcpg.ClearUtxosSpentByTxIDParams{ + WalletID: walletID, + SpentByTxID: sql.NullInt64{ + Int64: descendantID, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("clear descendant spends: %w", err) + } + + return nil +} + +// markDescendantsFailed batch-marks the collected rollback descendants as +// failed once every dependent spend edge has been cleared. +func (o pgRollbackToBlockOps) markDescendantsFailed( + ctx context.Context, walletID int64, descendantIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int16(TxStatusFailed), + TxIds: descendantIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark descendants failed: %w", err) + } + + return nil +} + +// groupRollbackCoinbaseRootsPg groups rollback-affected coinbase hashes by +// wallet so descendant invalidation can reuse wallet-scoped unmined queries. +func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( + map[uint32]map[chainhash.Hash]struct{}, error) { + + rootHashesByWallet := make( + map[uint32]map[chainhash.Hash]struct{}, len(rows), + ) + for _, row := range rows { + walletID, err := int64ToUint32(row.WalletID) + if err != nil { + return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) + } + + txHash, err := chainhash.NewHash(row.TxHash) + if err != nil { + return nil, fmt.Errorf("rollback coinbase hash: %w", err) + } + + if _, ok := rootHashesByWallet[walletID]; !ok { + rootHashesByWallet[walletID] = make(map[chainhash.Hash]struct{}) + } + + rootHashesByWallet[walletID][*txHash] = struct{}{} + } + + return rootHashesByWallet, nil +} diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite_txstore_rollback.go new file mode 100644 index 0000000000..87f3f7396e --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_rollback.go @@ -0,0 +1,168 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// RollbackToBlock atomically removes every block at or above the provided +// height and rewrites wallet sync-state references so the block delete can +// succeed. +func (s *SqliteStore) RollbackToBlock(ctx context.Context, + height uint32) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return rollbackToBlockWithOps(ctx, height, + sqliteRollbackToBlockOps{qtx: qtx}) + }) +} + +// sqliteRollbackToBlockOps adapts sqlite sqlc queries to the shared rollback +// sequence. +type sqliteRollbackToBlockOps struct { + qtx *sqlcsqlite.Queries +} + +var _ rollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) + +// listRollbackRootHashes loads the coinbase roots that a rollback disconnects +// and groups them by wallet. +func (o sqliteRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, + height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) { + + rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, int64(height)) + if err != nil { + return nil, fmt.Errorf("query rollback coinbase roots: %w", err) + } + + return groupRollbackCoinbaseRootsSqlite(rows) +} + +// rewindWalletSyncStateHeights clamps wallet sync-state references below the +// rollback boundary before the block rows are deleted. +func (o sqliteRollbackToBlockOps) rewindWalletSyncStateHeights( + ctx context.Context, height uint32) error { + + newHeight := sql.NullInt64{} + if height > 0 { + newHeight = sql.NullInt64{Int64: int64(height - 1), Valid: true} + } + + _, err := o.qtx.RewindWalletSyncStateHeightsForRollback( + ctx, sqlcsqlite.RewindWalletSyncStateHeightsForRollbackParams{ + RollbackHeight: int64(height), + NewHeight: newHeight, + }, + ) + if err != nil { + return fmt.Errorf("rewind wallet sync state heights query: %w", err) + } + + return nil +} + +// deleteBlocksAtOrAboveHeight removes the shared block rows after sync-state +// references have been rewound. +func (o sqliteRollbackToBlockOps) deleteBlocksAtOrAboveHeight( + ctx context.Context, height uint32) error { + + _, err := o.qtx.DeleteBlocksAtOrAboveHeight(ctx, int64(height)) + if err != nil { + return fmt.Errorf("delete blocks at or above height query: %w", err) + } + + return nil +} + +// listUnminedTxRecords loads and decodes every unmined transaction row for the +// wallet so the shared helper can inspect raw inputs for descendant edges. +func (o sqliteRollbackToBlockOps) listUnminedTxRecords( + ctx context.Context, walletID int64) ([]unminedTxRecord, error) { + + rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) + if err != nil { + return nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildUnminedTxRecords(rows, + func(row sqlcsqlite.ListUnminedTransactionsRow) ( + int64, []byte, []byte) { + + return row.ID, row.TxHash, row.RawTx + }, + ) +} + +// clearDescendantSpends removes any wallet-owned spend edges claimed by one +// invalid descendant transaction before its status is rewritten. +func (o sqliteRollbackToBlockOps) clearDescendantSpends( + ctx context.Context, walletID int64, descendantID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, sqlcsqlite.ClearUtxosSpentByTxIDParams{ + WalletID: walletID, + SpentByTxID: sql.NullInt64{ + Int64: descendantID, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("clear descendant spends: %w", err) + } + + return nil +} + +// markDescendantsFailed batch-marks the collected rollback descendants as +// failed once every dependent spend edge has been cleared. +func (o sqliteRollbackToBlockOps) markDescendantsFailed( + ctx context.Context, walletID int64, descendantIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int64(TxStatusFailed), + TxIds: descendantIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark descendants failed: %w", err) + } + + return nil +} + +// groupRollbackCoinbaseRootsSqlite groups rollback-affected coinbase hashes by +// wallet so descendant invalidation can reuse wallet-scoped unmined queries. +func groupRollbackCoinbaseRootsSqlite( + rows []sqlcsqlite.ListRollbackCoinbaseRootsRow) ( + map[uint32]map[chainhash.Hash]struct{}, error) { + + rootHashesByWallet := make( + map[uint32]map[chainhash.Hash]struct{}, len(rows), + ) + for _, row := range rows { + walletID, err := int64ToUint32(row.WalletID) + if err != nil { + return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) + } + + txHash, err := chainhash.NewHash(row.TxHash) + if err != nil { + return nil, fmt.Errorf("rollback coinbase hash: %w", err) + } + + if _, ok := rootHashesByWallet[walletID]; !ok { + rootHashesByWallet[walletID] = make(map[chainhash.Hash]struct{}) + } + + rootHashesByWallet[walletID][*txHash] = struct{}{} + } + + return rootHashesByWallet, nil +} From 193162f83365f41d9862f9f3cfdca7925ce9f925 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:20:29 +0800 Subject: [PATCH 484/691] wallet: add utxo store common helpers Add the shared UTXO-store helpers used by the backend methods that follow. The helper layer centralizes outpoint decoding, public UtxoInfo and LeasedOutput conversion, and the nullable filter adapters required by the sqlite and postgres query bindings. --- wallet/internal/db/utxo_store_common.go | 127 +++++++++++++ wallet/internal/db/utxo_store_common_test.go | 183 +++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 wallet/internal/db/utxo_store_common.go create mode 100644 wallet/internal/db/utxo_store_common_test.go diff --git a/wallet/internal/db/utxo_store_common.go b/wallet/internal/db/utxo_store_common.go new file mode 100644 index 0000000000..3d9ac18807 --- /dev/null +++ b/wallet/internal/db/utxo_store_common.go @@ -0,0 +1,127 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" +) + +var ( + // errInvalidLockID indicates that a lease row contained bytes that cannot + // be represented as a fixed-size LockID. + errInvalidLockID = errors.New("invalid lock id length") + + // ErrOutputAlreadyLeased reports that a UTXO lease request conflicted with + // another active lock on the same output. + ErrOutputAlreadyLeased = errors.New("output already leased") + + // ErrOutputUnlockNotAllowed reports that a UTXO release request used a lock + // ID different from the active lease. + ErrOutputUnlockNotAllowed = errors.New("output unlock not allowed") +) + +// buildOutPoint converts database tx-hash and output-index fields into a +// wire.OutPoint. +func buildOutPoint(hash []byte, outputIndex uint32) (wire.OutPoint, error) { + txHash, err := chainhash.NewHash(hash) + if err != nil { + return wire.OutPoint{}, fmt.Errorf("utxo hash: %w", err) + } + + return wire.OutPoint{Hash: *txHash, Index: outputIndex}, nil +} + +// buildUtxoInfo converts normalized SQL result fields into the public UtxoInfo +// shape returned by the db interfaces. +func buildUtxoInfo(hash []byte, outputIndex uint32, amount int64, + pkScript []byte, received time.Time, isCoinbase bool, + blockHeight *uint32) (*UtxoInfo, error) { + + outPoint, err := buildOutPoint(hash, outputIndex) + if err != nil { + return nil, err + } + + height := UnminedHeight + if blockHeight != nil { + height = *blockHeight + } + + return &UtxoInfo{ + OutPoint: outPoint, + Amount: btcutil.Amount(amount), + PkScript: pkScript, + Received: received.UTC(), + FromCoinBase: isCoinbase, + Height: height, + }, nil +} + +// buildLeasedOutput converts SQL lease-row fields into the public LeasedOutput +// type. +func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, + expiration time.Time) (*LeasedOutput, error) { + + outPoint, err := buildOutPoint(hash, outputIndex) + if err != nil { + return nil, err + } + + if len(lockID) != len(LockID{}) { + return nil, fmt.Errorf("lock id: %w", errInvalidLockID) + } + + var id LockID + copy(id[:], lockID) + + return &LeasedOutput{ + OutPoint: outPoint, + LockID: id, + Expiration: expiration.UTC(), + }, nil +} + +// optionalUint32Int64 converts an optional uint32 filter into the nullable any +// form used by sqlite sqlc queries. +func optionalUint32Int64(value *uint32) any { + if value == nil { + return nil + } + + return int64(*value) +} + +// optionalInt32 converts an optional int32 filter into the nullable any form +// used by sqlite sqlc queries. +func optionalInt32(value *int32) any { + if value == nil { + return nil + } + + return *value +} + +// nullableUint32Int64 converts an optional uint32 filter into the typed null +// form used by postgres sqlc queries. +func nullableUint32Int64(value *uint32) sql.NullInt64 { + if value == nil { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: int64(*value), Valid: true} +} + +// nullableInt32 converts an optional int32 filter into the typed null form +// used by postgres sqlc queries. +func nullableInt32(value *int32) sql.NullInt32 { + if value == nil { + return sql.NullInt32{} + } + + return sql.NullInt32{Int32: *value, Valid: true} +} diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go new file mode 100644 index 0000000000..52ecae9707 --- /dev/null +++ b/wallet/internal/db/utxo_store_common_test.go @@ -0,0 +1,183 @@ +package db + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/stretchr/testify/require" +) + +// TestBuildOutPoint verifies the common hash/index conversion shared by both +// SQL backends when building a valid public outpoint. +// +// Scenario: +// - One normalized outpoint row is read back from the store. +// Setup: +// - Build a valid transaction hash. +// Action: +// - Convert the normalized hash/index pair into a wire.OutPoint. +// Assertions: +// - The public outpoint preserves the original hash and output index. +func TestBuildOutPoint(t *testing.T) { + t.Parallel() + + // Scenario: One normalized outpoint row is read back from the store. + // Setup: Build a valid transaction hash. + hash := chainhash.Hash{1, 2, 3} + + // Act: Build the public outpoint from normalized DB fields. + outPoint, err := buildOutPoint(hash[:], 7) + + // Assert: The helper preserves the hash and output index. + require.NoError(t, err) + require.Equal(t, hash, outPoint.Hash) + require.Equal(t, uint32(7), outPoint.Index) +} + +// TestBuildOutPoint_InvalidHash verifies that buildOutPoint rejects malformed +// hash bytes. +// +// Scenario: +// - A normalized outpoint row carries malformed hash bytes. +// Setup: +// - Use a short hash payload. +// Action: +// - Attempt to convert the malformed row into a wire.OutPoint. +// Assertions: +// - The helper returns an error instead of building a partial outpoint. +func TestBuildOutPoint_InvalidHash(t *testing.T) { + t.Parallel() + + // Scenario: A normalized outpoint row carries invalid hash bytes. + // Setup: Use a malformed hash payload. + malformedHash := []byte{1, 2, 3} + + // Act: Attempt to build the public outpoint. + _, err := buildOutPoint(malformedHash, 0) + + // Assert: The helper rejects the malformed hash. + require.Error(t, err) +} + +// TestBuildUtxoInfo_Confirmed verifies that buildUtxoInfo preserves confirmed +// UTXO metadata. +// +// Scenario: +// - One confirmed UTXO row is read back from the store. +// Setup: +// - Build a valid hash and confirmed block height. +// Action: +// - Convert the normalized row into a public UtxoInfo value. +// Assertions: +// - The mined height and outpoint metadata are preserved. +func TestBuildUtxoInfo_Confirmed(t *testing.T) { + t.Parallel() + + // Scenario: One confirmed UTXO row is read back from the store. + // Setup: Build a valid hash and confirmed block height. + hash := chainhash.Hash{9} + confirmedHeight := uint32(33) + + // Act: Build the public UTXO view for the confirmed row. + confirmed, err := buildUtxoInfo( + hash[:], 1, 1234, []byte{0x51}, time.Unix(111, 0), true, + &confirmedHeight, + ) + + // Assert: The helper preserves the mined height and outpoint metadata. + require.NoError(t, err) + require.Equal(t, confirmedHeight, confirmed.Height) + require.Equal(t, hash, confirmed.OutPoint.Hash) + require.Equal(t, uint32(1), confirmed.OutPoint.Index) +} + +// TestBuildUtxoInfo_Unconfirmed verifies that buildUtxoInfo maps unconfirmed +// rows to the public unmined sentinel. +// +// Scenario: +// - One unconfirmed UTXO row is read back from the store. +// Setup: +// - Build a valid hash with no block height. +// Action: +// - Convert the normalized row into a public UtxoInfo value. +// Assertions: +// - The missing height maps to UnminedHeight and the timestamp is stored in +// UTC. +func TestBuildUtxoInfo_Unconfirmed(t *testing.T) { + t.Parallel() + + // Scenario: One unconfirmed UTXO row is read back from the store. + // Setup: Build a valid hash with no block height. + hash := chainhash.Hash{9} + + // Act: Build the public UTXO view for the unconfirmed row. + unconfirmed, err := buildUtxoInfo( + hash[:], 2, 5678, []byte{0x52}, time.Unix(222, 0), false, nil, + ) + + // Assert: The helper maps the missing height to UnminedHeight and stores + // timestamps in UTC. + require.NoError(t, err) + require.Equal(t, UnminedHeight, unconfirmed.Height) + require.Equal(t, time.UTC, unconfirmed.Received.Location()) +} + +// TestBuildLeasedOutput verifies the common conversion used by both SQL +// backends when surfacing one valid active lease. +// +// Scenario: +// - One active lease row is read back from the store. +// Setup: +// - Build a valid hash and 32-byte lock ID. +// Action: +// - Convert the normalized row into a public LeasedOutput value. +// Assertions: +// - The outpoint, lock ID, and UTC expiration are preserved. +func TestBuildLeasedOutput(t *testing.T) { + t.Parallel() + + // Scenario: One active lease row is read back from the store. + // Setup: Build a valid hash and 32-byte lock ID. + hash := chainhash.Hash{4, 5, 6} + lockID := make([]byte, 32) + lockID[0] = 7 + + // Act: Build the public leased-output view. + lease, err := buildLeasedOutput( + hash[:], 9, lockID, time.Unix(333, 0).In(time.FixedZone("X", 3600)), + ) + + // Assert: The helper preserves the outpoint, lock ID, and UTC expiration. + require.NoError(t, err) + require.Equal(t, hash, lease.OutPoint.Hash) + require.Equal(t, uint32(9), lease.OutPoint.Index) + require.Equal(t, byte(7), lease.LockID[0]) + require.Equal(t, time.UTC, lease.Expiration.Location()) +} + +// TestBuildLeasedOutput_InvalidLockID verifies that buildLeasedOutput rejects +// malformed lock IDs. +// +// Scenario: +// - A lease row carries an invalid lock ID payload. +// Setup: +// - Build a valid hash with a short lock ID. +// Action: +// - Attempt to convert the malformed row into a public LeasedOutput value. +// Assertions: +// - The helper returns errInvalidLockID. +func TestBuildLeasedOutput_InvalidLockID(t *testing.T) { + t.Parallel() + + // Scenario: A lease row carries an invalid lock ID payload. + // Setup: Build a valid hash with a short lock ID. + hash := chainhash.Hash{4, 5, 6} + shortLockID := []byte{1, 2, 3} + + // Act: Attempt to build the public leased-output view. + _, err := buildLeasedOutput(hash[:], 0, shortLockID, time.Now()) + + // Assert: The helper returns the invalid-lock-ID sentinel. + require.ErrorIs(t, err, errInvalidLockID) +} From dec90a4ee67216d09e77fc95a4c6cfdaf48c2010 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:22:22 +0800 Subject: [PATCH 485/691] wallet: add GetUtxo Add the postgres and sqlite GetUtxo implementations together with GetUtxo integration coverage. The method returns one current wallet-owned outpoint view built from normalized query rows and maps missing rows to the public ErrUtxoNotFound sentinel. --- .../internal/db/itest/tx_utxo_store_test.go | 55 ++++++++++++++ .../internal/db/postgres_utxostore_getutxo.go | 71 +++++++++++++++++++ .../internal/db/sqlite_utxostore_getutxo.go | 66 +++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_getutxo.go create mode 100644 wallet/internal/db/sqlite_utxostore_getutxo.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 1890cf6365..8f3034f2ef 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1156,6 +1156,61 @@ func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { require.Empty(t, childSpendingTxIDs(t, store, walletID, childTx.TxHash())) } +// TestGetUtxoReturnsCurrentWalletOutput verifies that GetUtxo returns a stored +// wallet-owned output created by an unmined transaction. +func TestGetUtxoReturnsCurrentWalletOutput(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-utxo") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 15000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001400, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + utxo, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + }) + + require.NoError(t, err) + require.Equal(t, tx.TxHash(), utxo.OutPoint.Hash) + require.Equal(t, uint32(0), utxo.OutPoint.Index) + require.Equal(t, btcutil.Amount(15000), utxo.Amount) + require.Equal(t, db.UnminedHeight, utxo.Height) +} + +// TestGetUtxoNotFound verifies that GetUtxo returns ErrUtxoNotFound when the +// requested outpoint is not part of the current wallet UTXO set. +func TestGetUtxoNotFound(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-utxo-missing") + + _, err := store.GetUtxo(t.Context(), db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: randomOutPoint(), + }) + + require.ErrorIs(t, err, db.ErrUtxoNotFound) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_getutxo.go b/wallet/internal/db/postgres_utxostore_getutxo.go new file mode 100644 index 0000000000..6ffac436e5 --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_getutxo.go @@ -0,0 +1,71 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// GetUtxo retrieves one current wallet-owned UTXO by outpoint. +// +// The output must still be unspent and its creating transaction must still be +// in `pending` or `published` status. +func (s *PostgresStore) GetUtxo(ctx context.Context, + query GetUtxoQuery) (*UtxoInfo, error) { + + outputIndex, err := uint32ToInt32(query.OutPoint.Index) + if err != nil { + return nil, fmt.Errorf("convert output index: %w", err) + } + + row, err := s.queries.GetUtxoByOutpoint( + ctx, sqlcpg.GetUtxoByOutpointParams{ + WalletID: int64(query.WalletID), + TxHash: query.OutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, + ErrUtxoNotFound) + } + + return nil, fmt.Errorf("get utxo: %w", err) + } + + return utxoInfoFromPgRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) +} + +// utxoInfoFromPgRow converts one normalized postgres query row into the public +// UtxoInfo shape. +func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, + pkScript []byte, received time.Time, isCoinbase bool, + blockHeight sql.NullInt32) (*UtxoInfo, error) { + + index, err := int64ToUint32(int64(outputIndex)) + if err != nil { + return nil, fmt.Errorf("utxo output index: %w", err) + } + + var height *uint32 + if blockHeight.Valid { + heightValue, err := nullInt32ToUint32(blockHeight) + if err != nil { + return nil, fmt.Errorf("utxo block height: %w", err) + } + + height = &heightValue + } + + return buildUtxoInfo( + hash, index, amount, pkScript, received, isCoinbase, height, + ) +} diff --git a/wallet/internal/db/sqlite_utxostore_getutxo.go b/wallet/internal/db/sqlite_utxostore_getutxo.go new file mode 100644 index 0000000000..dce91783e5 --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_getutxo.go @@ -0,0 +1,66 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// GetUtxo retrieves one current wallet-owned UTXO by outpoint. +// +// The output must still be unspent and its creating transaction must still be +// in `pending` or `published` status. +func (s *SqliteStore) GetUtxo(ctx context.Context, + query GetUtxoQuery) (*UtxoInfo, error) { + + row, err := s.queries.GetUtxoByOutpoint( + ctx, sqlcsqlite.GetUtxoByOutpointParams{ + WalletID: int64(query.WalletID), + TxHash: query.OutPoint.Hash[:], + OutputIndex: int64(query.OutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, + ErrUtxoNotFound) + } + + return nil, fmt.Errorf("get utxo: %w", err) + } + + return utxoInfoFromSqliteRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) +} + +// utxoInfoFromSqliteRow converts one normalized sqlite query row into the +// public UtxoInfo shape. +func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, + pkScript []byte, received time.Time, isCoinbase bool, + blockHeight sql.NullInt64) (*UtxoInfo, error) { + + index, err := int64ToUint32(outputIndex) + if err != nil { + return nil, fmt.Errorf("utxo output index: %w", err) + } + + var height *uint32 + if blockHeight.Valid { + heightValue, err := int64ToUint32(blockHeight.Int64) + if err != nil { + return nil, fmt.Errorf("utxo block height: %w", err) + } + + height = &heightValue + } + + return buildUtxoInfo( + hash, index, amount, pkScript, received, isCoinbase, height, + ) +} From c99db8b65058420b8e990c94f6f1b2104fed904a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:23:37 +0800 Subject: [PATCH 486/691] wallet: add ListUTXOs Add the postgres and sqlite ListUTXOs implementations together with ListUTXOs integration coverage. The method returns the current wallet-owned output set after optional account filters while keeping the wallet-ownership constraints in the query layer. --- .../internal/db/itest/tx_utxo_store_test.go | 105 ++++++++++++++++++ .../db/postgres_utxostore_listutxos.go | 68 ++++++++++++ .../internal/db/sqlite_utxostore_listutxos.go | 61 ++++++++++ 3 files changed, 234 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_listutxos.go create mode 100644 wallet/internal/db/sqlite_utxostore_listutxos.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 8f3034f2ef..a38f7d83aa 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1211,6 +1211,111 @@ func TestGetUtxoNotFound(t *testing.T) { require.ErrorIs(t, err, db.ErrUtxoNotFound) } +// TestListUTXOsReturnsCurrentWalletOutputs verifies that ListUTXOs returns the +// current wallet-owned outputs created by pending transactions. +func TestListUTXOsReturnsCurrentWalletOutputs(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-utxos") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + txOne := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 15000, PkScript: addr.ScriptPubKey}}, + ) + txTwo := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 12000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txOne, + Received: time.Unix(1710001500, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txTwo, + Received: time.Unix(1710001510, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + }) + + require.NoError(t, err) + require.Len(t, utxos, 2) + require.Equal(t, txTwo.TxHash(), utxos[0].OutPoint.Hash) + require.Equal(t, txOne.TxHash(), utxos[1].OutPoint.Hash) +} + +// TestListUTXOsFiltersByAccount verifies that ListUTXOs applies the optional +// account filter without affecting the underlying wallet ownership checks. +func TestListUTXOsFiltersByAccount(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-utxos-account") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "savings") + + defaultAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + savingsAddr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "savings", false, + ) + + txDefault := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 16000, PkScript: defaultAddr.ScriptPubKey}}, + ) + txSavings := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 17000, PkScript: savingsAddr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txDefault, + Received: time.Unix(1710001600, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txSavings, + Received: time.Unix(1710001610, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + account := uint32(1) + utxos, err := store.ListUTXOs(t.Context(), db.ListUtxosQuery{ + WalletID: walletID, + Account: &account, + }) + + require.NoError(t, err) + require.Len(t, utxos, 1) + require.Equal(t, txSavings.TxHash(), utxos[0].OutPoint.Hash) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_listutxos.go b/wallet/internal/db/postgres_utxostore_listutxos.go new file mode 100644 index 0000000000..550fe1bea6 --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_listutxos.go @@ -0,0 +1,68 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. +// +// The result set is already constrained to outputs whose creating +// transactions are still in `pending` or `published` status. +func (s *PostgresStore) ListUTXOs(ctx context.Context, + query ListUtxosQuery) ([]UtxoInfo, error) { + + rows, err := s.queries.ListUtxos(ctx, buildListUtxosParamsPg(query)) + if err != nil { + return nil, fmt.Errorf("list utxos: %w", err) + } + + utxos := make([]UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := utxoInfoFromPgRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + if err != nil { + return nil, err + } + + utxos[i] = *utxo + } + + return utxos, nil +} + +// buildListUtxosParamsPg prepares the typed nullable filters required by the +// postgres ListUtxos query. +func buildListUtxosParamsPg(query ListUtxosQuery) sqlcpg.ListUtxosParams { + return sqlcpg.ListUtxosParams{ + WalletID: int64(query.WalletID), + AccountNumber: nullableUint32Int64Pg(query.Account), + MinConfirms: nullableInt32Pg(query.MinConfs), + MaxConfirms: nullableInt32Pg(query.MaxConfs), + } +} + +// nullableUint32Int64Pg converts an optional uint32 filter into the typed null +// form used by postgres sqlc queries. +func nullableUint32Int64Pg(value *uint32) sql.NullInt64 { + if value == nil { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: int64(*value), Valid: true} +} + +// nullableInt32Pg converts an optional int32 filter into the typed null form +// used by postgres sqlc queries. +func nullableInt32Pg(value *int32) sql.NullInt32 { + if value == nil { + return sql.NullInt32{} + } + + return sql.NullInt32{Int32: *value, Valid: true} +} diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite_utxostore_listutxos.go new file mode 100644 index 0000000000..3ef01e8b1c --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_listutxos.go @@ -0,0 +1,61 @@ +package db + +import ( + "context" + "fmt" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. +// +// The result set is already constrained to outputs whose creating +// transactions are still in `pending` or `published` status. +func (s *SqliteStore) ListUTXOs(ctx context.Context, + query ListUtxosQuery) ([]UtxoInfo, error) { + + rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ + WalletID: int64(query.WalletID), + AccountNumber: optionalUint32Int64Sqlite(query.Account), + MinConfirms: optionalInt32Sqlite(query.MinConfs), + MaxConfirms: optionalInt32Sqlite(query.MaxConfs), + }) + if err != nil { + return nil, fmt.Errorf("list utxos: %w", err) + } + + utxos := make([]UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := utxoInfoFromSqliteRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + if err != nil { + return nil, err + } + + utxos[i] = *utxo + } + + return utxos, nil +} + +// optionalUint32Int64Sqlite converts an optional uint32 filter into the +// nullable form used by sqlite sqlc queries. +func optionalUint32Int64Sqlite(value *uint32) any { + if value == nil { + return nil + } + + return int64(*value) +} + +// optionalInt32Sqlite converts an optional int32 filter into the nullable form +// used by sqlite sqlc queries. +func optionalInt32Sqlite(value *int32) any { + if value == nil { + return nil + } + + return *value +} From bf0c2edd104d017831ffc1e7d139d26c53ae273c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:30:03 +0800 Subject: [PATCH 487/691] wallet: add LeaseOutput ops Add the shared LeaseOutput orchestration used by both SQL backends. The shared helper validates lease duration up front and centralizes the missing-utxo versus already-leased decision so the later backend method commit only wires query details and integration coverage. --- wallet/internal/db/utxo_store_common.go | 91 +++++++++----- wallet/internal/db/utxo_store_common_test.go | 126 +++++++++++++++++++ 2 files changed, 188 insertions(+), 29 deletions(-) diff --git a/wallet/internal/db/utxo_store_common.go b/wallet/internal/db/utxo_store_common.go index 3d9ac18807..59437bd67a 100644 --- a/wallet/internal/db/utxo_store_common.go +++ b/wallet/internal/db/utxo_store_common.go @@ -1,7 +1,7 @@ package db import ( - "database/sql" + "context" "errors" "fmt" "time" @@ -23,6 +23,10 @@ var ( // ErrOutputUnlockNotAllowed reports that a UTXO release request used a lock // ID different from the active lease. ErrOutputUnlockNotAllowed = errors.New("output unlock not allowed") + + // errLeaseOutputNoRow indicates that the backend lease write found no + // leasable current UTXO row for the requested outpoint. + errLeaseOutputNoRow = errors.New("lease output no row") ) // buildOutPoint converts database tx-hash and output-index fields into a @@ -86,42 +90,71 @@ func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, }, nil } -// optionalUint32Int64 converts an optional uint32 filter into the nullable any -// form used by sqlite sqlc queries. -func optionalUint32Int64(value *uint32) any { - if value == nil { - return nil - } - - return int64(*value) +// leaseOutputOps is the backend adapter the shared LeaseOutput workflow uses. +// +// The shared lease algorithm is intentionally ordered: +// - validate the public lease request up front +// - attempt the atomic lease write or renewal next +// - if the write reports no row, distinguish a missing UTXO from an active +// conflicting lease +// - return the public leased-output view only after the write succeeds +// +// The adapter methods map directly to those stages so the shared helper keeps +// the policy and sequencing while each backend keeps only query details. +type leaseOutputOps interface { + // acquire attempts to write or renew the lease and returns the stored + // expiration timestamp when the write succeeds. + acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, + expiresAt time.Time) (time.Time, error) + + // hasUtxo reports whether the requested outpoint still exists as a current + // wallet-owned UTXO. + hasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) } -// optionalInt32 converts an optional int32 filter into the nullable any form -// used by sqlite sqlc queries. -func optionalInt32(value *int32) any { - if value == nil { - return nil +// leaseOutputWithOps runs the backend-independent LeaseOutput workflow once the +// caller has opened a backend-specific SQL transaction. +// +// The helper owns the lease sequencing so every backend answers the same two +// questions in the same order: did the lease write succeed, and if not, was the +// target output missing or merely already leased by a different lock? +func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, + ops leaseOutputOps) (*LeasedOutput, error) { + + if params.Duration <= 0 { + return nil, fmt.Errorf("%w: lease duration must be positive", + ErrInvalidParam) } - return *value -} + nowUTC := time.Now().UTC() + expiresAt := nowUTC.Add(params.Duration) -// nullableUint32Int64 converts an optional uint32 filter into the typed null -// form used by postgres sqlc queries. -func nullableUint32Int64(value *uint32) sql.NullInt64 { - if value == nil { - return sql.NullInt64{} + expiration, err := ops.acquire(ctx, params, nowUTC, expiresAt) + if err == nil { + return &LeasedOutput{ + OutPoint: params.OutPoint, + LockID: LockID(params.ID), + Expiration: expiration.UTC(), + }, nil } - return sql.NullInt64{Int64: int64(*value), Valid: true} -} + if !errors.Is(err, errLeaseOutputNoRow) { + return nil, fmt.Errorf("acquire utxo lease: %w", err) + } + + // A no-row acquire means the write path found no leasable row. + // Distinguish a missing UTXO from an already-active lease before + // returning a public error. + exists, err := ops.hasUtxo(ctx, params) + if err != nil { + return nil, fmt.Errorf("lookup utxo before lease conflict: %w", err) + } -// nullableInt32 converts an optional int32 filter into the typed null form -// used by postgres sqlc queries. -func nullableInt32(value *int32) sql.NullInt32 { - if value == nil { - return sql.NullInt32{} + if !exists { + return nil, fmt.Errorf("utxo %s: %w", params.OutPoint, + ErrUtxoNotFound) } - return sql.NullInt32{Int32: *value, Valid: true} + return nil, fmt.Errorf("utxo %s: %w", params.OutPoint, + ErrOutputAlreadyLeased) } diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 52ecae9707..c26203d635 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -1,10 +1,12 @@ package db import ( + "context" "testing" "time" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/stretchr/testify/require" ) @@ -181,3 +183,127 @@ func TestBuildLeasedOutput_InvalidLockID(t *testing.T) { // Assert: The helper returns the invalid-lock-ID sentinel. require.ErrorIs(t, err, errInvalidLockID) } + +// TestLeaseOutputWithOps verifies that the shared LeaseOutput helper returns +// the leased outpoint when the backend acquire step succeeds. +func TestLeaseOutputWithOps(t *testing.T) { + t.Parallel() + + // Arrange: Build one valid lease request and one successful stub adapter. + params := LeaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: LockID{7}, + Duration: time.Minute, + } + ops := &stubLeaseOutputOps{ + acquireExpiration: time.Unix(333, 0).In(time.FixedZone("X", 3600)), + } + + // Act: Run the shared LeaseOutput flow. + lease, err := leaseOutputWithOps(context.Background(), params, ops) + + // Assert: The helper returns the leased outpoint and UTC expiration. + require.NoError(t, err) + require.Equal(t, params.OutPoint, lease.OutPoint) + require.Equal(t, LockID(params.ID), lease.LockID) + require.Equal(t, time.UTC, lease.Expiration.Location()) + require.Equal(t, []string{"acquire"}, ops.calls) +} + +// TestLeaseOutputWithOpsRejectsNonPositiveDuration verifies that the shared +// LeaseOutput helper rejects an already-expired lease request up front. +func TestLeaseOutputWithOpsRejectsNonPositiveDuration(t *testing.T) { + t.Parallel() + + params := LeaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: LockID{7}, + Duration: 0, + } + ops := &stubLeaseOutputOps{} + + _, err := leaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrInvalidParam) + require.Empty(t, ops.calls) +} + +// TestLeaseOutputWithOpsMissingUtxo verifies that the shared LeaseOutput helper +// maps a no-row acquire and missing outpoint lookup to ErrUtxoNotFound. +func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { + t.Parallel() + + params := LeaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: LockID{7}, + Duration: time.Minute, + } + ops := &stubLeaseOutputOps{ + acquireErr: errLeaseOutputNoRow, + } + + _, err := leaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrUtxoNotFound) + require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) +} + +// TestLeaseOutputWithOpsAlreadyLeased verifies that the shared LeaseOutput +// helper maps a no-row acquire and existing outpoint to ErrOutputAlreadyLeased. +func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { + t.Parallel() + + params := LeaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: LockID{7}, + Duration: time.Minute, + } + ops := &stubLeaseOutputOps{ + acquireErr: errLeaseOutputNoRow, + hasUtxoResult: true, + } + + _, err := leaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrOutputAlreadyLeased) + require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) +} + +// stubLeaseOutputOps records how the shared LeaseOutput helper drives one +// backend adapter while letting each test control the returned results. +type stubLeaseOutputOps struct { + acquireExpiration time.Time + acquireErr error + hasUtxoResult bool + + calls []string +} + +var _ leaseOutputOps = (*stubLeaseOutputOps)(nil) + +// acquire records the shared write attempt and returns the test-controlled +// lease result. +func (s *stubLeaseOutputOps) acquire(_ context.Context, + _ LeaseOutputParams, _ time.Time, _ time.Time) (time.Time, error) { + + s.calls = append(s.calls, "acquire") + + return s.acquireExpiration, s.acquireErr +} + +// hasUtxo records the fallback ownership lookup and returns the test-controlled +// result. +func (s *stubLeaseOutputOps) hasUtxo(_ context.Context, + _ LeaseOutputParams) (bool, error) { + + s.calls = append(s.calls, "has-utxo") + + return s.hasUtxoResult, nil +} + +// testLeaseOutPoint builds one stable outpoint fixture for shared UTXO lease +// helper tests. +func testLeaseOutPoint() wire.OutPoint { + return wire.OutPoint{Hash: chainhash.Hash{7}, Index: 1} +} From 0aa1864753b6874842805ec36e93c9630dead4f6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:30:12 +0800 Subject: [PATCH 488/691] wallet: add LeaseOutput Add the postgres and sqlite LeaseOutput implementations together with LeaseOutput integration coverage. The backend adapters now reuse the shared lease flow while keeping the query parameter wiring and lease-write details local to each SQL backend. --- .../internal/db/itest/tx_utxo_store_test.go | 103 +++++++++++++++++ .../db/postgres_utxostore_leaseoutput.go | 108 ++++++++++++++++++ .../db/sqlite_utxostore_leaseoutput.go | 98 ++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_leaseoutput.go create mode 100644 wallet/internal/db/sqlite_utxostore_leaseoutput.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index a38f7d83aa..dd9d27822f 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1316,6 +1316,109 @@ func TestListUTXOsFiltersByAccount(t *testing.T) { require.Equal(t, txSavings.TxHash(), utxos[0].OutPoint.Hash) } +// TestLeaseOutputLocksCurrentUtxo verifies that LeaseOutput returns the active +// lease metadata for a current wallet-owned output. +func TestLeaseOutputLocksCurrentUtxo(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-lease-output") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 18000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001700, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + lease, err := store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + ID: db.LockID{1}, + Duration: 30 * time.Minute, + }) + + require.NoError(t, err) + require.Equal(t, tx.TxHash(), lease.OutPoint.Hash) + require.Equal(t, uint32(0), lease.OutPoint.Index) + require.Equal(t, db.LockID{1}, lease.LockID) + require.True(t, lease.Expiration.After(time.Now().UTC())) +} + +// TestLeaseOutputRejectsAlreadyLeasedUtxo verifies that LeaseOutput reports +// ErrOutputAlreadyLeased when another active lock already owns the same output. +func TestLeaseOutputRejectsAlreadyLeasedUtxo(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-lease-output-conflict") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 19000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001710, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + ID: db.LockID{1}, + Duration: 30 * time.Minute, + }) + require.NoError(t, err) + + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + ID: db.LockID{2}, + Duration: 30 * time.Minute, + }) + require.ErrorIs(t, err, db.ErrOutputAlreadyLeased) +} + +// TestLeaseOutputRejectsNonPositiveDuration verifies that LeaseOutput rejects a +// non-positive duration before it attempts any lease write. +func TestLeaseOutputRejectsNonPositiveDuration(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-lease-output-duration") + + _, err := store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + OutPoint: randomOutPoint(), + ID: db.LockID{3}, + Duration: 0, + }) + + require.ErrorIs(t, err, db.ErrInvalidParam) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_leaseoutput.go b/wallet/internal/db/postgres_utxostore_leaseoutput.go new file mode 100644 index 0000000000..e3592f5493 --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_leaseoutput.go @@ -0,0 +1,108 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// LeaseOutput atomically acquires or renews a lease for one current UTXO. +// +// The lease lookup and acquisition run in one transaction so competing calls +// cannot observe a partially-written lease. Expiration timestamps are +// normalized to UTC before insert. +func (s *PostgresStore) LeaseOutput(ctx context.Context, + params LeaseOutputParams) (*LeasedOutput, error) { + + var lease *LeasedOutput + + err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + acquiredLease, err := leaseOutputWithOps( + ctx, params, &pgLeaseOutputOps{qtx: qtx}, + ) + if err != nil { + return err + } + + lease = acquiredLease + + return nil + }) + if err != nil { + return nil, err + } + + return lease, nil +} + +// pgLeaseOutputOps adapts postgres sqlc queries to the shared LeaseOutput +// workflow. +type pgLeaseOutputOps struct { + qtx *sqlcpg.Queries +} + +var _ leaseOutputOps = (*pgLeaseOutputOps)(nil) + +// acquire attempts to write or renew one postgres lease row for the requested +// outpoint. +func (o *pgLeaseOutputOps) acquire(ctx context.Context, + params LeaseOutputParams, nowUTC time.Time, + expiresAt time.Time) (time.Time, error) { + + outputIndex, err := uint32ToInt32(params.OutPoint.Index) + if err != nil { + return time.Time{}, fmt.Errorf("convert output index: %w", err) + } + + expiration, err := o.qtx.AcquireUtxoLease( + ctx, sqlcpg.AcquireUtxoLeaseParams{ + WalletID: int64(params.WalletID), + LockID: params.ID[:], + ExpiresAt: expiresAt, + TxHash: params.OutPoint.Hash[:], + OutputIndex: outputIndex, + NowUtc: nowUTC, + }, + ) + if err == nil { + return expiration, nil + } + + if errors.Is(err, sql.ErrNoRows) { + return time.Time{}, errLeaseOutputNoRow + } + + return time.Time{}, fmt.Errorf("acquire lease row: %w", err) +} + +// hasUtxo reports whether the requested outpoint still exists as a current +// wallet-owned UTXO. +func (o *pgLeaseOutputOps) hasUtxo(ctx context.Context, + params LeaseOutputParams) (bool, error) { + + outputIndex, err := uint32ToInt32(params.OutPoint.Index) + if err != nil { + return false, fmt.Errorf("convert output index: %w", err) + } + + _, err = o.qtx.GetUtxoIDByOutpoint( + ctx, sqlcpg.GetUtxoIDByOutpointParams{ + WalletID: int64(params.WalletID), + TxHash: params.OutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err == nil { + return true, nil + } + + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("lookup utxo row: %w", err) +} diff --git a/wallet/internal/db/sqlite_utxostore_leaseoutput.go b/wallet/internal/db/sqlite_utxostore_leaseoutput.go new file mode 100644 index 0000000000..e3033a27be --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_leaseoutput.go @@ -0,0 +1,98 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// LeaseOutput atomically acquires or renews a lease for one current UTXO. +// +// The lease lookup and acquisition run in one transaction so competing calls +// cannot observe a partially-written lease. Expiration timestamps are +// normalized to UTC before insert. +func (s *SqliteStore) LeaseOutput(ctx context.Context, + params LeaseOutputParams) (*LeasedOutput, error) { + + var lease *LeasedOutput + + err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + acquiredLease, err := leaseOutputWithOps( + ctx, params, &sqliteLeaseOutputOps{qtx: qtx}, + ) + if err != nil { + return err + } + + lease = acquiredLease + + return nil + }) + if err != nil { + return nil, err + } + + return lease, nil +} + +// sqliteLeaseOutputOps adapts sqlite sqlc queries to the shared LeaseOutput +// workflow. +type sqliteLeaseOutputOps struct { + qtx *sqlcsqlite.Queries +} + +var _ leaseOutputOps = (*sqliteLeaseOutputOps)(nil) + +// acquire attempts to write or renew one sqlite lease row for the requested +// outpoint. +func (o *sqliteLeaseOutputOps) acquire(ctx context.Context, + params LeaseOutputParams, nowUTC time.Time, + expiresAt time.Time) (time.Time, error) { + + expiration, err := o.qtx.AcquireUtxoLease( + ctx, sqlcsqlite.AcquireUtxoLeaseParams{ + WalletID: int64(params.WalletID), + LockID: params.ID[:], + ExpiresAt: expiresAt, + TxHash: params.OutPoint.Hash[:], + OutputIndex: int64(params.OutPoint.Index), + NowUtc: nowUTC, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return time.Time{}, errLeaseOutputNoRow + } + + return time.Time{}, fmt.Errorf("acquire lease row: %w", err) + } + + return expiration, nil +} + +// hasUtxo reports whether the requested outpoint still exists as a current +// wallet-owned UTXO. +func (o *sqliteLeaseOutputOps) hasUtxo(ctx context.Context, + params LeaseOutputParams) (bool, error) { + + _, err := o.qtx.GetUtxoIDByOutpoint( + ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ + WalletID: int64(params.WalletID), + TxHash: params.OutPoint.Hash[:], + OutputIndex: int64(params.OutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + + return false, fmt.Errorf("lookup utxo row: %w", err) + } + + return true, nil +} From 0dab42c8e649128ad5229d523fa7c44923f8e2bf Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:33:20 +0800 Subject: [PATCH 489/691] wallet: add ReleaseOutput ops Add the shared ReleaseOutput orchestration used by both SQL backends. The shared helper centralizes the current-utxo lookup, lease delete, and active-lock fallback so the backend method commit only wires query shapes and integration coverage. --- wallet/internal/db/utxo_store_common.go | 95 +++++++++++++ wallet/internal/db/utxo_store_common_test.go | 135 +++++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/wallet/internal/db/utxo_store_common.go b/wallet/internal/db/utxo_store_common.go index 59437bd67a..6d7107ad39 100644 --- a/wallet/internal/db/utxo_store_common.go +++ b/wallet/internal/db/utxo_store_common.go @@ -1,6 +1,7 @@ package db import ( + "bytes" "context" "errors" "fmt" @@ -27,6 +28,19 @@ var ( // errLeaseOutputNoRow indicates that the backend lease write found no // leasable current UTXO row for the requested outpoint. errLeaseOutputNoRow = errors.New("lease output no row") + + // errReleaseOutputUtxoNotFound indicates that ReleaseOutput could not + // resolve the requested outpoint to a current wallet-owned UTXO row. + errReleaseOutputUtxoNotFound = errors.New( + "release output utxo not found", + ) + + // errReleaseOutputNoActiveLease indicates that the target UTXO no longer + // has an active lease row by the time ReleaseOutput checks the fallback + // path. + errReleaseOutputNoActiveLease = errors.New( + "release output no active lease", + ) ) // buildOutPoint converts database tx-hash and output-index fields into a @@ -158,3 +172,84 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, return nil, fmt.Errorf("utxo %s: %w", params.OutPoint, ErrOutputAlreadyLeased) } + +// releaseOutputOps is the backend adapter the shared ReleaseOutput workflow +// uses. +// +// The shared release algorithm is intentionally ordered: +// - resolve the wallet-owned outpoint to a stable UTXO row first +// - attempt the lease delete by lock ID second +// - if no row is deleted, check the active lease state for that UTXO +// - treat a missing active lease as a no-op +// - map a different active lock to ErrOutputUnlockNotAllowed +// +// The adapter methods map directly to those stages so the shared helper keeps +// the release policy and sequencing while each backend keeps only query +// details. +type releaseOutputOps interface { + // lookupUtxoID resolves the current wallet-owned outpoint to its stable + // UTXO row ID. + lookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) + + // release attempts to delete the lease row for the provided UTXO ID and + // lock ID, returning the number of deleted rows. + release(ctx context.Context, walletID uint32, utxoID int64, + lockID [32]byte) (int64, error) + + // activeLockID returns the currently active lock ID for the provided UTXO + // ID. + activeLockID(ctx context.Context, walletID uint32, utxoID int64, + nowUTC time.Time) ([]byte, error) +} + +// releaseOutputWithOps runs the backend-independent ReleaseOutput workflow once +// the caller has opened a backend-specific SQL transaction. +// +// The helper resolves the stable UTXO row first, attempts the lock-specific +// delete second, and only falls back to the active-lock lookup when no row was +// deleted. That keeps a released-or-expired lease as a no-op while still +// surfacing conflicting active locks consistently across backends. +func releaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, + ops releaseOutputOps) error { + + nowUTC := time.Now().UTC() + + utxoID, err := ops.lookupUtxoID(ctx, params) + if err != nil { + if errors.Is(err, errReleaseOutputUtxoNotFound) { + return fmt.Errorf("utxo %s: %w", params.OutPoint, + ErrUtxoNotFound) + } + + return fmt.Errorf("lookup utxo for release: %w", err) + } + + rows, err := ops.release(ctx, params.WalletID, utxoID, params.ID) + if err != nil { + return fmt.Errorf("release utxo lease: %w", err) + } + + if rows != 0 { + return nil + } + + // No row was deleted, so either the lease already expired or was released, + // or a different active lock still owns this UTXO. + activeLockID, err := ops.activeLockID( + ctx, params.WalletID, utxoID, nowUTC, + ) + if err != nil { + if errors.Is(err, errReleaseOutputNoActiveLease) { + return nil + } + + return fmt.Errorf("lookup active utxo lease: %w", err) + } + + if !bytes.Equal(activeLockID, params.ID[:]) { + return fmt.Errorf("utxo %s: %w", params.OutPoint, + ErrOutputUnlockNotAllowed) + } + + return nil +} diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index c26203d635..700319ef49 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -270,6 +270,96 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) } +// TestReleaseOutputWithOps verifies that the shared ReleaseOutput helper +// returns nil when the backend delete removes the matching lease row. +func TestReleaseOutputWithOps(t *testing.T) { + t.Parallel() + + // Arrange: Build one valid release request and one successful stub adapter. + params := ReleaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: [32]byte{9}, + } + ops := &stubReleaseOutputOps{ + lookupUtxoIDResult: 11, + releaseRows: 1, + } + + // Act: Run the shared ReleaseOutput flow. + err := releaseOutputWithOps(context.Background(), params, ops) + + // Assert: The helper stops after the successful delete path. + require.NoError(t, err) + require.Equal(t, []string{"lookup-utxo", "release"}, ops.calls) +} + +// TestReleaseOutputWithOpsMissingUtxo verifies that the shared ReleaseOutput +// helper maps a missing current outpoint to ErrUtxoNotFound. +func TestReleaseOutputWithOpsMissingUtxo(t *testing.T) { + t.Parallel() + + params := ReleaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: [32]byte{9}, + } + ops := &stubReleaseOutputOps{ + lookupUtxoIDErr: errReleaseOutputUtxoNotFound, + } + + err := releaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrUtxoNotFound) + require.Equal(t, []string{"lookup-utxo"}, ops.calls) +} + +// TestReleaseOutputWithOpsWrongLock verifies that the shared ReleaseOutput +// helper maps a different active lock to ErrOutputUnlockNotAllowed. +func TestReleaseOutputWithOpsWrongLock(t *testing.T) { + t.Parallel() + + params := ReleaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: [32]byte{9}, + } + ops := &stubReleaseOutputOps{ + lookupUtxoIDResult: 11, + activeLockIDResult: []byte{1, 2, 3}, + } + + err := releaseOutputWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrOutputUnlockNotAllowed) + require.Equal(t, + []string{"lookup-utxo", "release", "active-lock"}, + ops.calls, + ) +} + +// TestReleaseOutputWithOpsMissingActiveLease verifies that the shared +// ReleaseOutput helper treats an already-expired or already-cleared lease as a +// no-op. +func TestReleaseOutputWithOpsMissingActiveLease(t *testing.T) { + t.Parallel() + + params := ReleaseOutputParams{ + WalletID: 5, + OutPoint: testLeaseOutPoint(), + ID: [32]byte{9}, + } + ops := &stubReleaseOutputOps{ + lookupUtxoIDResult: 11, + activeLockIDErr: errReleaseOutputNoActiveLease, + } + + err := releaseOutputWithOps(context.Background(), params, ops) + require.NoError(t, err) + require.Equal(t, + []string{"lookup-utxo", "release", "active-lock"}, + ops.calls, + ) +} + // stubLeaseOutputOps records how the shared LeaseOutput helper drives one // backend adapter while letting each test control the returned results. type stubLeaseOutputOps struct { @@ -302,6 +392,51 @@ func (s *stubLeaseOutputOps) hasUtxo(_ context.Context, return s.hasUtxoResult, nil } +// stubReleaseOutputOps records how the shared ReleaseOutput helper drives one +// backend adapter while letting each test control the returned results. +type stubReleaseOutputOps struct { + lookupUtxoIDResult int64 + lookupUtxoIDErr error + releaseRows int64 + releaseErr error + activeLockIDResult []byte + activeLockIDErr error + + calls []string +} + +var _ releaseOutputOps = (*stubReleaseOutputOps)(nil) + +// lookupUtxoID records the shared outpoint lookup and returns the test- +// controlled result. +func (s *stubReleaseOutputOps) lookupUtxoID(_ context.Context, + _ ReleaseOutputParams) (int64, error) { + + s.calls = append(s.calls, "lookup-utxo") + + return s.lookupUtxoIDResult, s.lookupUtxoIDErr +} + +// release records the shared delete attempt and returns the test-controlled row +// count. +func (s *stubReleaseOutputOps) release(_ context.Context, _ uint32, + _ int64, _ [32]byte) (int64, error) { + + s.calls = append(s.calls, "release") + + return s.releaseRows, s.releaseErr +} + +// activeLockID records the fallback active-lock lookup and returns the test- +// controlled result. +func (s *stubReleaseOutputOps) activeLockID(_ context.Context, _ uint32, + _ int64, _ time.Time) ([]byte, error) { + + s.calls = append(s.calls, "active-lock") + + return s.activeLockIDResult, s.activeLockIDErr +} + // testLeaseOutPoint builds one stable outpoint fixture for shared UTXO lease // helper tests. func testLeaseOutPoint() wire.OutPoint { From 5ef42d1164c9dc811516505d067c26f2cd6cd834 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:33:29 +0800 Subject: [PATCH 490/691] wallet: add ReleaseOutput Add the postgres and sqlite ReleaseOutput implementations together with ReleaseOutput integration coverage. The backend adapters now reuse the shared release flow while keeping the query parameter wiring and active-lock lookup details local to each SQL backend. --- .../internal/db/itest/tx_utxo_store_test.go | 100 +++++++++++++++++ .../db/postgres_utxostore_releaseoutput.go | 104 ++++++++++++++++++ .../db/sqlite_utxostore_releaseoutput.go | 99 +++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_releaseoutput.go create mode 100644 wallet/internal/db/sqlite_utxostore_releaseoutput.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index dd9d27822f..839adf0454 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1419,6 +1419,106 @@ func TestLeaseOutputRejectsNonPositiveDuration(t *testing.T) { require.ErrorIs(t, err, db.ErrInvalidParam) } +// TestReleaseOutputUnlocksMatchingLease verifies that ReleaseOutput removes the +// active lease when the caller presents the matching lock ID. +func TestReleaseOutputUnlocksMatchingLease(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-release-output") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 20000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001900, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + }) + + require.NoError(t, err) + + otherID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: otherID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) +} + +// TestReleaseOutputRejectsWrongLockID verifies that ReleaseOutput reports the +// public unlock error when another active lock still owns the output. +func TestReleaseOutputRejectsWrongLockID(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-release-conflict") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 21000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710002000, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + wrongID := RandomHash() + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: walletID, + ID: wrongID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + }) + + require.ErrorIs(t, err, db.ErrOutputUnlockNotAllowed) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_releaseoutput.go b/wallet/internal/db/postgres_utxostore_releaseoutput.go new file mode 100644 index 0000000000..579118dd17 --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_releaseoutput.go @@ -0,0 +1,104 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// ReleaseOutput atomically releases a lease when the caller provides the +// active lock ID. +// +// The ownership check and lease deletion run in one transaction so callers +// cannot unlock a UTXO using stale state from a separate read. +func (s *PostgresStore) ReleaseOutput(ctx context.Context, + params ReleaseOutputParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return releaseOutputWithOps( + ctx, params, &pgReleaseOutputOps{qtx: qtx}, + ) + }) +} + +// pgReleaseOutputOps adapts postgres sqlc queries to the shared ReleaseOutput +// workflow. +type pgReleaseOutputOps struct { + qtx *sqlcpg.Queries +} + +var _ releaseOutputOps = (*pgReleaseOutputOps)(nil) + +// lookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO +// row ID. +func (o *pgReleaseOutputOps) lookupUtxoID(ctx context.Context, + params ReleaseOutputParams) (int64, error) { + + outputIndex, err := uint32ToInt32(params.OutPoint.Index) + if err != nil { + return 0, err + } + + utxoID, err := o.qtx.GetUtxoIDByOutpoint( + ctx, sqlcpg.GetUtxoIDByOutpointParams{ + WalletID: int64(params.WalletID), + TxHash: params.OutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, errReleaseOutputUtxoNotFound + } + + return 0, fmt.Errorf("lookup utxo row: %w", err) + } + + return utxoID, nil +} + +// release attempts to delete the postgres lease row for the provided UTXO ID +// and lock ID. +func (o *pgReleaseOutputOps) release(ctx context.Context, walletID uint32, + utxoID int64, lockID [32]byte) (int64, error) { + + rows, err := o.qtx.ReleaseUtxoLease( + ctx, sqlcpg.ReleaseUtxoLeaseParams{ + WalletID: int64(walletID), + UtxoID: utxoID, + LockID: lockID[:], + }, + ) + if err != nil { + return 0, fmt.Errorf("release lease row: %w", err) + } + + return rows, nil +} + +// activeLockID returns the currently active postgres lease lock ID for the +// provided UTXO ID. +func (o *pgReleaseOutputOps) activeLockID(ctx context.Context, walletID uint32, + utxoID int64, nowUTC time.Time) ([]byte, error) { + + activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + ctx, sqlcpg.GetActiveUtxoLeaseLockIDParams{ + WalletID: int64(walletID), + UtxoID: utxoID, + NowUtc: nowUTC, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errReleaseOutputNoActiveLease + } + + return nil, fmt.Errorf("lookup active lease row: %w", err) + } + + return activeLockID, nil +} diff --git a/wallet/internal/db/sqlite_utxostore_releaseoutput.go b/wallet/internal/db/sqlite_utxostore_releaseoutput.go new file mode 100644 index 0000000000..3ecc4d52bb --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_releaseoutput.go @@ -0,0 +1,99 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// ReleaseOutput atomically releases a lease when the caller provides the +// active lock ID. +// +// The ownership check and lease deletion run in one transaction so callers +// cannot unlock a UTXO using stale state from a separate read. +func (s *SqliteStore) ReleaseOutput(ctx context.Context, + params ReleaseOutputParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return releaseOutputWithOps( + ctx, params, &sqliteReleaseOutputOps{qtx: qtx}, + ) + }) +} + +// sqliteReleaseOutputOps adapts sqlite sqlc queries to the shared +// ReleaseOutput workflow. +type sqliteReleaseOutputOps struct { + qtx *sqlcsqlite.Queries +} + +var _ releaseOutputOps = (*sqliteReleaseOutputOps)(nil) + +// lookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row +// ID. +func (o *sqliteReleaseOutputOps) lookupUtxoID(ctx context.Context, + params ReleaseOutputParams) (int64, error) { + + utxoID, err := o.qtx.GetUtxoIDByOutpoint( + ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ + WalletID: int64(params.WalletID), + TxHash: params.OutPoint.Hash[:], + OutputIndex: int64(params.OutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, errReleaseOutputUtxoNotFound + } + + return 0, fmt.Errorf("lookup utxo row: %w", err) + } + + return utxoID, nil +} + +// release attempts to delete the sqlite lease row for the provided UTXO ID and +// lock ID. +func (o *sqliteReleaseOutputOps) release(ctx context.Context, walletID uint32, + utxoID int64, lockID [32]byte) (int64, error) { + + rows, err := o.qtx.ReleaseUtxoLease( + ctx, sqlcsqlite.ReleaseUtxoLeaseParams{ + WalletID: int64(walletID), + UtxoID: utxoID, + LockID: lockID[:], + }, + ) + if err != nil { + return 0, fmt.Errorf("release lease row: %w", err) + } + + return rows, nil +} + +// activeLockID returns the currently active sqlite lease lock ID for the +// provided UTXO ID. +func (o *sqliteReleaseOutputOps) activeLockID(ctx context.Context, + walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { + + activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + ctx, sqlcsqlite.GetActiveUtxoLeaseLockIDParams{ + WalletID: int64(walletID), + UtxoID: utxoID, + NowUtc: nowUTC, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errReleaseOutputNoActiveLease + } + + return nil, fmt.Errorf("lookup active lease row: %w", err) + } + + return activeLockID, nil +} From f51581cad10039dac7da5de45eae3e03ebc57709 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:34:48 +0800 Subject: [PATCH 491/691] wallet: add ListLeasedOutputs Add the postgres and sqlite ListLeasedOutputs implementations together with integration coverage. The method returns the active wallet lease set as public LeasedOutput values built from normalized query rows. --- .../internal/db/itest/tx_utxo_store_test.go | 93 +++++++++++++++++++ .../postgres_utxostore_listleasedoutputs.go | 45 +++++++++ .../db/sqlite_utxostore_listleasedoutputs.go | 45 +++++++++ 3 files changed, 183 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_listleasedoutputs.go create mode 100644 wallet/internal/db/sqlite_utxostore_listleasedoutputs.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 839adf0454..51bb07d077 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1519,6 +1519,99 @@ func TestReleaseOutputRejectsWrongLockID(t *testing.T) { require.ErrorIs(t, err, db.ErrOutputUnlockNotAllowed) } +// TestListLeasedOutputsReturnsActiveLeases verifies that ListLeasedOutputs +// returns the currently active wallet lease set. +func TestListLeasedOutputsReturnsActiveLeases(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-leases") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 22000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710002100, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + leases, err := store.ListLeasedOutputs(t.Context(), walletID) + + require.NoError(t, err) + require.Len(t, leases, 1) + require.Equal(t, tx.TxHash(), leases[0].OutPoint.Hash) + require.Equal(t, db.LockID(leaseID), leases[0].LockID) +} + +// TestListLeasedOutputsExcludesReleasedLease verifies that ListLeasedOutputs +// reflects a successful release immediately. +func TestListLeasedOutputsExcludesReleasedLease(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-leases-after-release") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 23000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710002200, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + err = store.ReleaseOutput(t.Context(), db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + }) + require.NoError(t, err) + + leases, err := store.ListLeasedOutputs(t.Context(), walletID) + + require.NoError(t, err) + require.Empty(t, leases) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go b/wallet/internal/db/postgres_utxostore_listleasedoutputs.go new file mode 100644 index 0000000000..1944d7790e --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_listleasedoutputs.go @@ -0,0 +1,45 @@ +package db + +import ( + "context" + "fmt" + "time" + + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. +func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, + walletID uint32) ([]LeasedOutput, error) { + + nowUTC := time.Now().UTC() + + rows, err := s.queries.ListActiveUtxoLeases( + ctx, sqlcpg.ListActiveUtxoLeasesParams{ + WalletID: int64(walletID), + NowUtc: nowUTC, + }, + ) + if err != nil { + return nil, fmt.Errorf("list active utxo leases: %w", err) + } + + leases := make([]LeasedOutput, len(rows)) + for i, row := range rows { + outputIndex, err := int64ToUint32(int64(row.OutputIndex)) + if err != nil { + return nil, fmt.Errorf("lease output index: %w", err) + } + + lease, err := buildLeasedOutput( + row.TxHash, outputIndex, row.LockID, row.ExpiresAt, + ) + if err != nil { + return nil, err + } + + leases[i] = *lease + } + + return leases, nil +} diff --git a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go new file mode 100644 index 0000000000..4be3fbfee3 --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go @@ -0,0 +1,45 @@ +package db + +import ( + "context" + "fmt" + "time" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. +func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, + walletID uint32) ([]LeasedOutput, error) { + + nowUTC := time.Now().UTC() + + rows, err := s.queries.ListActiveUtxoLeases( + ctx, sqlcsqlite.ListActiveUtxoLeasesParams{ + WalletID: int64(walletID), + NowUtc: nowUTC, + }, + ) + if err != nil { + return nil, fmt.Errorf("list active utxo leases: %w", err) + } + + leases := make([]LeasedOutput, len(rows)) + for i, row := range rows { + outputIndex, err := int64ToUint32(row.OutputIndex) + if err != nil { + return nil, fmt.Errorf("lease output index: %w", err) + } + + lease, err := buildLeasedOutput( + row.TxHash, outputIndex, row.LockID, row.ExpiresAt, + ) + if err != nil { + return nil, err + } + + leases[i] = *lease + } + + return leases, nil +} From 0716f00ee411f04a4b600a09d88270f2f101578b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 18:35:54 +0800 Subject: [PATCH 492/691] wallet: add Balance Add the postgres and sqlite Balance implementations together with Balance integration coverage. The balance path returns the current wallet-owned total together with the locked subset computed from the same filtered UTXO base set so callers can reason about lease state without issuing a second query. --- .../internal/db/itest/tx_utxo_store_test.go | 58 +++++++++++++++++++ .../internal/db/postgres_utxostore_balance.go | 34 +++++++++++ .../internal/db/sqlite_utxostore_balance.go | 34 +++++++++++ 3 files changed, 126 insertions(+) create mode 100644 wallet/internal/db/postgres_utxostore_balance.go create mode 100644 wallet/internal/db/sqlite_utxostore_balance.go diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 51bb07d077..5195bba11a 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1612,6 +1612,64 @@ func TestListLeasedOutputsExcludesReleasedLease(t *testing.T) { require.Empty(t, leases) } +// TestBalanceReturnsTotalAndLocked verifies that Balance returns the filtered +// total UTXO value together with the locked subset covered by active leases. +func TestBalanceReturnsTotalAndLocked(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-balance") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + txOne := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 24000, PkScript: addr.ScriptPubKey}}, + ) + txTwo := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 26000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txOne, + Received: time.Unix(1710002300, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: txTwo, + Received: time.Unix(1710002310, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput(t.Context(), db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: txOne.TxHash(), Index: 0}, + Duration: time.Minute, + }) + require.NoError(t, err) + + balance, err := store.Balance(t.Context(), db.BalanceParams{ + WalletID: walletID, + }) + + require.NoError(t, err) + require.Equal(t, btcutil.Amount(50000), balance.Total) + require.Equal(t, btcutil.Amount(24000), balance.Locked) +} + // newCoinbaseTx builds a simple coinbase fixture transaction. func newCoinbaseTx(pkScript []byte) *wire.MsgTx { tx := wire.NewMsgTx(2) diff --git a/wallet/internal/db/postgres_utxostore_balance.go b/wallet/internal/db/postgres_utxostore_balance.go new file mode 100644 index 0000000000..993728f323 --- /dev/null +++ b/wallet/internal/db/postgres_utxostore_balance.go @@ -0,0 +1,34 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcutil" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// Balance returns the sum of wallet-owned current UTXOs after optional filters. +func (s *PostgresStore) Balance(ctx context.Context, + params BalanceParams) (BalanceResult, error) { + + nowUTC := time.Now().UTC() + + balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ + NowUtc: nowUTC, + WalletID: int64(params.WalletID), + AccountNumber: nullableUint32Int64Pg(params.Account), + MinConfirms: nullableInt32Pg(params.MinConfs), + MaxConfirms: nullableInt32Pg(params.MaxConfs), + CoinbaseMaturity: nullableInt32Pg(params.CoinbaseMaturity), + }) + if err != nil { + return BalanceResult{}, fmt.Errorf("balance: %w", err) + } + + return BalanceResult{ + Total: btcutil.Amount(balance.TotalBalance), + Locked: btcutil.Amount(balance.LockedBalance), + }, nil +} diff --git a/wallet/internal/db/sqlite_utxostore_balance.go b/wallet/internal/db/sqlite_utxostore_balance.go new file mode 100644 index 0000000000..694251b18f --- /dev/null +++ b/wallet/internal/db/sqlite_utxostore_balance.go @@ -0,0 +1,34 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcutil" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// Balance returns the sum of wallet-owned current UTXOs after optional filters. +func (s *SqliteStore) Balance(ctx context.Context, + params BalanceParams) (BalanceResult, error) { + + nowUTC := time.Now().UTC() + + balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ + NowUtc: nowUTC, + WalletID: int64(params.WalletID), + AccountNumber: optionalUint32Int64Sqlite(params.Account), + MinConfirms: optionalInt32Sqlite(params.MinConfs), + MaxConfirms: optionalInt32Sqlite(params.MaxConfs), + CoinbaseMaturity: optionalInt32Sqlite(params.CoinbaseMaturity), + }) + if err != nil { + return BalanceResult{}, fmt.Errorf("balance: %w", err) + } + + return BalanceResult{ + Total: btcutil.Amount(balance.TotalBalance), + Locked: btcutil.Amount(balance.LockedBalance), + }, nil +} From 2ac741dad7699fdbd77389d2ac24c59f9d273a43 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 3 Apr 2026 18:16:05 +0800 Subject: [PATCH 493/691] wallet: type sqlite utxo store filters Cast the optional sqlite UTXO filter params in the SQL so sqlc emits typed nullable fields instead of interface{} values. Keep the sqlite helper logic local to the UTXO store files so the common layer stays free of backend-only filter shaping code. --- wallet/internal/db/queries/sqlite/utxos.sql | 34 ++++++------- wallet/internal/db/sqlc/sqlite/utxos.sql.go | 48 +++++++++---------- .../internal/db/sqlite_utxostore_listutxos.go | 19 ++++---- 3 files changed, 51 insertions(+), 50 deletions(-) diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/db/queries/sqlite/utxos.sql index e361e37804..e817706381 100644 --- a/wallet/internal/db/queries/sqlite/utxos.sql +++ b/wallet/internal/db/queries/sqlite/utxos.sql @@ -139,12 +139,12 @@ WHERE AND u.spent_by_tx_id IS NULL AND t.tx_status IN (0, 1) AND ( - sqlc.narg('account_number') IS NULL - OR acc.account_number = sqlc.narg('account_number') + cast(sqlc.narg('account_number') AS INTEGER) IS NULL + OR acc.account_number = cast(sqlc.narg('account_number') AS INTEGER) ) AND ( - sqlc.narg('min_confirms') IS NULL - OR sqlc.narg('min_confirms') = 0 + cast(sqlc.narg('min_confirms') AS INTEGER) IS NULL + OR cast(sqlc.narg('min_confirms') AS INTEGER) = 0 OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -152,10 +152,10 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= sqlc.narg('min_confirms') + ) >= cast(sqlc.narg('min_confirms') AS INTEGER) ) AND ( - sqlc.narg('max_confirms') IS NULL + cast(sqlc.narg('max_confirms') AS INTEGER) IS NULL OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -163,7 +163,7 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) <= sqlc.narg('max_confirms') + ) <= cast(sqlc.narg('max_confirms') AS INTEGER) ) ORDER BY u.amount, t.tx_hash, u.output_index; @@ -220,12 +220,12 @@ WHERE AND u.spent_by_tx_id IS NULL AND t.tx_status IN (0, 1) AND ( - sqlc.narg('account_number') IS NULL - OR acc.account_number = sqlc.narg('account_number') + cast(sqlc.narg('account_number') AS INTEGER) IS NULL + OR acc.account_number = cast(sqlc.narg('account_number') AS INTEGER) ) AND ( - sqlc.narg('min_confirms') IS NULL - OR sqlc.narg('min_confirms') = 0 + cast(sqlc.narg('min_confirms') AS INTEGER) IS NULL + OR cast(sqlc.narg('min_confirms') AS INTEGER) = 0 OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -233,10 +233,10 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= sqlc.narg('min_confirms') + ) >= cast(sqlc.narg('min_confirms') AS INTEGER) ) AND ( - sqlc.narg('max_confirms') IS NULL + cast(sqlc.narg('max_confirms') AS INTEGER) IS NULL OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -244,11 +244,11 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) <= sqlc.narg('max_confirms') + ) <= cast(sqlc.narg('max_confirms') AS INTEGER) ) AND ( - sqlc.narg('coinbase_maturity') IS NULL - OR sqlc.narg('coinbase_maturity') = 0 + cast(sqlc.narg('coinbase_maturity') AS INTEGER) IS NULL + OR cast(sqlc.narg('coinbase_maturity') AS INTEGER) = 0 OR NOT t.is_coinbase OR ( CASE @@ -257,7 +257,7 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= sqlc.narg('coinbase_maturity') + ) >= cast(sqlc.narg('coinbase_maturity') AS INTEGER) ); -- name: ListSpendingTxIDsByParentTxID :many diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/db/sqlc/sqlite/utxos.sql.go index 6c41db8c2c..f8b5e110d5 100644 --- a/wallet/internal/db/sqlc/sqlite/utxos.sql.go +++ b/wallet/internal/db/sqlc/sqlite/utxos.sql.go @@ -44,12 +44,12 @@ WHERE AND u.spent_by_tx_id IS NULL AND t.tx_status IN (0, 1) AND ( - ?3 IS NULL - OR acc.account_number = ?3 + cast(?3 AS INTEGER) IS NULL + OR acc.account_number = cast(?3 AS INTEGER) ) AND ( - ?4 IS NULL - OR ?4 = 0 + cast(?4 AS INTEGER) IS NULL + OR cast(?4 AS INTEGER) = 0 OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -57,10 +57,10 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= ?4 + ) >= cast(?4 AS INTEGER) ) AND ( - ?5 IS NULL + cast(?5 AS INTEGER) IS NULL OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -68,11 +68,11 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) <= ?5 + ) <= cast(?5 AS INTEGER) ) AND ( - ?6 IS NULL - OR ?6 = 0 + cast(?6 AS INTEGER) IS NULL + OR cast(?6 AS INTEGER) = 0 OR NOT t.is_coinbase OR ( CASE @@ -81,17 +81,17 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= ?6 + ) >= cast(?6 AS INTEGER) ) ` type BalanceParams struct { NowUtc time.Time WalletID int64 - AccountNumber interface{} - MinConfirms interface{} - MaxConfirms interface{} - CoinbaseMaturity interface{} + AccountNumber sql.NullInt64 + MinConfirms sql.NullInt64 + MaxConfirms sql.NullInt64 + CoinbaseMaturity sql.NullInt64 } type BalanceRow struct { @@ -522,12 +522,12 @@ WHERE AND u.spent_by_tx_id IS NULL AND t.tx_status IN (0, 1) AND ( - ?2 IS NULL - OR acc.account_number = ?2 + cast(?2 AS INTEGER) IS NULL + OR acc.account_number = cast(?2 AS INTEGER) ) AND ( - ?3 IS NULL - OR ?3 = 0 + cast(?3 AS INTEGER) IS NULL + OR cast(?3 AS INTEGER) = 0 OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -535,10 +535,10 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) >= ?3 + ) >= cast(?3 AS INTEGER) ) AND ( - ?4 IS NULL + cast(?4 AS INTEGER) IS NULL OR ( CASE WHEN t.block_height IS NULL THEN 0 @@ -546,16 +546,16 @@ WHERE WHEN t.block_height > s.synced_height THEN NULL ELSE s.synced_height - t.block_height + 1 END - ) <= ?4 + ) <= cast(?4 AS INTEGER) ) ORDER BY u.amount, t.tx_hash, u.output_index ` type ListUtxosParams struct { WalletID int64 - AccountNumber interface{} - MinConfirms interface{} - MaxConfirms interface{} + AccountNumber sql.NullInt64 + MinConfirms sql.NullInt64 + MaxConfirms sql.NullInt64 } type ListUtxosRow struct { diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite_utxostore_listutxos.go index 3ef01e8b1c..80811a8bfa 100644 --- a/wallet/internal/db/sqlite_utxostore_listutxos.go +++ b/wallet/internal/db/sqlite_utxostore_listutxos.go @@ -2,6 +2,7 @@ package db import ( "context" + "database/sql" "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -40,22 +41,22 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, return utxos, nil } -// optionalUint32Int64Sqlite converts an optional uint32 filter into the +// optionalUint32Int64Sqlite converts an optional uint32 filter into the typed // nullable form used by sqlite sqlc queries. -func optionalUint32Int64Sqlite(value *uint32) any { +func optionalUint32Int64Sqlite(value *uint32) sql.NullInt64 { if value == nil { - return nil + return sql.NullInt64{} } - return int64(*value) + return sql.NullInt64{Int64: int64(*value), Valid: true} } -// optionalInt32Sqlite converts an optional int32 filter into the nullable form -// used by sqlite sqlc queries. -func optionalInt32Sqlite(value *int32) any { +// optionalInt32Sqlite converts an optional int32 filter into the typed nullable +// form used by sqlite sqlc queries. +func optionalInt32Sqlite(value *int32) sql.NullInt64 { if value == nil { - return nil + return sql.NullInt64{} } - return *value + return sql.NullInt64{Int64: int64(*value), Valid: true} } From 8b39ddb34f870b275d5f10baa5f229917c7d91e4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 3 Apr 2026 18:16:05 +0800 Subject: [PATCH 494/691] wallet: document db ops interface pattern Add a short package README describing when the db layer should use shared ops interfaces and how shared and backend-specific code should be laid out. This gives future tx-store and utxo-store work one local reference for the pattern instead of relying on review context alone. --- wallet/internal/db/README.md | 109 +++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 wallet/internal/db/README.md diff --git a/wallet/internal/db/README.md b/wallet/internal/db/README.md new file mode 100644 index 0000000000..452073bc14 --- /dev/null +++ b/wallet/internal/db/README.md @@ -0,0 +1,109 @@ +# wallet/internal/db + +This package keeps the public wallet-store APIs in one place while supporting +multiple SQL backends. + +## Ops interfaces + +Some store methods use a small backend-specific `ops` interface together with a +shared backend-independent helper. + +Here, `ops` is short for the backend-specific database operations that one +shared workflow needs. + +We use this pattern because postgres and sqlite usually agree on the high-level +wallet workflow, but they still differ in important low-level ways: + +- sqlc generates different parameter and row types for each backend +- nullable arguments often have different shapes, such as typed `sql.Null*` + values in postgres versus sqlite-specific generated forms +- integer widths and conversion helpers differ across backend bindings +- some statements need backend-specific SQL or binding workarounds +- we still want compile-time checking against each backend's concrete query set + +Without a small adapter layer, each backend file tends to duplicate the same +business workflow with only query-shape differences. That makes reviews noisy +and makes later invariant changes easy to miss in one backend. + +Use this pattern when a method has both of these properties: + +- the high-level workflow is the same for postgres and sqlite +- the SQL query types, nullable argument shapes, or row handling differ by + backend + +In that case: + +- keep the shared workflow in one backend-independent helper +- keep the backend adapters close to the concrete backend methods +- keep shared unit tests near the shared workflow +- keep backend-visible behavior in integration tests +- document the algorithm in detail on both the `ops` interface and the shared + helper so reviewers can see the intended sequencing and ownership boundaries + +The abstraction boundary is intentionally narrow: + +- one backend-specific `ops` interface per shared method workflow +- one shared backend-independent workflow helper for that method +- backend-specific preparation, row-count handling, sqlc binding shapes, and + query error mapping stay in backend files + +If a helper is only called from backend-specific files and never from the +shared workflow helper, it should stay in backend-specific files rather than in +the common workflow file. + +This approach is meant to keep one copy of the domain workflow while still +leaving backend-specific SQL details explicit and close to the actual queries. +The shared helper owns the sequencing and invariants. The backend `ops` +implementation owns query calls, generated binding types, backend-specific +conversions, and any helper code that only those backend methods need. + +Examples: + +- tx store: `CreateTx`, `UpdateTx`, `DeleteTx`, `RollbackToBlock` +- utxo store: `LeaseOutput`, `ReleaseOutput` + +Minimal example: + +```go +type someMethodOps interface { + load(ctx context.Context, id int64) (row, error) + write(ctx context.Context, req request) error +} + +func someMethodWithOps(ctx context.Context, req request, + ops someMethodOps) error { + loaded, err := ops.load(ctx, req.id) + if err != nil { + return err + } + + return ops.write(ctx, mergeRequest(req, loaded)) +} +``` + +The shared helper owns the ordering and invariants. Each backend `ops` +implementation only adapts query calls, generated sqlc types, and backend- +specific conversions needed by that shared method. + +The algorithm docs should be explicit. They should describe the ordered stages +of the shared workflow, what each stage is responsible for, and why the shared +helper owns that sequencing instead of leaving it implicit in backend files. + +Do not introduce an `ops` interface for thin read methods or simple wrappers. +If a method is mostly one query plus row conversion, prefer direct backend +implementations. Examples include `GetTx`, `ListTxns`, `GetUtxo`, +`ListUTXOs`, `ListLeasedOutputs`, and `Balance`. + +Likewise, do not add extra common-file helper layers for backend-only prep +work. If a helper only exists to support postgres or sqlite methods and is not +called by the shared workflow helper itself, keep it in backend-specific files. + +## File layout + +- shared helpers and shared workflows should stay backend-independent +- backend-specific implementations should stay close to the concrete backend +- method-specific files are preferred over large backend files that mix many + unrelated methods + +This layout keeps commit boundaries small, makes review easier, and lets shared +logic evolve without hiding backend-specific SQL details. From 98ca8b3353dc1c5eed093545c3f43e1f089d198c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 3 Apr 2026 18:39:27 +0800 Subject: [PATCH 495/691] wallet: share nullable SQL casting helpers Add reusable nullable-to-SQL integer helpers in safecasting.go and use them where account and utxo store queries currently hand-roll the same sql.Null conversions. This keeps backend files focused on query wiring while preserving the narrow common-workflow boundary and giving the conversion behavior one place to test. --- wallet/internal/db/accounts_pg.go | 24 ++++------- wallet/internal/db/accounts_sqlite.go | 24 ++++------- .../internal/db/postgres_utxostore_balance.go | 8 ++-- .../db/postgres_utxostore_listutxos.go | 27 ++---------- wallet/internal/db/safecasting.go | 27 ++++++++++++ wallet/internal/db/safecasting_test.go | 42 +++++++++++++++++++ .../internal/db/sqlite_utxostore_balance.go | 8 ++-- .../internal/db/sqlite_utxostore_listutxos.go | 27 ++---------- 8 files changed, 101 insertions(+), 86 deletions(-) diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index 4019822067..e42f494cb7 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -335,13 +335,10 @@ func (p pgAccountGetQueries) byNumber(ctx context.Context, return getAccount( ctx, p.q.GetAccountByWalletScopeAndNumber, sqlcpg.GetAccountByWalletScopeAndNumberParams{ - WalletID: int64(query.WalletID), - Purpose: int64(query.Scope.Purpose), - CoinType: int64(query.Scope.Coin), - AccountNumber: sql.NullInt64{ - Int64: int64(*query.AccountNumber), - Valid: true, - }, + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: nullableUint32ToSQLInt64(query.AccountNumber), }, query, pgAccountRowToInfo, ) } @@ -374,14 +371,11 @@ func (p pgAccountRenameQueries) byNumber(ctx context.Context, return renameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ - NewName: params.NewName, - WalletID: int64(params.WalletID), - Purpose: int64(params.Scope.Purpose), - CoinType: int64(params.Scope.Coin), - AccountNumber: sql.NullInt64{ - Int64: int64(*params.AccountNumber), - Valid: true, - }, + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountNumber: nullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index 19ce3ad573..d73d0f18cc 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -331,13 +331,10 @@ func (s sqliteAccountGetQueries) byNumber(ctx context.Context, return getAccount( ctx, s.q.GetAccountByWalletScopeAndNumber, sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ - WalletID: int64(query.WalletID), - Purpose: int64(query.Scope.Purpose), - CoinType: int64(query.Scope.Coin), - AccountNumber: sql.NullInt64{ - Int64: int64(*query.AccountNumber), - Valid: true, - }, + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: nullableUint32ToSQLInt64(query.AccountNumber), }, query, sqliteAccountRowToInfo, ) } @@ -369,14 +366,11 @@ func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, return renameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ - NewName: params.NewName, - WalletID: int64(params.WalletID), - Purpose: int64(params.Scope.Purpose), - CoinType: int64(params.Scope.Coin), - AccountNumber: sql.NullInt64{ - Int64: int64(*params.AccountNumber), - Valid: true, - }, + NewName: params.NewName, + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountNumber: nullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } diff --git a/wallet/internal/db/postgres_utxostore_balance.go b/wallet/internal/db/postgres_utxostore_balance.go index 993728f323..33c6d73b09 100644 --- a/wallet/internal/db/postgres_utxostore_balance.go +++ b/wallet/internal/db/postgres_utxostore_balance.go @@ -18,10 +18,10 @@ func (s *PostgresStore) Balance(ctx context.Context, balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: nullableUint32Int64Pg(params.Account), - MinConfirms: nullableInt32Pg(params.MinConfs), - MaxConfirms: nullableInt32Pg(params.MaxConfs), - CoinbaseMaturity: nullableInt32Pg(params.CoinbaseMaturity), + AccountNumber: nullableUint32ToSQLInt64(params.Account), + MinConfirms: nullableInt32ToSQLInt32(params.MinConfs), + MaxConfirms: nullableInt32ToSQLInt32(params.MaxConfs), + CoinbaseMaturity: nullableInt32ToSQLInt32(params.CoinbaseMaturity), }) if err != nil { return BalanceResult{}, fmt.Errorf("balance: %w", err) diff --git a/wallet/internal/db/postgres_utxostore_listutxos.go b/wallet/internal/db/postgres_utxostore_listutxos.go index 550fe1bea6..2a9afdeeb3 100644 --- a/wallet/internal/db/postgres_utxostore_listutxos.go +++ b/wallet/internal/db/postgres_utxostore_listutxos.go @@ -2,7 +2,6 @@ package db import ( "context" - "database/sql" "fmt" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -41,28 +40,8 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, func buildListUtxosParamsPg(query ListUtxosQuery) sqlcpg.ListUtxosParams { return sqlcpg.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: nullableUint32Int64Pg(query.Account), - MinConfirms: nullableInt32Pg(query.MinConfs), - MaxConfirms: nullableInt32Pg(query.MaxConfs), + AccountNumber: nullableUint32ToSQLInt64(query.Account), + MinConfirms: nullableInt32ToSQLInt32(query.MinConfs), + MaxConfirms: nullableInt32ToSQLInt32(query.MaxConfs), } } - -// nullableUint32Int64Pg converts an optional uint32 filter into the typed null -// form used by postgres sqlc queries. -func nullableUint32Int64Pg(value *uint32) sql.NullInt64 { - if value == nil { - return sql.NullInt64{} - } - - return sql.NullInt64{Int64: int64(*value), Valid: true} -} - -// nullableInt32Pg converts an optional int32 filter into the typed null form -// used by postgres sqlc queries. -func nullableInt32Pg(value *int32) sql.NullInt32 { - if value == nil { - return sql.NullInt32{} - } - - return sql.NullInt32{Int32: *value, Valid: true} -} diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 5393670073..5188a5f608 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -94,6 +94,33 @@ func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { return sql.NullInt32{Int32: toInt32, Valid: true}, nil } +// nullableInt32ToSQLInt32 converts an optional int32 to sql.NullInt32. +func nullableInt32ToSQLInt32(v *int32) sql.NullInt32 { + if v == nil { + return sql.NullInt32{} + } + + return sql.NullInt32{Int32: *v, Valid: true} +} + +// nullableInt32ToSQLInt64 converts an optional int32 to sql.NullInt64. +func nullableInt32ToSQLInt64(v *int32) sql.NullInt64 { + if v == nil { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: int64(*v), Valid: true} +} + +// nullableUint32ToSQLInt64 converts an optional uint32 to sql.NullInt64. +func nullableUint32ToSQLInt64(v *uint32) sql.NullInt64 { + if v == nil { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: int64(*v), Valid: true} +} + // nullInt32ToUint32 safely casts a sql.NullInt32 to an uint32, returning // an error if the value is out of range or invalid. func nullInt32ToUint32(n sql.NullInt32) (uint32, error) { diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index 0dd260851f..4523127a62 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -371,3 +371,45 @@ func TestNullInt32ToUint32(t *testing.T) { }) } } + +// TestNullableInt32ToSQLInt32 checks that optional int32 values become a valid +// sql.NullInt32 only when the pointer is present. +func TestNullableInt32ToSQLInt32(t *testing.T) { + t.Parallel() + + value := int32(42) + + require.Equal(t, sql.NullInt32{}, nullableInt32ToSQLInt32(nil)) + require.Equal(t, + sql.NullInt32{Int32: value, Valid: true}, + nullableInt32ToSQLInt32(&value), + ) +} + +// TestNullableInt32ToSQLInt64 checks that optional int32 values become a valid +// sql.NullInt64 only when the pointer is present. +func TestNullableInt32ToSQLInt64(t *testing.T) { + t.Parallel() + + value := int32(42) + + require.Equal(t, sql.NullInt64{}, nullableInt32ToSQLInt64(nil)) + require.Equal(t, + sql.NullInt64{Int64: int64(value), Valid: true}, + nullableInt32ToSQLInt64(&value), + ) +} + +// TestNullableUint32ToSQLInt64 checks that optional uint32 values become a +// valid sql.NullInt64 only when the pointer is present. +func TestNullableUint32ToSQLInt64(t *testing.T) { + t.Parallel() + + value := uint32(42) + + require.Equal(t, sql.NullInt64{}, nullableUint32ToSQLInt64(nil)) + require.Equal(t, + sql.NullInt64{Int64: int64(value), Valid: true}, + nullableUint32ToSQLInt64(&value), + ) +} diff --git a/wallet/internal/db/sqlite_utxostore_balance.go b/wallet/internal/db/sqlite_utxostore_balance.go index 694251b18f..2067550dc2 100644 --- a/wallet/internal/db/sqlite_utxostore_balance.go +++ b/wallet/internal/db/sqlite_utxostore_balance.go @@ -18,10 +18,10 @@ func (s *SqliteStore) Balance(ctx context.Context, balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: optionalUint32Int64Sqlite(params.Account), - MinConfirms: optionalInt32Sqlite(params.MinConfs), - MaxConfirms: optionalInt32Sqlite(params.MaxConfs), - CoinbaseMaturity: optionalInt32Sqlite(params.CoinbaseMaturity), + AccountNumber: nullableUint32ToSQLInt64(params.Account), + MinConfirms: nullableInt32ToSQLInt64(params.MinConfs), + MaxConfirms: nullableInt32ToSQLInt64(params.MaxConfs), + CoinbaseMaturity: nullableInt32ToSQLInt64(params.CoinbaseMaturity), }) if err != nil { return BalanceResult{}, fmt.Errorf("balance: %w", err) diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite_utxostore_listutxos.go index 80811a8bfa..741b4ce2ae 100644 --- a/wallet/internal/db/sqlite_utxostore_listutxos.go +++ b/wallet/internal/db/sqlite_utxostore_listutxos.go @@ -2,7 +2,6 @@ package db import ( "context" - "database/sql" "fmt" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -17,9 +16,9 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: optionalUint32Int64Sqlite(query.Account), - MinConfirms: optionalInt32Sqlite(query.MinConfs), - MaxConfirms: optionalInt32Sqlite(query.MaxConfs), + AccountNumber: nullableUint32ToSQLInt64(query.Account), + MinConfirms: nullableInt32ToSQLInt64(query.MinConfs), + MaxConfirms: nullableInt32ToSQLInt64(query.MaxConfs), }) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) @@ -40,23 +39,3 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, return utxos, nil } - -// optionalUint32Int64Sqlite converts an optional uint32 filter into the typed -// nullable form used by sqlite sqlc queries. -func optionalUint32Int64Sqlite(value *uint32) sql.NullInt64 { - if value == nil { - return sql.NullInt64{} - } - - return sql.NullInt64{Int64: int64(*value), Valid: true} -} - -// optionalInt32Sqlite converts an optional int32 filter into the typed nullable -// form used by sqlite sqlc queries. -func optionalInt32Sqlite(value *int32) sql.NullInt64 { - if value == nil { - return sql.NullInt64{} - } - - return sql.NullInt64{Int64: int64(*value), Valid: true} -} From d03b803da16486c36716ce01b7349fdd7b9a6ead Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 2 Apr 2026 13:57:43 -0300 Subject: [PATCH 496/691] wallet: improve goroutine-safety in concurrent itests --- .../internal/db/itest/account_store_test.go | 26 ++++++++++++++++--- .../internal/db/itest/address_store_test.go | 26 ++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 5d8c8ecafa..7f2c74819b 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -182,7 +182,13 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { scope := db.KeyScopeBIP0084 const workers = 20 - results := make([]uint32, workers) + + type createResult struct { + number uint32 + err error + } + + resultCh := make(chan createResult, workers) var wg sync.WaitGroup ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -192,6 +198,7 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { wg.Add(1) go func(i int) { defer wg.Done() + info, err := store.CreateDerivedAccount( ctx, db.CreateDerivedAccountParams{ WalletID: walletID, @@ -199,12 +206,25 @@ func TestCreateDerivedAccountConcurrent(t *testing.T) { Name: "acct-concurrent-" + strconv.Itoa(i), }, ) - require.NoError(t, err) - results[i] = info.AccountNumber + if err != nil { + resultCh <- createResult{err: err} + return + } + + resultCh <- createResult{number: info.AccountNumber} }(i) } wg.Wait() + close(resultCh) + + results := make([]uint32, 0, workers) + for result := range resultCh { + require.NoError(t, result.err) + results = append(results, result.number) + } + + require.Len(t, results, workers) // Verify all numbers are unique and sequential. sort.Slice(results, func(i, j int) bool { diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 19f0099e9b..97186e13eb 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -1059,7 +1059,13 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) const workers = 20 - results := make([]db.AddressInfo, workers) + + type deriveResult struct { + info db.AddressInfo + err error + } + + resultCh := make(chan deriveResult, workers) var wg sync.WaitGroup ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -1070,6 +1076,7 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { wg.Add(1) go func(i int) { defer wg.Done() + info, err := store.NewDerivedAddress( ctx, db.NewDerivedAddressParams{ WalletID: walletID, @@ -1078,12 +1085,25 @@ func TestNewDerivedAddressConcurrent(t *testing.T) { Change: false, }, deriveFn, ) - require.NoError(t, err) - results[i] = *info + if err != nil { + resultCh <- deriveResult{err: err} + return + } + + resultCh <- deriveResult{info: *info} }(i) } wg.Wait() + close(resultCh) + + results := make([]db.AddressInfo, 0, workers) + for result := range resultCh { + require.NoError(t, result.err) + results = append(results, result.info) + } + + require.Len(t, results, workers) // Verify all indexes are unique and sequential. indexes := make([]uint32, workers) From 906f26e3dbb53bfe4211acd87398014dee7df518 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 2 Apr 2026 13:58:10 -0300 Subject: [PATCH 497/691] wallet: remove timing dependency from timestamp itest --- wallet/internal/db/itest/account_store_test.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 7f2c74819b..7c9078c130 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -529,12 +529,8 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { createdNear time.Time } - // Create three accounts with slight delays to ensure different - // timestamps. var accounts []createdAccount for i := range 3 { - time.Sleep(1 * time.Second) - createdNear := time.Now() params := db.CreateDerivedAccountParams{ WalletID: walletID, @@ -557,11 +553,10 @@ func TestAccountCreatedAtTimestamp(t *testing.T) { 5*time.Second, "account %d CreatedAt should track creation", i) } - // Verify accounts are ordered by creation time. - require.True(t, accounts[0].info.CreatedAt.Before(accounts[1].info.CreatedAt), - "account 0 should have CreatedAt before account 1") - require.True(t, accounts[1].info.CreatedAt.Before(accounts[2].info.CreatedAt), - "account 1 should have CreatedAt before account 2") + require.False(t, accounts[0].info.CreatedAt.After(accounts[1].info.CreatedAt), + "account 0 should not have CreatedAt after account 1") + require.False(t, accounts[1].info.CreatedAt.After(accounts[2].info.CreatedAt), + "account 1 should not have CreatedAt after account 2") } // TestRenameAccount verifies that RenameAccount successfully renames accounts From 1bcf34ae67c54b621b41b7f09d109a94b61e6b96 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 11 Mar 2026 19:17:50 -0300 Subject: [PATCH 498/691] wallet: add AddressStore interface impl compile check --- wallet/internal/db/addresses_pg.go | 2 ++ wallet/internal/db/addresses_sqlite.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 98c05d19e5..8462f8729b 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -9,6 +9,8 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) +var _ AddressStore = (*PostgresStore)(nil) + // GetAddress retrieves information about a specific address, identified by // its script pubkey. func (s *PostgresStore) GetAddress(ctx context.Context, diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index c5eb7d353b..a1107f8b71 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -8,6 +8,8 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) +var _ AddressStore = (*SqliteStore)(nil) + // GetAddress retrieves information about a specific address, identified by // its script pubkey. func (s *SqliteStore) GetAddress(ctx context.Context, From 437cd36ee4f9746f0147faccdebafeebc7ae6d66 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 9 Mar 2026 18:26:38 -0300 Subject: [PATCH 499/691] wallet: add pagination base --- wallet/internal/db/page/doc.go | 31 ++ wallet/internal/db/page/iter.go | 55 +++ wallet/internal/db/page/iter_test.go | 487 ++++++++++++++++++++++++ wallet/internal/db/page/request.go | 119 ++++++ wallet/internal/db/page/request_test.go | 243 ++++++++++++ wallet/internal/db/page/result.go | 12 + 6 files changed, 947 insertions(+) create mode 100644 wallet/internal/db/page/doc.go create mode 100644 wallet/internal/db/page/iter.go create mode 100644 wallet/internal/db/page/iter_test.go create mode 100644 wallet/internal/db/page/request.go create mode 100644 wallet/internal/db/page/request_test.go create mode 100644 wallet/internal/db/page/result.go diff --git a/wallet/internal/db/page/doc.go b/wallet/internal/db/page/doc.go new file mode 100644 index 0000000000..b0795cd0f0 --- /dev/null +++ b/wallet/internal/db/page/doc.go @@ -0,0 +1,31 @@ +// Package page provides after-based pagination primitives for SQL-backed +// stores. +// +// # Core types +// +// A [Request] carries the parameters for a single page fetch: page limit, +// and an optional after that identifies where the previous page ended. +// The zero value requests the first page at [DefaultLimit]. +// +// A [Result] carries the items returned by one fetch together with +// [Result.Next]. Pass *Next back to [Request.WithAfter] to advance to the +// next page. +// +// Queries fetch normalizedLimit+1 rows internally and return at most +// normalizedLimit items. If the extra row exists, [Result.Next] is non-nil. +// If it does not, [Result.Next] is nil and the current page is the last page. +// +// # Iterating +// +// [Iter] wraps a fetch function in a standard [iter.Seq2] that pages +// transparently until the list is exhausted or the caller breaks early. +// It propagates fetch errors and respects context cancellation through +// the fetchPage callback. +// +// # Store integration +// +// Stores typically translate [Request.After] into an optional backend +// query parameter, fetch [Request.QueryLimit] rows with a single ordered +// SQL query, map the raw rows to domain items, and then call +// [BuildResult] to derive [Result.Next]. +package page diff --git a/wallet/internal/db/page/iter.go b/wallet/internal/db/page/iter.go new file mode 100644 index 0000000000..9116240f12 --- /dev/null +++ b/wallet/internal/db/page/iter.go @@ -0,0 +1,55 @@ +package page + +import ( + "context" + "iter" +) + +// Iter iterates through paginated results by repeatedly fetching pages and +// yielding items until exhaustion, caller break, error, or cancellation. +// +// Errors are yielded as the second value in the iterator pair. Callers must +// check the error on every iteration before using the yielded item. +func Iter[Query, Item, Cursor any](ctx context.Context, query Query, + fetch func(context.Context, Query) (Result[Item, Cursor], error), + withAfter func(Query, Cursor) Query) iter.Seq2[Item, error] { + + return func(yield func(Item, error) bool) { + var zero Item + + for { + err := ctx.Err() + if err != nil { + yield(zero, err) + + return + } + + result, err := fetch(ctx, query) + if err != nil { + yield(zero, err) + + return + } + + for _, item := range result.Items { + err = ctx.Err() + if err != nil { + yield(zero, err) + + return + } + + if !yield(item, nil) { + return + } + } + + if result.Next == nil { + return + } + + query = withAfter(query, *result.Next) + } + } +} diff --git a/wallet/internal/db/page/iter_test.go b/wallet/internal/db/page/iter_test.go new file mode 100644 index 0000000000..3b574bfbd9 --- /dev/null +++ b/wallet/internal/db/page/iter_test.go @@ -0,0 +1,487 @@ +package page + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// errTest is a sentinel error used across page package tests to +// verify error propagation through pagination helpers. +var errTest = errors.New("test error") + +// intPtr returns a pointer to the given int value. It is used in tests to +// construct Result literals that require a *int after. +func intPtr(v int) *int { + return &v +} + +// TestIterTraversal tests the traversal of items using the page iterator. +func TestIterTraversal(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + pages [][]int + wantItems []int + wantFetchCalls int + wantQueries []int + wantCursors []int + }{ + { + name: "empty result", + pages: [][]int{{}}, + wantItems: nil, + wantFetchCalls: 1, + wantQueries: nil, + wantCursors: nil, + }, + { + name: "single non-empty page", + pages: [][]int{{1, 2}}, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + wantQueries: nil, + wantCursors: nil, + }, + { + name: "multi page traversal", + pages: [][]int{{1, 2}, {3, 4}, {5}}, + wantItems: []int{1, 2, 3, 4, 5}, + wantFetchCalls: 3, + wantQueries: []int{0, 1}, + wantCursors: []int{2, 4}, + }, + { + name: "exact multiple of page limit", + pages: [][]int{{1, 2}, {3, 4}}, + wantItems: []int{1, 2, 3, 4}, + wantFetchCalls: 2, + wantQueries: []int{0}, + wantCursors: []int{2}, + }, + { + name: "limit one traversal", + pages: [][]int{{1}, {2}, {3}}, + wantItems: []int{1, 2, 3}, + wantFetchCalls: 3, + wantQueries: []int{0, 1}, + wantCursors: []int{1, 2}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + nextQueries []int + nextCursors []int + gotItems []int + ) + + fetchPage := func(_ context.Context, query int) (Result[int, int], + error) { + + require.Less(t, fetchCalls, len(tc.pages)) + require.Equal(t, fetchCalls, query) + + items := tc.pages[fetchCalls] + hasMore := fetchCalls < len(tc.pages)-1 + fetchCalls++ + + result := Result[int, int]{ + Items: items, + } + if hasMore && len(items) > 0 { + last := items[len(items)-1] + result.Next = &last + } + + return result, nil + } + + setCursor := func(query int, cursor int) int { + nextQueries = append(nextQueries, query) + nextCursors = append(nextCursors, cursor) + + return query + 1 + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + require.NoError(t, err) + + gotItems = append(gotItems, item) + } + + require.Equal(t, tc.wantItems, gotItems) + require.Equal(t, tc.wantFetchCalls, fetchCalls) + require.Equal(t, tc.wantQueries, nextQueries) + require.Equal(t, tc.wantCursors, nextCursors) + }) + } +} + +// TestIterFetchErrorOnFirstCall verifies that Iter yields the error immediately +// when fetchPage fails on the very first call, before any items are produced. +func TestIterFetchErrorOnFirstCall(t *testing.T) { + t.Parallel() + + gotItems := make([]int, 0) + + var iterErr error + + fetchPage := func(_ context.Context, _ int) (Result[int, int], error) { + return Result[int, int]{}, errTest + } + + setCursor := func(_ int, _ int) int { + return 0 + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + if err != nil { + iterErr = err + break + } + + gotItems = append(gotItems, item) + } + + require.ErrorIs(t, iterErr, errTest) + require.Empty(t, gotItems) +} + +// TestIterFetchErrorAfterTwoPages verifies that Iter yields all items from +// successful pages before propagating an error from a later fetchPage call. +func TestIterFetchErrorAfterTwoPages(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + nextCursors []int + iterErr error + ) + + gotItems := make([]int, 0) + + fetchPage := func( + _ context.Context, _ int, + ) (Result[int, int], error) { + + fetchCalls++ + switch fetchCalls { + case 1: + return Result[int, int]{ + Items: []int{1, 2}, + Next: intPtr(2), + }, nil + case 2: + return Result[int, int]{ + Items: []int{3, 4}, + Next: intPtr(4), + }, nil + default: + return Result[int, int]{}, errTest + } + } + + setCursor := func(query int, cursor int) int { + nextCursors = append(nextCursors, cursor) + return query + 1 + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + if err != nil { + iterErr = err + break + } + + gotItems = append(gotItems, item) + } + + require.ErrorIs(t, iterErr, errTest) + require.Equal(t, []int{1, 2, 3, 4}, gotItems) + require.Equal(t, 3, fetchCalls) + require.Equal(t, []int{2, 4}, nextCursors) +} + +// TestIterContextCancellation tests cancellation behavior for page iteration. +func TestIterContextCancellation(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + pages [][]int + cancelBefore bool + cancelAfterItems int + wantItems []int + wantFetchCalls int + }{ + { + name: "cancelled before first fetch", + pages: [][]int{{1, 2}}, + cancelBefore: true, + wantItems: nil, + wantFetchCalls: 0, + }, + { + name: "cancelled between pages", + pages: [][]int{{1, 2}, {3, 4}}, + cancelAfterItems: 2, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + }, + { + name: "cancel mid-page stops before next yield", + pages: [][]int{ + {1, 2, 3}, + {4, 5}, + }, + cancelAfterItems: 1, + wantItems: []int{1}, + wantFetchCalls: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + gotItems []int + iterErr error + ) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + if tc.cancelBefore { + cancel() + } + + fetchPage := func(ctx context.Context, _ int) (Result[int, int], + error) { + + err := ctx.Err() + if err != nil { + fetchCalls++ + + return Result[int, int]{}, err + } + + require.Less(t, fetchCalls, len(tc.pages)) + + items := tc.pages[fetchCalls] + fetchCalls++ + + result := Result[int, int]{ + Items: items, + } + if len(items) > 0 { + last := items[len(items)-1] + result.Next = &last + } + + return result, nil + } + + setCursor := func(query int, _ int) int { + return query + 1 + } + + for item, err := range Iter( + ctx, 0, fetchPage, setCursor, + ) { + if err != nil { + iterErr = err + break + } + + gotItems = append(gotItems, item) + if tc.cancelAfterItems > 0 && + len(gotItems) == tc.cancelAfterItems { + + cancel() + } + } + + require.ErrorIs(t, iterErr, context.Canceled) + require.Equal(t, tc.wantItems, gotItems) + require.Equal(t, tc.wantFetchCalls, fetchCalls) + }) + } +} + +// TestIterConsumerBreaks tests the page iterator when the consumer breaks out +// of the loop. +func TestIterConsumerBreaks(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + pages [][]int + stopAfter int + wantItems []int + wantFetchCalls int + wantNextCalls int + }{ + { + name: "mid page", + pages: [][]int{{1, 2, 3}, {4, 5}}, + stopAfter: 2, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + wantNextCalls: 0, + }, + { + // The consumer stops after consuming all items in the first page. + // setCursor is never called because the break happens before the + // iterator advances to the next page. + name: "at page boundary", + pages: [][]int{{1, 2}, {3, 4}}, + stopAfter: 2, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + wantNextCalls: 0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + nextCalls int + gotItems []int + ) + + fetchPage := func(_ context.Context, _ int) (Result[int, int], + error) { + + require.Less(t, fetchCalls, len(tc.pages)) + + items := tc.pages[fetchCalls] + hasMore := fetchCalls < len(tc.pages)-1 + fetchCalls++ + + result := Result[int, int]{ + Items: items, + } + if hasMore && len(items) > 0 { + last := items[len(items)-1] + result.Next = &last + } + + return result, nil + } + + setCursor := func(query int, cursor int) int { + nextCalls++ + + return query + cursor + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + require.NoError(t, err) + + gotItems = append(gotItems, item) + if len(gotItems) == tc.stopAfter { + break + } + } + + require.Equal(t, tc.wantItems, gotItems) + require.Equal(t, tc.wantFetchCalls, fetchCalls) + require.Equal(t, tc.wantNextCalls, nextCalls) + }) + } +} + +// TestIterNextNilTermination verifies Iter stops when Next becomes nil, +// without requiring an extra empty-page fetch. +func TestIterNextNilTermination(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + pages []Result[int, int] + wantItems []int + wantFetchCalls int + wantNextCalls int + }{ + { + name: "stops on last non-empty page", + pages: []Result[int, int]{ + {Items: []int{1, 2}}, + }, + wantItems: []int{1, 2}, + wantFetchCalls: 1, + wantNextCalls: 0, + }, + { + name: "multi-page, stops mid", + pages: []Result[int, int]{ + {Items: []int{1, 2}, Next: intPtr(2)}, + {Items: []int{3}}, + }, + wantItems: []int{1, 2, 3}, + wantFetchCalls: 2, + wantNextCalls: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + fetchCalls int + nextCalls int + gotItems []int + ) + + fetchPage := func(_ context.Context, _ int) (Result[int, int], + error) { + + require.Less(t, fetchCalls, len(tc.pages)) + + result := tc.pages[fetchCalls] + fetchCalls++ + + return result, nil + } + + setCursor := func(query int, _ int) int { + nextCalls++ + + return query + 1 + } + + for item, err := range Iter( + t.Context(), 0, fetchPage, setCursor, + ) { + require.NoError(t, err) + + gotItems = append(gotItems, item) + } + + require.Equal(t, tc.wantItems, gotItems) + require.Equal(t, tc.wantFetchCalls, fetchCalls) + require.Equal(t, tc.wantNextCalls, nextCalls) + }) + } +} diff --git a/wallet/internal/db/page/request.go b/wallet/internal/db/page/request.go new file mode 100644 index 0000000000..8653199fab --- /dev/null +++ b/wallet/internal/db/page/request.go @@ -0,0 +1,119 @@ +package page + +const ( + // DefaultLimit is the default number of items that can be returned to a + // page. + DefaultLimit = 100 + + // MaxLimit is the maximum number of items that can be returned to a page. + // Store implementations may fetch MaxLimit+1 rows internally to detect + // whether another page exists. + MaxLimit = 1000 +) + +// Request holds the parameters for a paginated list query. The zero value is +// valid and requests the first page at DefaultLimit. All With* methods +// return a modified shallow copy of the request. +// +// Fields are unexported so that callers must use the With* methods and +// accessor functions. This design preserves the normalization of limit (zero +// maps to DefaultLimit via normalizedLimit()) and ensures consistency across +// all page operations. +type Request[Cursor any] struct { + // limit is the maximum number of items to return per page. + limit uint32 + + // after is the pagination cursor that marks where the next page + // starts after. Nil means the first page. + after *Cursor +} + +// QueryLimit returns the number of rows the SQL query should fetch. Queries +// fetch normalizedLimit+1 rows, so BuildResult can use the extra row as a +// lookahead signal to determine whether another page exists. +func (r Request[Cursor]) QueryLimit() uint32 { + return r.normalizedLimit() + 1 +} + +// WithLimit returns a copy of the request with the limit replaced. The limit is +// not validated here; normalization (zero -> DefaultLimit, over MaxLimit -> +// MaxLimit) happens in normalizedLimit() and QueryLimit(). A caller passing +// 0 or a large value will not see an error, the value will just be normalized +// later. +func (r Request[Cursor]) WithLimit(limit uint32) Request[Cursor] { + r.limit = limit + + return r +} + +// After returns the pagination after from the previous page. +// A false ok return value means the first page is being requested. +func (r Request[Cursor]) After() (Cursor, bool) { + if r.after == nil { + var zero Cursor + + return zero, false + } + + return *r.after, true +} + +// WithAfter returns a copy of the request with the after replaced. +// Calling this on a zero-value Request produces a request for the second page +// (the page after this after). It takes the after by value to avoid +// the caller retaining a pointer into the Request. +func (r Request[Cursor]) WithAfter(after Cursor) Request[Cursor] { + r.after = &after + + return r +} + +// BuildResult assembles a page.Result from a slice of items already fetched by +// the caller. It uses r.normalizedLimit to determine whether the query fetched +// an extra lookahead row. The toCursor function is called on the last item of +// the possibly trimmed slice. +// +// An empty slice always returns an empty result. +// +// If len(items) is greater than normalizedLimit, it trims to normalizedLimit +// and sets Next to the after of the last item in the trimmed slice. +// Otherwise, Next is nil. +func BuildResult[Cursor, Item any](r Request[Cursor], items []Item, + nextOf func(Item) Cursor) Result[Item, Cursor] { + + if len(items) == 0 { + return Result[Item, Cursor]{Items: items} + } + + limit := r.normalizedLimit() + if len(items) <= int(limit) { + return Result[Item, Cursor]{ + Items: items, + } + } + + items = items[:int(limit)] + last := items[len(items)-1] + cursor := nextOf(last) + + return Result[Item, Cursor]{ + Items: items, + Next: &cursor, + } +} + +// normalizedLimit returns the normalized requested page limit for this request. +// A limit of zero returns DefaultLimit. A limit greater than MaxLimit is +// clamped to MaxLimit. +func (r Request[Cursor]) normalizedLimit() uint32 { + switch { + case r.limit == 0: + return DefaultLimit + + case r.limit > MaxLimit: + return MaxLimit + + default: + return r.limit + } +} diff --git a/wallet/internal/db/page/request_test.go b/wallet/internal/db/page/request_test.go new file mode 100644 index 0000000000..e1de74ca5a --- /dev/null +++ b/wallet/internal/db/page/request_test.go @@ -0,0 +1,243 @@ +package page + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRequestSize verifies that normalizedLimit normalizes the raw limit field. +func TestRequestSize(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + size uint32 + wantSize uint32 + }{ + { + name: "zero defaults to DefaultLimit", + size: 0, + wantSize: DefaultLimit, + }, + { + name: "minimum in range", + size: 1, + wantSize: 1, + }, + { + name: "exactly MaxLimit passes through", + size: MaxLimit, + wantSize: MaxLimit, + }, + { + name: "over MaxLimit clamped to MaxLimit", + size: uint32(MaxLimit) + 1, + wantSize: MaxLimit, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + request := Request[uint32]{}.WithLimit(tc.size) + require.Equal(t, tc.wantSize, request.normalizedLimit()) + }) + } +} + +// TestQueryLimit verifies that QueryLimit returns normalizedLimit+1, +// including at MaxLimit. +func TestQueryLimit(t *testing.T) { + t.Parallel() + + t.Run("uses limit plus one", func(t *testing.T) { + t.Parallel() + + r := Request[uint32]{}.WithLimit(25) + require.Equal(t, r.normalizedLimit()+1, r.QueryLimit()) + }) + + t.Run("at max page limit", func(t *testing.T) { + t.Parallel() + + r := Request[uint32]{}.WithLimit(MaxLimit) + require.Equal(t, uint32(MaxLimit)+1, r.QueryLimit()) + }) +} + +// TestRequestChaining verifies that chaining With* calls produces the +// expected limit and after on the resulting Request. +func TestRequestChaining(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + request Request[uint32] + wantSize uint32 + wantCursor uint32 + wantHasCursor bool + }{ + { + name: "after nil by default", + request: Request[uint32]{}, + wantSize: DefaultLimit, + wantHasCursor: false, + }, + { + name: "after set without limit", + request: Request[uint32]{}.WithAfter(42), + wantSize: DefaultLimit, + wantCursor: 42, + wantHasCursor: true, + }, + { + name: "limit and after set together", + request: Request[uint32]{}.WithLimit(50).WithAfter(99), + wantSize: 50, + wantCursor: 99, + wantHasCursor: true, + }, + { + name: "after overwrites previous after", + request: Request[uint32]{}.WithAfter(1).WithAfter(2), + wantSize: DefaultLimit, + wantCursor: 2, + wantHasCursor: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.wantSize, tc.request.normalizedLimit()) + + if !tc.wantHasCursor { + _, ok := tc.request.After() + require.False(t, ok) + + return + } + + after, ok := tc.request.After() + require.True(t, ok) + require.Equal(t, tc.wantCursor, after) + }) + } +} + +// TestRequestWithSizeImmutability verifies that WithLimit returns a new +// Request and does not modify the original. +func TestRequestWithSizeImmutability(t *testing.T) { + t.Parallel() + + original := Request[uint32]{}.WithLimit(10).WithAfter(7) + updated := original.WithLimit(20) + + require.Equal(t, uint32(10), original.normalizedLimit()) + originalAfter, ok := original.After() + require.True(t, ok) + require.Equal(t, uint32(7), originalAfter) + + require.Equal(t, uint32(20), updated.normalizedLimit()) + updatedAfter, ok := updated.After() + require.True(t, ok) + require.Equal(t, uint32(7), updatedAfter) +} + +// TestRequestWithCursorImmutability verifies that WithAfter returns a new +// Request and does not modify the original. +func TestRequestWithCursorImmutability(t *testing.T) { + t.Parallel() + + original := Request[uint32]{}.WithLimit(10).WithAfter(7) + updated := original.WithAfter(9) + + require.Equal(t, uint32(10), original.normalizedLimit()) + originalAfter, ok := original.After() + require.True(t, ok) + require.Equal(t, uint32(7), originalAfter) + + require.Equal(t, uint32(10), updated.normalizedLimit()) + updatedAfter, ok := updated.After() + require.True(t, ok) + require.Equal(t, uint32(9), updatedAfter) +} + +// TestRequestAfterReturnsCursorCopy verifies that mutating the local cursor +// variable returned by After does not mutate the request's internal cursor. +func TestRequestAfterReturnsCursorCopy(t *testing.T) { + t.Parallel() + + request := Request[uint32]{}.WithAfter(7) + after, ok := request.After() + + require.True(t, ok) + require.Equal(t, uint32(7), after) + + after = 9 + require.Equal(t, uint32(9), after) + + originalAfter, ok := request.After() + require.True(t, ok) + require.Equal(t, uint32(7), originalAfter) +} + +// TestBuildResult verifies BuildResult assembles the correct Result using the +// lookahead row trimming and Next logic. +func TestBuildResult(t *testing.T) { + t.Parallel() + + toCursor := func(item int) int { return item } + + testCases := []struct { + name string + items []int + size uint32 + wantItems []int + wantNext *int + }{ + { + name: "empty slice returns empty result", + items: []int{}, + size: 100, + wantItems: []int{}, + wantNext: nil, + }, + { + name: "len less than limit leaves Next nil", + items: []int{1, 2}, + size: 5, + wantItems: []int{1, 2}, + wantNext: nil, + }, + { + name: "len equal to limit leaves Next nil", + items: []int{1, 2}, + size: 2, + wantItems: []int{1, 2}, + wantNext: nil, + }, + { + name: "len greater than limit trims and sets Next", + items: []int{1, 2, 3}, + size: 2, + wantItems: []int{1, 2}, + wantNext: func() *int { v := 2; return &v }(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := Request[int]{}.WithLimit(tc.size) + result := BuildResult(req, tc.items, toCursor) + + require.Equal(t, tc.wantItems, result.Items) + require.Equal(t, tc.wantNext, result.Next) + }) + } +} diff --git a/wallet/internal/db/page/result.go b/wallet/internal/db/page/result.go new file mode 100644 index 0000000000..4387aa882b --- /dev/null +++ b/wallet/internal/db/page/result.go @@ -0,0 +1,12 @@ +package page + +// Result holds one page of items returned by a paginated list query. +type Result[T any, C any] struct { + // Items contain the results for this page. It may be empty on the last + // page. + Items []T + + // Next is the after to use for fetching the next page. It is nil when + // no more pages exist. + Next *C +} From 12f26d6a1db0e46b35a42f203763055cdf338db2 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 11 Mar 2026 19:18:36 -0300 Subject: [PATCH 500/691] wallet: add pagination to addresses --- wallet/internal/db/addresses_common.go | 34 +- wallet/internal/db/addresses_pg.go | 86 +++- wallet/internal/db/addresses_sqlite.go | 84 +++- wallet/internal/db/data_types.go | 4 + wallet/internal/db/interface.go | 16 +- .../internal/db/itest/address_store_test.go | 467 +++++++++++++++++- .../postgres/000006_addresses.down.sql | 1 + .../postgres/000006_addresses.up.sql | 4 + .../sqlite/000006_addresses.down.sql | 1 + .../migrations/sqlite/000006_addresses.up.sql | 4 + .../db/queries/postgres/addresses.sql | 24 +- .../internal/db/queries/sqlite/addresses.sql | 24 +- .../db/sqlc/postgres/addresses.sql.go | 24 +- wallet/internal/db/sqlc/postgres/querier.go | 7 +- .../internal/db/sqlc/sqlite/addresses.sql.go | 26 +- wallet/internal/db/sqlc/sqlite/querier.go | 7 +- 16 files changed, 723 insertions(+), 90 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index 204712b022..e3e059d531 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -359,36 +359,14 @@ func getAddress[T any, Args any](ctx context.Context, return nil, ErrAddressNotFound } -// listAddresses is a generic helper that retrieves addresses using the provided -// lister function and converts the results to AddressInfo structs. -func listAddresses[T any, Args any](ctx context.Context, - lister func(context.Context, Args) ([]T, error), args Args, - toInfo func(T) (*AddressInfo, error)) ([]AddressInfo, error) { +// nextListAddressesQuery returns a query with its pagination cursor advanced to +// the provided value. +func nextListAddressesQuery(q ListAddressesQuery, + cursor uint32) ListAddressesQuery { - rows, err := lister(ctx, args) - if err != nil { - return nil, fmt.Errorf("list addresses: %w", err) - } - - return addressInfosFromRows(rows, toInfo) -} - -// addressInfosFromRows converts a slice of row types to AddressInfo structs -// using the provided converter function. -func addressInfosFromRows[T any](rows []T, - toInfo func(T) (*AddressInfo, error)) ([]AddressInfo, error) { - - infos := make([]AddressInfo, len(rows)) - for i, row := range rows { - info, err := toInfo(row) - if err != nil { - return nil, err - } - - infos[i] = *info - } + q.Page = q.Page.WithAfter(cursor) - return infos, nil + return q } // derivedAddressAdapters groups the functions needed to create a diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 8462f8729b..7159261ac5 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -4,8 +4,10 @@ import ( "context" "database/sql" "fmt" + "iter" "time" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -31,19 +33,31 @@ func (s *PostgresStore) GetAddress(ctx context.Context, return getAddressByQuery(ctx, query, getByScript) } -// ListAddresses returns a slice of AddressInfo for all addresses in a given -// account. +// ListAddresses returns a page of addresses matching the given query. func (s *PostgresStore) ListAddresses(ctx context.Context, - query ListAddressesQuery) ([]AddressInfo, error) { + query ListAddressesQuery) (page.Result[AddressInfo, uint32], error) { + + items, err := pgListAddressesByAccount(ctx, s.queries, query) + if err != nil { + return page.Result[AddressInfo, uint32]{}, err + } - return listAddresses( - ctx, s.queries.ListAddressesByAccount, - sqlcpg.ListAddressesByAccountParams{ - WalletID: int64(query.WalletID), - Purpose: int64(query.Scope.Purpose), - CoinType: int64(query.Scope.Coin), - AccountName: query.AccountName, - }, pgAddressRowToInfo, + result := page.BuildResult( + query.Page, items, + func(item AddressInfo) uint32 { + return item.ID + }, + ) + + return result, nil +} + +// IterAddresses returns an iterator over paginated address results. +func (s *PostgresStore) IterAddresses(ctx context.Context, + query ListAddressesQuery) iter.Seq2[AddressInfo, error] { + + return page.Iter( + ctx, query, s.ListAddresses, nextListAddressesQuery, ) } @@ -300,3 +314,53 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { return info, nil } + +// pgListAddressesByAccount lists addresses filtered by wallet ID, key scope, +// and account name, with pagination support. +func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, + query ListAddressesQuery) ([]AddressInfo, error) { + + rows, err := q.ListAddressesByAccount( + ctx, pgBuildAddressPageParams(query), + ) + if err != nil { + return nil, fmt.Errorf("list addresses by account: %w", err) + } + + items := make([]AddressInfo, len(rows)) + for i, row := range rows { + item, err := pgAddressRowToInfo(row) + if err != nil { + return nil, + fmt.Errorf("list addresses by account: map address row: %w", + err) + } + + items[i] = *item + } + + return items, nil +} + +// pgBuildAddressPageParams translates a ListAddresses query to +// ListAddressesByAccount parameters, handling pagination cursors. +func pgBuildAddressPageParams( + q ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { + + params := sqlcpg.ListAddressesByAccountParams{ + WalletID: int64(q.WalletID), + Purpose: int64(q.Scope.Purpose), + CoinType: int64(q.Scope.Coin), + AccountName: q.AccountName, + PageLimit: int64(q.Page.QueryLimit()), + } + + if cursor, ok := q.Page.After(); ok { + params.CursorID = sql.NullInt64{ + Int64: int64(cursor), + Valid: true, + } + } + + return params +} diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index a1107f8b71..0e1bbc500e 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -3,8 +3,11 @@ package db import ( "context" "database/sql" + "fmt" + "iter" "time" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -30,19 +33,31 @@ func (s *SqliteStore) GetAddress(ctx context.Context, return getAddressByQuery(ctx, query, getByScript) } -// ListAddresses returns a slice of AddressInfo for all addresses in a given -// account. +// ListAddresses returns a page of addresses matching the given query. func (s *SqliteStore) ListAddresses(ctx context.Context, - query ListAddressesQuery) ([]AddressInfo, error) { + query ListAddressesQuery) (page.Result[AddressInfo, uint32], error) { + + items, err := sqliteListAddressesByAccount(ctx, s.queries, query) + if err != nil { + return page.Result[AddressInfo, uint32]{}, err + } - return listAddresses( - ctx, s.queries.ListAddressesByAccount, - sqlcsqlite.ListAddressesByAccountParams{ - WalletID: int64(query.WalletID), - Purpose: int64(query.Scope.Purpose), - CoinType: int64(query.Scope.Coin), - AccountName: query.AccountName, - }, sqliteAddressRowToInfo, + result := page.BuildResult( + query.Page, items, + func(item AddressInfo) uint32 { + return item.ID + }, + ) + + return result, nil +} + +// IterAddresses returns an iterator over paginated address results. +func (s *SqliteStore) IterAddresses(ctx context.Context, + query ListAddressesQuery) iter.Seq2[AddressInfo, error] { + + return page.Iter( + ctx, query, s.ListAddresses, nextListAddressesQuery, ) } @@ -293,3 +308,50 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, return info, nil } + +// sqliteListAddressesByAccount lists addresses filtered by wallet ID, key +// scope, and account name, with pagination support. +func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, + query ListAddressesQuery) ([]AddressInfo, error) { + + rows, err := q.ListAddressesByAccount( + ctx, sqliteBuildAddressPageParams(query), + ) + if err != nil { + return nil, fmt.Errorf("list addresses by account: %w", err) + } + + items := make([]AddressInfo, len(rows)) + for i, row := range rows { + item, err := sqliteAddressRowToInfo(row) + if err != nil { + return nil, + fmt.Errorf("list addresses by account: map address row: %w", + err) + } + + items[i] = *item + } + + return items, nil +} + +// sqliteBuildAddressPageParams translates a ListAddresses query to +// ListAddressesByAccount parameters, handling pagination cursors. +func sqliteBuildAddressPageParams( + q ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { + + params := sqlcsqlite.ListAddressesByAccountParams{ + WalletID: int64(q.WalletID), + Purpose: int64(q.Scope.Purpose), + CoinType: int64(q.Scope.Coin), + AccountName: q.AccountName, + PageLimit: int64(q.Page.QueryLimit()), + } + + if cursor, ok := q.Page.After(); ok { + params.CursorID = int64(cursor) + } + + return params +} diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index a70c087606..f37d1dad1b 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" ) const ( @@ -710,6 +711,9 @@ type ListAddressesQuery struct { // Scope is the key scope of the account. Scope KeyScope + + // Page holds the pagination parameters for this query. + Page page.Request[uint32] } // -------------------- diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 280e23d012..2e838f9369 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -3,6 +3,9 @@ package db import ( "context" "errors" + "iter" + + "github.com/btcsuite/btcwallet/wallet/internal/db/page" ) var ( @@ -235,10 +238,15 @@ type AddressStore interface { // an error if the address is not found. GetAddress(ctx context.Context, query GetAddressQuery) (*AddressInfo, error) - // ListAddresses returns a slice of AddressInfo for all addresses in a - // given account. It returns an empty slice if no addresses are found. - ListAddresses(ctx context.Context, query ListAddressesQuery) ([]AddressInfo, - error) + // ListAddresses returns one page of addresses for the given query, + // including a next-cursor for the following page. + ListAddresses(ctx context.Context, query ListAddressesQuery) ( + page.Result[AddressInfo, uint32], error) + + // IterAddresses returns an iterator that fetches pages transparently and + // yields addresses one by one until exhaustion or error. + IterAddresses(ctx context.Context, + query ListAddressesQuery) iter.Seq2[AddressInfo, error] // GetAddressSecret retrieves the encrypted secret material for a given // address. Returns the AddressSecret containing encrypted private key diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 97186e13eb..81a416595a 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -15,6 +15,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" "github.com/stretchr/testify/require" ) @@ -35,9 +36,9 @@ func mockDeriveFunc() db.AddressDerivationFunc { } } +// newDerivedAddress creates and returns a derived address for testing. func newDerivedAddress(t *testing.T, store db.AddressStore, walletID uint32, scope db.KeyScope, accountName string, change bool) *db.AddressInfo { - t.Helper() info, err := store.NewDerivedAddress( @@ -53,10 +54,11 @@ func newDerivedAddress(t *testing.T, store db.AddressStore, walletID uint32, return info } +// createDerivedAddresses creates and returns a slice of derived addresses for +// testing. func createDerivedAddresses(t *testing.T, store db.AddressStore, walletID uint32, scope db.KeyScope, accountName string, change bool, count int) []db.AddressInfo { - t.Helper() addresses := make([]db.AddressInfo, 0, count) @@ -70,9 +72,9 @@ func createDerivedAddresses(t *testing.T, store db.AddressStore, return addresses } +// getAccountByName retrieves an account by wallet, scope, and name for testing. func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, scope db.KeyScope, accountName string) *db.AccountInfo { - t.Helper() account, err := store.GetAccount( @@ -83,6 +85,45 @@ func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, return account } +// collectAddressPages collects paginated address results by iterating +// through all pages from ListAddresses, using cursor pagination until +// Next is nil. +func collectAddressPages(t *testing.T, store db.AddressStore, + query db.ListAddressesQuery) []page.Result[db.AddressInfo, uint32] { + t.Helper() + + pages := make([]page.Result[db.AddressInfo, uint32], 0) + for { + pageResult, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + pages = append(pages, pageResult) + + if pageResult.Next == nil { + return pages + } + + query.Page = query.Page.WithAfter(*pageResult.Next) + } +} + +// flattenAddressPages flattens paginated address results into a single +// slice containing all addresses from all pages. +func flattenAddressPages( + pages []page.Result[db.AddressInfo, uint32]) []db.AddressInfo { + + count := 0 + for i := range pages { + count += len(pages[i].Items) + } + + addresses := make([]db.AddressInfo, 0, count) + for i := range pages { + addresses = append(addresses, pages[i].Items...) + } + + return addresses +} + // TestNewImportedAddress verifies that NewImportedAddress correctly imports // addresses of different types, both watch-only and spendable. func TestNewImportedAddress(t *testing.T) { @@ -236,7 +277,9 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { queries := store.Queries() walletID := newWallet(t, store, "wallet-encrypted-script") createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0049Plus, "imported") + createImportedAccount( + t, store, walletID, db.KeyScopeBIP0049Plus, "imported", + ) redeemScript := RandomBytes(32) witnessScript := RandomBytes(48) @@ -661,9 +704,9 @@ func TestGetAddress(t *testing.T) { } } -// TestListAddresses verifies that ListAddresses correctly returns all -// addresses for an account in a specified scope, filters by scope -// appropriately, and handles empty results without error. +// TestListAddresses verifies that ListAddresses correctly returns addresses +// with page-contract behavior, filters by scope appropriately, and handles +// empty results without error. func TestListAddresses(t *testing.T) { t.Parallel() @@ -780,7 +823,7 @@ func TestListAddresses(t *testing.T) { walletID := newWallet(t, store, tc.name+"-wallet") query := tc.setupFunc(t, store, store, walletID) - addrs, err := store.ListAddresses(t.Context(), query) + pageResult, err := store.ListAddresses(t.Context(), query) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) @@ -788,6 +831,7 @@ func TestListAddresses(t *testing.T) { } require.NoError(t, err) + addrs := pageResult.Items require.Len(t, addrs, tc.wantCount) if tc.validate != nil { @@ -946,7 +990,7 @@ func TestListAddressesOrdering(t *testing.T) { t, store, walletID, db.KeyScopeBIP0084, "ordering-account", true, 3, ) - addresses, err := store.ListAddresses( + pageResult, err := store.ListAddresses( t.Context(), db.ListAddressesQuery{ WalletID: walletID, @@ -956,6 +1000,7 @@ func TestListAddressesOrdering(t *testing.T) { ) require.NoError(t, err) + addresses := pageResult.Items require.Len(t, addresses, 6) // Separate addresses by branch for verification. @@ -987,6 +1032,410 @@ func TestListAddressesOrdering(t *testing.T) { } } +// TestListAddressesPagination verifies that ListAddresses paginates correctly +// and sets Next without requiring an extra round-trip. +func TestListAddressesPagination(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-addresses-strong-mode") + scope := db.KeyScopeBIP0084 + + createDerivedAccount(t, store, walletID, scope, "account-a") + createDerivedAccount(t, store, walletID, scope, "account-b") + + accountA := createDerivedAddresses( + t, store, walletID, scope, "account-a", false, 5, + ) + createDerivedAddresses(t, store, walletID, scope, "account-b", false, 2) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "account-a", + Page: page.Request[uint32]{}.WithLimit(2), + } + + page1, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.Equal(t, accountA[:2], page1.Items) + require.NotNil(t, page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Equal(t, accountA[2:4], page2.Items) + require.NotNil(t, page2.Next) + + query.Page = query.Page.WithAfter(*page2.Next) + page3, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page3.Items, 1) + require.Equal(t, accountA[4:], page3.Items) + require.Nil(t, page3.Next) + + query.Page = query.Page.WithAfter(page3.Items[len(page3.Items)-1].ID) + page4, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Empty(t, page4.Items) + require.Nil(t, page4.Next) + + paged := append([]db.AddressInfo{}, page1.Items...) + paged = append(paged, page2.Items...) + paged = append(paged, page3.Items...) + require.Equal(t, accountA, paged) + for i, addr := range paged { + require.Equal(t, uint32(i), addr.Index) + require.Equal(t, uint32(0), addr.Branch) + } +} + +// TestListAddressesExactBoundary verifies that pagination correctly handles +// the exact boundary case where total results equal page-size multiples. +func TestListAddressesExactBoundary(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "boundary-wallet") + scope := db.KeyScopeBIP0084 + accountName := "boundary-account" + + createDerivedAccount(t, store, walletID, scope, accountName) + expected := createDerivedAddresses( + t, store, walletID, scope, accountName, false, 4, + ) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}.WithLimit(2), + } + + page1, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Equal(t, expected[:2], page1.Items) + require.NotNil(t, page1.Next) + require.Equal(t, page1.Items[1].ID, *page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Equal(t, expected[2:], page2.Items) + require.Nil(t, page2.Next) + require.Greater(t, page2.Items[0].ID, *page1.Next) + + query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + page3, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Empty(t, page3.Items) + require.Nil(t, page3.Next) +} + +// TestListAddressesPagedEmptyResult verifies that paginated ListAddresses +// returns an empty result with no cursor for an account with no addresses. +func TestListAddressesPagedEmptyResult(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-addresses-empty") + scope := db.KeyScopeBIP0084 + pageSize := uint(2) + + createDerivedAccount(t, store, walletID, scope, "empty-account") + + pageResult, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "empty-account", + Page: page.Request[uint32]{}.WithLimit(uint32(pageSize)), + }) + require.NoError(t, err) + require.Empty(t, pageResult.Items) + require.Nil(t, pageResult.Next) +} + +// TestListAddressesDeterministicPagination verifies stable ID-ordered address +// pagination and next-cursor behavior. +func TestListAddressesDeterministicPagination(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-address-deterministic") + scope := db.KeyScopeBIP0084 + accountName := "deterministic-account" + createDerivedAccount(t, store, walletID, scope, accountName) + expected := createDerivedAddresses( + t, store, walletID, scope, accountName, false, 5, + ) + + pages := collectAddressPages(t, store, db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}.WithLimit(2), + }) + require.Len(t, pages, 3) + require.Len(t, pages[0].Items, 2) + require.Len(t, pages[1].Items, 2) + require.Len(t, pages[2].Items, 1) + require.NotNil(t, pages[0].Next) + require.NotNil(t, pages[1].Next) + require.Nil(t, pages[2].Next) + + addresses := flattenAddressPages(pages) + require.Equal(t, expected, addresses) + + seenIDs := make(map[uint32]struct{}, len(addresses)) + for i := range pages { + for j, addr := range pages[i].Items { + _, duplicate := seenIDs[addr.ID] + require.False(t, duplicate) + seenIDs[addr.ID] = struct{}{} + + // Skip the first item on the first page; there's no prior cursor + // to compare against. + if i == 0 && j == 0 { + continue + } + + // First item on a later page: verify it sorts strictly after the + // previous page's cursor to ensure no gaps or duplicates at page + // boundaries. + if j == 0 { + require.Greater(t, addr.ID, *pages[i-1].Next) + continue + } + + // Items within the same page: verify strict ordering to ensure + // the page contents are sorted. + require.Greater(t, addr.ID, pages[i].Items[j-1].ID) + } + } + + for i := range addresses { + if i == 0 { + continue + } + + require.Less(t, addresses[i-1].ID, addresses[i].ID) + } +} + +// TestListAddressesAccountIsolation verifies that ListAddresses returns only +// addresses for the requested account, excluding addresses from other accounts. +func TestListAddressesAccountIsolation(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-address-account-isolation") + scope := db.KeyScopeBIP0084 + + createDerivedAccount(t, store, walletID, scope, "account-a") + createDerivedAccount(t, store, walletID, scope, "account-b") + + expected := createDerivedAddresses( + t, store, walletID, scope, "account-a", false, 5, + ) + otherAccount := createDerivedAddresses( + t, store, walletID, scope, "account-b", false, 3, + ) + + pages := collectAddressPages(t, store, db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "account-a", + Page: page.Request[uint32]{}.WithLimit(2), + }) + addresses := flattenAddressPages(pages) + + require.Len(t, addresses, len(expected)) + require.Equal(t, expected, addresses) + accountAID := expected[0].AccountID + accountBID := otherAccount[0].AccountID + for _, addr := range addresses { + require.Equal(t, accountAID, addr.AccountID) + require.NotEqual(t, accountBID, addr.AccountID) + } +} + +// TestListAddressesInsertAfterCursor verifies inserts after page N are +// returned on page N+1 when pagination uses increasing ID cursors. +func TestListAddressesInsertAfterCursor(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-address-boundary-insert") + scope := db.KeyScopeBIP0084 + accountName := "boundary-account" + createDerivedAccount(t, store, walletID, scope, accountName) + createDerivedAddresses(t, store, walletID, scope, accountName, false, 3) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}. + WithLimit(2), + } + page1, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.NotNil(t, page1.Next) + + // Pagination is by increasing address ID (id > cursor). + // An address created after page 1 should therefore appear on page 2. + inserted := newDerivedAddress( + t, store, walletID, scope, accountName, false, + ) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListAddresses(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Equal(t, uint32(2), page2.Items[0].Index) + require.Equal(t, inserted.ID, page2.Items[1].ID) + require.Equal(t, uint32(3), page2.Items[1].Index) + require.Nil(t, page2.Next) +} + +// TestListAddressesCursorEdges verifies stale and zero-value cursors produce +// deterministic page results. +func TestListAddressesCursorEdges(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-address-cursor-edges") + scope := db.KeyScopeBIP0084 + accountName := "cursor-account" + createDerivedAccount(t, store, walletID, scope, accountName) + createDerivedAddresses(t, store, walletID, scope, accountName, false, 3) + + stalePage, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}. + WithLimit(2). + WithAfter(math.MaxUint32), + }) + require.NoError(t, err) + require.Empty(t, stalePage.Items) + require.Nil(t, stalePage.Next) + + zeroPage, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: accountName, + Page: page.Request[uint32]{}. + WithLimit(2). + WithAfter(0), + }) + require.NoError(t, err) + require.Len(t, zeroPage.Items, 2) + require.Equal(t, uint32(0), zeroPage.Items[0].Index) + require.Equal(t, uint32(1), zeroPage.Items[1].Index) + require.NotNil(t, zeroPage.Next) +} + +// TestIterAddresses verifies that IterAddresses yields the same addresses in +// the same order as manual cursor-based pagination. +func TestIterAddresses(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-iter-addresses") + scope := db.KeyScopeBIP0084 + pageSize := uint(2) + + createDerivedAccount(t, store, walletID, scope, "iter-account") + expected := createDerivedAddresses( + t, store, walletID, scope, "iter-account", false, 5, + ) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "iter-account", + Page: page.Request[uint32]{}.WithLimit(uint32(pageSize)), + } + + iterAddrs := make([]db.AddressInfo, 0, len(expected)) + for addr, err := range store.IterAddresses(t.Context(), query) { + require.NoError(t, err) + iterAddrs = append(iterAddrs, addr) + } + + paged := flattenAddressPages(collectAddressPages(t, store, query)) + require.Equal(t, expected, paged) + require.Equal(t, expected, iterAddrs) + require.Equal(t, paged, iterAddrs) +} + +// TestIterAddressesPaginated verifies that IterAddresses produces the same +// results as manual pagination and correctly signals end-of-list. +func TestIterAddressesPaginated(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-iter-addresses-early") + scope := db.KeyScopeBIP0084 + + createDerivedAccount(t, store, walletID, scope, "iter-account") + createDerivedAddresses( + t, store, walletID, scope, "iter-account", false, 4, + ) + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "iter-account", + Page: page.Request[uint32]{}. + WithLimit(2), + } + + pages := collectAddressPages(t, store, query) + require.Len(t, pages, 2) + require.NotNil(t, pages[0].Next) + require.Nil(t, pages[1].Next) + + expected := flattenAddressPages(pages) + + iterAddrs := make([]db.AddressInfo, 0, len(expected)) + for addr, err := range store.IterAddresses(t.Context(), query) { + require.NoError(t, err) + iterAddrs = append(iterAddrs, addr) + } + + require.Equal(t, expected, iterAddrs) +} + +// TestIterAddressesEmpty verifies that IterAddresses correctly handles +// empty accounts without error and yields no addresses. +func TestIterAddressesEmpty(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-iter-addresses-empty") + scope := db.KeyScopeBIP0084 + + createDerivedAccount(t, store, walletID, scope, "empty-account") + + query := db.ListAddressesQuery{ + WalletID: walletID, + Scope: scope, + AccountName: "empty-account", + Page: page.Request[uint32]{}.WithLimit(10), + } + + for addr, err := range store.IterAddresses(t.Context(), query) { + require.NoError(t, err) + require.Failf(t, "unexpected address", "address=%v", addr) + } +} + // TestNewDerivedAddressErrors verifies that NewDerivedAddress returns // appropriate errors for invalid parameters, including non-existent // accounts, unknown key scopes, and empty account names. diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.down.sql b/wallet/internal/db/migrations/postgres/000006_addresses.down.sql index 92ce9f0dd2..ea5ce7ef37 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.down.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.down.sql @@ -1,4 +1,5 @@ -- Rollback note: Idempotent by design (using "IF EXISTS"). -- Must succeed even if tables are already dropped or database is in unexpected state. +DROP INDEX IF EXISTS idx_addresses_account_id; DROP TABLE IF EXISTS address_secrets; DROP TABLE IF EXISTS addresses; diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql index 2c13ad1a4b..2e9c79ddcb 100644 --- a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/postgres/000006_addresses.up.sql @@ -76,6 +76,10 @@ ON addresses (account_id, script_pub_key); -- Used by GetAddressByScriptPubKey. CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); +-- Index on (account_id, id) for efficient pagination of addresses by account. +-- Used by ListAddressesByAccount for cursor-based pagination. +CREATE INDEX idx_addresses_account_id ON addresses (account_id, id); + -- Address Secrets table stores sensitive encrypted material needed to spend -- from an address. This table has a one-to-one relationship with addresses. -- Watch-only addresses may have no row in this table. diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql index 92ce9f0dd2..ea5ce7ef37 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql @@ -1,4 +1,5 @@ -- Rollback note: Idempotent by design (using "IF EXISTS"). -- Must succeed even if tables are already dropped or database is in unexpected state. +DROP INDEX IF EXISTS idx_addresses_account_id; DROP TABLE IF EXISTS address_secrets; DROP TABLE IF EXISTS addresses; diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql index 39e5f96d69..72a58dc139 100644 --- a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql +++ b/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql @@ -74,6 +74,10 @@ ON addresses (account_id, script_pub_key); -- Used by GetAddressByScriptPubKey. CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); +-- Index on (account_id, id) for efficient pagination of addresses by account. +-- Used by ListAddressesByAccount for cursor-based pagination. +CREATE INDEX idx_addresses_account_id ON addresses (account_id, id); + -- Address Secrets table stores sensitive encrypted material needed to spend -- from an address. This table has a one-to-one relationship with addresses. -- Watch-only addresses may have no row in this table. diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/db/queries/postgres/addresses.sql index c566ac3159..a76546339a 100644 --- a/wallet/internal/db/queries/postgres/addresses.sql +++ b/wallet/internal/db/queries/postgres/addresses.sql @@ -71,9 +71,10 @@ INSERT INTO addresses ( RETURNING id, created_at; -- name: ListAddressesByAccount :many --- Lists all addresses for a given account identified by wallet_id, key scope --- (purpose/coin_type), and account name. Returns all address columns for --- filtering and processing by the application. +-- Lists addresses for an account identified by wallet_id, key scope +-- (purpose/coin_type), and account name, ordered by address ID. +-- When cursor_id is provided, only rows strictly after that address ID are +-- returned. Returns up to page_limit rows. SELECT a.id, a.account_id, @@ -91,6 +92,17 @@ INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE - ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 - AND acc.account_name = $4 -ORDER BY a.id; + ks.wallet_id = sqlc.arg('wallet_id') + AND ks.purpose = sqlc.arg('purpose') + AND ks.coin_type = sqlc.arg('coin_type') + AND acc.account_name = sqlc.arg('account_name') + -- sqlc.arg()/sqlc.narg() calls are bind parameters, not column + -- references; the RF02 suppression below silences a false-positive + -- from sqlfluff, which cannot distinguish sqlc pseudo-functions + -- from column names in a multi-table JOIN context. + AND ( + sqlc.narg('cursor_id')::BIGINT IS NULL -- noqa: RF02 + OR a.id > sqlc.narg('cursor_id')::BIGINT -- noqa: RF02 + ) +ORDER BY a.id +LIMIT sqlc.arg('page_limit')::BIGINT; diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/db/queries/sqlite/addresses.sql index d195991d7e..dc9db4a85d 100644 --- a/wallet/internal/db/queries/sqlite/addresses.sql +++ b/wallet/internal/db/queries/sqlite/addresses.sql @@ -71,9 +71,10 @@ INSERT INTO addresses ( RETURNING id, created_at; -- name: ListAddressesByAccount :many --- Lists all addresses for a given account identified by wallet_id, key scope --- (purpose/coin_type), and account name. Returns all address columns for --- filtering and processing by the application. +-- Lists addresses for an account identified by wallet_id, key scope +-- (purpose/coin_type), and account name, ordered by address ID. +-- When cursor_id is provided, only rows strictly after that address ID are +-- returned. Returns up to page_limit rows. SELECT a.id, a.account_id, @@ -91,6 +92,17 @@ INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE - ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND acc.account_name = ? -ORDER BY a.id; + ks.wallet_id = sqlc.arg('wallet_id') + AND ks.purpose = sqlc.arg('purpose') + AND ks.coin_type = sqlc.arg('coin_type') + AND acc.account_name = sqlc.arg('account_name') + -- sqlc.arg()/sqlc.narg() calls are bind parameters, not column + -- references; the RF02 suppression below silences a false-positive + -- from sqlfluff, which cannot distinguish sqlc pseudo-functions + -- from column names in a multi-table JOIN context. + AND ( + sqlc.narg('cursor_id') IS NULL -- noqa: RF02 + OR a.id > sqlc.narg('cursor_id') -- noqa: RF02 + ) +ORDER BY a.id +LIMIT sqlc.arg('page_limit'); diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/db/sqlc/postgres/addresses.sql.go index 3d5b0c0777..3ffd8b851a 100644 --- a/wallet/internal/db/sqlc/postgres/addresses.sql.go +++ b/wallet/internal/db/sqlc/postgres/addresses.sql.go @@ -220,9 +220,20 @@ INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE - ks.wallet_id = $1 AND ks.purpose = $2 AND ks.coin_type = $3 + ks.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 AND acc.account_name = $4 + -- sqlc.arg()/sqlc.narg() calls are bind parameters, not column + -- references; the RF02 suppression below silences a false-positive + -- from sqlfluff, which cannot distinguish sqlc pseudo-functions + -- from column names in a multi-table JOIN context. + AND ( + $5::BIGINT IS NULL -- noqa: RF02 + OR a.id > $5::BIGINT -- noqa: RF02 + ) ORDER BY a.id +LIMIT $6::BIGINT ` type ListAddressesByAccountParams struct { @@ -230,6 +241,8 @@ type ListAddressesByAccountParams struct { Purpose int64 CoinType int64 AccountName string + CursorID sql.NullInt64 + PageLimit int64 } type ListAddressesByAccountRow struct { @@ -246,15 +259,18 @@ type ListAddressesByAccountRow struct { HasScript bool } -// Lists all addresses for a given account identified by wallet_id, key scope -// (purpose/coin_type), and account name. Returns all address columns for -// filtering and processing by the application. +// Lists addresses for an account identified by wallet_id, key scope +// (purpose/coin_type), and account name, ordered by address ID. +// When cursor_id is provided, only rows strictly after that address ID are +// returned. Returns up to page_limit rows. func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) { rows, err := q.query(ctx, q.listAddressesByAccountStmt, ListAddressesByAccount, arg.WalletID, arg.Purpose, arg.CoinType, arg.AccountName, + arg.CursorID, + arg.PageLimit, ) if err != nil { return nil, err diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index 94d35afc83..cae8eb777f 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -328,9 +328,10 @@ type Querier interface { ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) - // Lists all addresses for a given account identified by wallet_id, key scope - // (purpose/coin_type), and account name. Returns all address columns for - // filtering and processing by the application. + // Lists addresses for an account identified by wallet_id, key scope + // (purpose/coin_type), and account name, ordered by address ID. + // When cursor_id is provided, only rows strictly after that address ID are + // returned. Returns up to page_limit rows. ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/db/sqlc/sqlite/addresses.sql.go index 393fdafc4d..fbf4baf2e2 100644 --- a/wallet/internal/db/sqlc/sqlite/addresses.sql.go +++ b/wallet/internal/db/sqlc/sqlite/addresses.sql.go @@ -220,9 +220,20 @@ INNER JOIN accounts AS acc ON a.account_id = acc.id INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id WHERE - ks.wallet_id = ? AND ks.purpose = ? AND ks.coin_type = ? - AND acc.account_name = ? + ks.wallet_id = ?1 + AND ks.purpose = ?2 + AND ks.coin_type = ?3 + AND acc.account_name = ?4 + -- sqlc.arg()/sqlc.narg() calls are bind parameters, not column + -- references; the RF02 suppression below silences a false-positive + -- from sqlfluff, which cannot distinguish sqlc pseudo-functions + -- from column names in a multi-table JOIN context. + AND ( + ?5 IS NULL -- noqa: RF02 + OR a.id > ?5 -- noqa: RF02 + ) ORDER BY a.id +LIMIT ?6 ` type ListAddressesByAccountParams struct { @@ -230,6 +241,8 @@ type ListAddressesByAccountParams struct { Purpose int64 CoinType int64 AccountName string + CursorID interface{} + PageLimit int64 } type ListAddressesByAccountRow struct { @@ -246,15 +259,18 @@ type ListAddressesByAccountRow struct { HasScript bool } -// Lists all addresses for a given account identified by wallet_id, key scope -// (purpose/coin_type), and account name. Returns all address columns for -// filtering and processing by the application. +// Lists addresses for an account identified by wallet_id, key scope +// (purpose/coin_type), and account name, ordered by address ID. +// When cursor_id is provided, only rows strictly after that address ID are +// returned. Returns up to page_limit rows. func (q *Queries) ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) { rows, err := q.query(ctx, q.listAddressesByAccountStmt, ListAddressesByAccount, arg.WalletID, arg.Purpose, arg.CoinType, arg.AccountName, + arg.CursorID, + arg.PageLimit, ) if err != nil { return nil, err diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index a7345d54e8..7718ff08eb 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -324,9 +324,10 @@ type Querier interface { ListActiveUtxoLeases(ctx context.Context, arg ListActiveUtxoLeasesParams) ([]ListActiveUtxoLeasesRow, error) // Returns all address types ordered by ID. ListAddressTypes(ctx context.Context) ([]AddressType, error) - // Lists all addresses for a given account identified by wallet_id, key scope - // (purpose/coin_type), and account name. Returns all address columns for - // filtering and processing by the application. + // Lists addresses for an account identified by wallet_id, key scope + // (purpose/coin_type), and account name, ordered by address ID. + // When cursor_id is provided, only rows strictly after that address ID are + // returned. Returns up to page_limit rows. ListAddressesByAccount(ctx context.Context, arg ListAddressesByAccountParams) ([]ListAddressesByAccountRow, error) // Lists all key scopes for a wallet, ordered by ID. ListKeyScopesByWallet(ctx context.Context, walletID int64) ([]KeyScope, error) From 3902e8c096d51b9f405762a2ddfdc8f8388d7b00 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Wed, 11 Mar 2026 20:31:13 -0300 Subject: [PATCH 501/691] wallet: add pagination to wallets --- wallet/internal/db/data_types.go | 6 + wallet/internal/db/interface.go | 13 +- wallet/internal/db/itest/wallet_store_test.go | 453 +++++++++++++++++- .../internal/db/queries/postgres/wallets.sql | 10 +- wallet/internal/db/queries/sqlite/wallets.sql | 10 +- wallet/internal/db/sqlc/postgres/querier.go | 5 +- .../internal/db/sqlc/postgres/wallets.sql.go | 17 +- wallet/internal/db/sqlc/sqlite/querier.go | 5 +- wallet/internal/db/sqlc/sqlite/wallets.sql.go | 17 +- wallet/internal/db/wallet_pg.go | 95 +++- wallet/internal/db/wallet_sqlite.go | 96 +++- wallet/internal/db/wallets_common.go | 9 + 12 files changed, 665 insertions(+), 71 deletions(-) create mode 100644 wallet/internal/db/wallets_common.go diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index f37d1dad1b..8d04730a62 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -240,6 +240,12 @@ type Block struct { Timestamp time.Time } +// ListWalletsQuery contains the parameters for listing wallets. +type ListWalletsQuery struct { + // Page holds the pagination parameters for this query. + Page page.Request[uint32] +} + // CreateWalletParams contains the parameters required to create a new wallet. type CreateWalletParams struct { // Name is the name of the new wallet. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 2e838f9369..524ed32be9 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -142,10 +142,15 @@ type WalletStore interface { // error if the wallet is not found. GetWallet(ctx context.Context, name string) (*WalletInfo, error) - // ListWallets returns a slice of WalletInfo for all wallets stored in - // the database. It returns an empty slice if no wallets are found, or - // an error if the retrieval fails. - ListWallets(ctx context.Context) ([]WalletInfo, error) + // ListWallets returns one page of wallets for the given query, including a + // next-cursor for the following page. + ListWallets(ctx context.Context, query ListWalletsQuery) ( + page.Result[WalletInfo, uint32], error) + + // IterWallets returns an iterator that fetches pages transparently and + // yields wallets one by one until exhaustion or error. + IterWallets(ctx context.Context, + query ListWalletsQuery) iter.Seq2[WalletInfo, error] // UpdateWallet updates various properties of a wallet, such as its // birthday, birthday block, or sync state. The specific fields to diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index 8f7f5c3624..ab7d0eee95 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -3,10 +3,12 @@ package itest import ( + "math" "testing" "time" "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" "github.com/stretchr/testify/require" ) @@ -145,16 +147,22 @@ func TestGetWallet_NotFound(t *testing.T) { require.ErrorIs(t, err, db.ErrWalletNotFound) } -// TestListWallets verifies that ListWallets returns all wallets. +// TestListWallets verifies that ListWallets correctly returns wallets and +// handles empty results without error. func TestListWallets(t *testing.T) { t.Parallel() store := NewTestStore(t) // Initially empty. - wallets, err := store.ListWallets(t.Context()) + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(10), + } + + pageResult, err := store.ListWallets(t.Context(), query) require.NoError(t, err) - require.Empty(t, wallets) + require.Empty(t, pageResult.Items) + require.Nil(t, pageResult.Next) // Create three wallets. names := []string{"wallet-1", "wallet-2", "wallet-3"} @@ -164,18 +172,449 @@ func TestListWallets(t *testing.T) { require.NoError(t, err) } - wallets, err = store.ListWallets(t.Context()) + pageResult, err = store.ListWallets(t.Context(), query) require.NoError(t, err) - require.Len(t, wallets, 3) + require.Len(t, pageResult.Items, 3) // Verify all names are present. - walletsName := make([]string, len(wallets)) - for i, w := range wallets { + walletsName := make([]string, len(pageResult.Items)) + for i, w := range pageResult.Items { walletsName[i] = w.Name } require.ElementsMatch(t, names, walletsName) } +// TestListWalletsPagination verifies that ListWallets paginates correctly and +// sets Next without requiring an extra round-trip. +func TestListWalletsPagination(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + for _, name := range names { + params := CreateWalletParamsFixture(name) + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + + page1, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.NotNil(t, page1.Next) + require.Equal(t, page1.Items[1].ID, *page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Nil(t, page2.Next) + + query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + page3, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Empty(t, page3.Items) + require.Nil(t, page3.Next) + + paged := append([]db.WalletInfo{}, page1.Items...) + paged = append(paged, page2.Items...) + require.Len(t, paged, len(names)) + + pagedNames := make([]string, len(paged)) + for i, wallet := range paged { + pagedNames[i] = wallet.Name + } + require.Equal(t, names, pagedNames) +} + +// TestListWalletsExactBoundary verifies that pagination correctly handles the +// exact boundary case where total results equal page-size multiples. +func TestListWalletsExactBoundary(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + for _, name := range names { + _, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture(name), + ) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + + page1, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.Equal(t, names[0], page1.Items[0].Name) + require.Equal(t, names[1], page1.Items[1].Name) + require.NotNil(t, page1.Next) + require.Equal(t, page1.Items[1].ID, *page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Equal(t, names[2], page2.Items[0].Name) + require.Equal(t, names[3], page2.Items[1].Name) + require.Nil(t, page2.Next) + require.Greater(t, page2.Items[0].ID, *page1.Next) + + query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + page3, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Empty(t, page3.Items) + require.Nil(t, page3.Next) +} + +// TestIterWallets verifies that IterWallets exhaustively retrieves all wallets +// and yields the same results in the same order as manual cursor-based +// pagination. +func TestIterWallets(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + for _, name := range names { + params := CreateWalletParamsFixture(name) + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + expected := flattenWalletPages(collectWalletPages(t, store, query)) + + iterWallets := make([]db.WalletInfo, 0, len(expected)) + for wallet, err := range store.IterWallets(t.Context(), query) { + require.NoError(t, err) + iterWallets = append(iterWallets, wallet) + } + + require.Equal(t, expected, iterWallets) +} + +// TestIterWalletsPaginated verifies that IterWallets produces the same +// results as manual pagination and correctly signals end-of-list. +func TestIterWalletsPaginated(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + for _, name := range names { + params := CreateWalletParamsFixture(name) + _, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + + pages := collectWalletPages(t, store, query) + require.Len(t, pages, 2) + require.NotNil(t, pages[0].Next) + require.Nil(t, pages[1].Next) + + expected := flattenWalletPages(pages) + + iterWallets := make([]db.WalletInfo, 0, len(expected)) + for wallet, err := range store.IterWallets(t.Context(), query) { + require.NoError(t, err) + iterWallets = append(iterWallets, wallet) + } + + require.Equal(t, expected, iterWallets) +} + +// TestListWalletsPagedFromCursor verifies that ListWallets can resume +// pagination from a specific cursor position, returning only wallets after +// that cursor. +func TestListWalletsPagedFromCursor(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + names := []string{"wallet-1", "wallet-2", "wallet-3", "wallet-4"} + created := make([]*db.WalletInfo, 0, len(names)) + for _, name := range names { + params := CreateWalletParamsFixture(name) + wallet, err := store.CreateWallet(t.Context(), params) + require.NoError(t, err) + created = append(created, wallet) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2).WithAfter(created[1].ID), + } + + pageResult, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, pageResult.Items, 2) + require.Equal(t, names[2], pageResult.Items[0].Name) + require.Equal(t, names[3], pageResult.Items[1].Name) + require.Nil(t, pageResult.Next) + + query.Page = query.Page.WithAfter(created[3].ID) + pageResult, err = store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Empty(t, pageResult.Items) + require.Nil(t, pageResult.Next) +} + +// TestListWalletsPagedWithSyncMetadata verifies that paginated wallet +// listings include sync metadata such as synced-to block and birthday block. +func TestListWalletsPagedWithSyncMetadata(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + + birthday1 := time.Now().UTC().Add(-48 * time.Hour) + birthday2 := time.Now().UTC().Add(-24 * time.Hour) + + params1 := CreateWalletParamsFixture("wallet-sync-1") + params1.Birthday = birthday1 + wallet1, err := store.CreateWallet(t.Context(), params1) + require.NoError(t, err) + + params2 := CreateWalletParamsFixture("wallet-sync-2") + params2.Birthday = birthday2 + wallet2, err := store.CreateWallet(t.Context(), params2) + require.NoError(t, err) + + block1 := CreateBlockFixture(t, queries, 100) + block2 := CreateBlockFixture(t, queries, 101) + + err = store.UpdateWallet(t.Context(), db.UpdateWalletParams{ + WalletID: wallet1.ID, + SyncedTo: &block2, + BirthdayBlock: &block1, + }) + require.NoError(t, err) + + err = store.UpdateWallet(t.Context(), db.UpdateWalletParams{ + WalletID: wallet2.ID, + SyncedTo: &block2, + BirthdayBlock: &block1, + }) + require.NoError(t, err) + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(1), + } + + page1, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 1) + require.NotNil(t, page1.Items[0].SyncedTo) + require.NotNil(t, page1.Items[0].BirthdayBlock) + require.False(t, page1.Items[0].Birthday.IsZero()) + require.NotNil(t, page1.Next) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 1) + require.NotNil(t, page2.Items[0].SyncedTo) + require.NotNil(t, page2.Items[0].BirthdayBlock) + require.False(t, page2.Items[0].Birthday.IsZero()) + require.Nil(t, page2.Next) +} + +// TestListWalletsDeterministicPagination verifies stable page ordering and +// next-cursor behavior for multi-page wallet listings. +func TestListWalletsDeterministicPagination(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + names := []string{ + "wallet-page-1", + "wallet-page-2", + "wallet-page-3", + "wallet-page-4", + "wallet-page-5", + } + + for _, name := range names { + _, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture(name), + ) + require.NoError(t, err) + } + + pages := collectWalletPages(t, store, db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + }) + require.Len(t, pages, 3) + require.Len(t, pages[0].Items, 2) + require.Len(t, pages[1].Items, 2) + require.Len(t, pages[2].Items, 1) + require.NotNil(t, pages[0].Next) + require.NotNil(t, pages[1].Next) + require.Nil(t, pages[2].Next) + + wallets := flattenWalletPages(pages) + require.Len(t, wallets, len(names)) + + collectedNames := make([]string, len(wallets)) + for i, wallet := range wallets { + if i > 0 { + require.Less(t, wallets[i-1].ID, wallet.ID) + } + + collectedNames[i] = wallet.Name + } + + require.Equal(t, names, collectedNames) + require.Equal(t, wallets[1].ID, *pages[0].Next) + require.Equal(t, wallets[3].ID, *pages[1].Next) + + seenIDs := make(map[uint32]struct{}, len(wallets)) + for i := range pages { + for j, wallet := range pages[i].Items { + _, duplicate := seenIDs[wallet.ID] + require.False(t, duplicate) + seenIDs[wallet.ID] = struct{}{} + + // Skip the first item on the first page; there's no prior cursor + // to compare against. + if i == 0 && j == 0 { + continue + } + + // First item on a later page: verify it sorts strictly after the + // previous page's cursor to ensure no gaps or duplicates at page + // boundaries. + if j == 0 { + require.Greater(t, wallet.ID, *pages[i-1].Next) + continue + } + + // Items within the same page: verify strict ordering to ensure + // the page contents are sorted. + require.Greater(t, wallet.ID, pages[i].Items[j-1].ID) + } + } +} + +// TestListWalletsInsertAfterCursor verifies inserts after page N are returned +// on page N+1 when pagination uses increasing ID cursors. +func TestListWalletsInsertAfterCursor(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + for _, name := range []string{"wallet-a", "wallet-b", "wallet-c"} { + _, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture(name), + ) + require.NoError(t, err) + } + + query := db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2), + } + page1, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page1.Items, 2) + require.NotNil(t, page1.Next) + + // Pagination is by increasing wallet ID (id > cursor). + // A wallet created after page 1 should therefore appear on page 2. + inserted, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture("wallet-d"), + ) + require.NoError(t, err) + + query.Page = query.Page.WithAfter(*page1.Next) + page2, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + require.Len(t, page2.Items, 2) + require.Equal(t, "wallet-c", page2.Items[0].Name) + require.Equal(t, inserted.ID, page2.Items[1].ID) + require.Equal(t, "wallet-d", page2.Items[1].Name) + require.Nil(t, page2.Next) +} + +// TestListWalletsCursorEdges verifies stale and zero-value cursors produce +// deterministic page results. +func TestListWalletsCursorEdges(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + names := []string{"wallet-1", "wallet-2", "wallet-3"} + for _, name := range names { + _, err := store.CreateWallet( + t.Context(), CreateWalletParamsFixture(name), + ) + require.NoError(t, err) + } + + stalePage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2).WithAfter(math.MaxUint32), + }) + require.NoError(t, err) + require.Empty(t, stalePage.Items) + require.Nil(t, stalePage.Next) + + zeroPage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ + Page: page.Request[uint32]{}.WithLimit(2).WithAfter(0), + }) + require.NoError(t, err) + require.Len(t, zeroPage.Items, 2) + require.Equal(t, names[0], zeroPage.Items[0].Name) + require.Equal(t, names[1], zeroPage.Items[1].Name) + require.NotNil(t, zeroPage.Next) +} + +// collectWalletPages collects paginated wallet results by iterating +// through all pages from ListWallets, using cursor pagination until +// Next is nil. +func collectWalletPages(t *testing.T, store db.WalletStore, + query db.ListWalletsQuery) []page.Result[db.WalletInfo, uint32] { + t.Helper() + + pages := make([]page.Result[db.WalletInfo, uint32], 0) + for { + pageResult, err := store.ListWallets(t.Context(), query) + require.NoError(t, err) + pages = append(pages, pageResult) + + if pageResult.Next == nil { + return pages + } + + query.Page = query.Page.WithAfter(*pageResult.Next) + } +} + +// flattenWalletPages flattens paginated wallet results into a single +// slice containing all wallets from all pages. +func flattenWalletPages( + pages []page.Result[db.WalletInfo, uint32]) []db.WalletInfo { + + count := 0 + for i := range pages { + count += len(pages[i].Items) + } + + wallets := make([]db.WalletInfo, 0, count) + for i := range pages { + wallets = append(wallets, pages[i].Items...) + } + + return wallets +} + // TestUpdateWallet_SyncedTo checks that updating the wallet's synced-to block // works correctly. func TestUpdateWallet_SyncedTo(t *testing.T) { diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/db/queries/postgres/wallets.sql index 58b6126a6a..e971fbbd24 100644 --- a/wallet/internal/db/queries/postgres/wallets.sql +++ b/wallet/internal/db/queries/postgres/wallets.sql @@ -62,6 +62,9 @@ LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.wallet_name = $1; -- name: ListWallets :many +-- Lists wallets using cursor-based pagination. If cursor_id is NULL, starts +-- from the beginning; otherwise returns wallets with id > cursor_id. Returns up +-- to page_limit rows. SELECT w.id, w.wallet_name, @@ -80,7 +83,12 @@ FROM wallets AS w LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height -ORDER BY w.id; +WHERE ( + sqlc.narg('cursor_id')::BIGINT IS NULL + OR w.id > sqlc.narg('cursor_id')::BIGINT +) +ORDER BY w.id +LIMIT sqlc.arg('page_limit')::BIGINT; -- name: GetWalletByID :one SELECT diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/db/queries/sqlite/wallets.sql index 3e180b39dd..a8c802e90e 100644 --- a/wallet/internal/db/queries/sqlite/wallets.sql +++ b/wallet/internal/db/queries/sqlite/wallets.sql @@ -62,6 +62,9 @@ LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height WHERE w.wallet_name = ?; -- name: ListWallets :many +-- Lists wallets using cursor-based pagination. If cursor_id is NULL, starts +-- from the beginning; otherwise returns wallets with id > cursor_id. Returns up +-- to page_limit rows. SELECT w.id, w.wallet_name, @@ -80,7 +83,12 @@ FROM wallets AS w LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height -ORDER BY w.id; +WHERE ( + sqlc.narg('cursor_id') IS NULL + OR w.id > sqlc.narg('cursor_id') +) +ORDER BY w.id +LIMIT sqlc.arg('page_limit'); -- name: GetWalletByID :one SELECT diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/db/sqlc/postgres/querier.go index cae8eb777f..2fc726dbc0 100644 --- a/wallet/internal/db/sqlc/postgres/querier.go +++ b/wallet/internal/db/sqlc/postgres/querier.go @@ -457,7 +457,10 @@ type Querier interface { // - Treats min/max confirmations as optional filters so callers can // distinguish "not set" from an explicit zero-conf request. ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) - ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Lists wallets using cursor-based pagination. If cursor_id is NULL, starts + // from the beginning; otherwise returns wallets with id > cursor_id. Returns up + // to page_limit rows. + ListWallets(ctx context.Context, arg ListWalletsParams) ([]ListWalletsRow, error) // Acquires a transaction-level advisory lock to serialize account creation within a scope. // The lock is automatically released upon transaction commit or rollback. // This MUST be called immediately before 'CreateDerivedAccount' within the same transaction. diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/db/sqlc/postgres/wallets.sql.go index 0aa4daccb2..91055c9f90 100644 --- a/wallet/internal/db/sqlc/postgres/wallets.sql.go +++ b/wallet/internal/db/sqlc/postgres/wallets.sql.go @@ -271,9 +271,19 @@ FROM wallets AS w LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE ( + $1::BIGINT IS NULL + OR w.id > $1::BIGINT +) ORDER BY w.id +LIMIT $2::BIGINT ` +type ListWalletsParams struct { + CursorID sql.NullInt64 + PageLimit int64 +} + type ListWalletsRow struct { ID int64 WalletName string @@ -290,8 +300,11 @@ type ListWalletsRow struct { BirthdayBlockTimestamp sql.NullInt64 } -func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { - rows, err := q.query(ctx, q.listWalletsStmt, ListWallets) +// Lists wallets using cursor-based pagination. If cursor_id is NULL, starts +// from the beginning; otherwise returns wallets with id > cursor_id. Returns up +// to page_limit rows. +func (q *Queries) ListWallets(ctx context.Context, arg ListWalletsParams) ([]ListWalletsRow, error) { + rows, err := q.query(ctx, q.listWalletsStmt, ListWallets, arg.CursorID, arg.PageLimit) if err != nil { return nil, err } diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/db/sqlc/sqlite/querier.go index 7718ff08eb..bebc1585c0 100644 --- a/wallet/internal/db/sqlc/sqlite/querier.go +++ b/wallet/internal/db/sqlc/sqlite/querier.go @@ -453,7 +453,10 @@ type Querier interface { // - Treats min/max confirmations as optional filters so callers can // distinguish "not set" from an explicit zero-conf request. ListUtxos(ctx context.Context, arg ListUtxosParams) ([]ListUtxosRow, error) - ListWallets(ctx context.Context) ([]ListWalletsRow, error) + // Lists wallets using cursor-based pagination. If cursor_id is NULL, starts + // from the beginning; otherwise returns wallets with id > cursor_id. Returns up + // to page_limit rows. + ListWallets(ctx context.Context, arg ListWalletsParams) ([]ListWalletsRow, error) // Marks a wallet-owned UTXO as spent by a transaction. // // How: diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/db/sqlc/sqlite/wallets.sql.go index 56685d4f7b..24077a7cae 100644 --- a/wallet/internal/db/sqlc/sqlite/wallets.sql.go +++ b/wallet/internal/db/sqlc/sqlite/wallets.sql.go @@ -271,9 +271,19 @@ FROM wallets AS w LEFT JOIN wallet_sync_states AS s ON w.id = s.wallet_id LEFT JOIN blocks AS b_synced ON s.synced_height = b_synced.block_height LEFT JOIN blocks AS b_birthday ON s.birthday_height = b_birthday.block_height +WHERE ( + ?1 IS NULL + OR w.id > ?1 +) ORDER BY w.id +LIMIT ?2 ` +type ListWalletsParams struct { + CursorID interface{} + PageLimit int64 +} + type ListWalletsRow struct { ID int64 WalletName string @@ -290,8 +300,11 @@ type ListWalletsRow struct { BirthdayBlockTimestamp sql.NullInt64 } -func (q *Queries) ListWallets(ctx context.Context) ([]ListWalletsRow, error) { - rows, err := q.query(ctx, q.listWalletsStmt, ListWallets) +// Lists wallets using cursor-based pagination. If cursor_id is NULL, starts +// from the beginning; otherwise returns wallets with id > cursor_id. Returns up +// to page_limit rows. +func (q *Queries) ListWallets(ctx context.Context, arg ListWalletsParams) ([]ListWalletsRow, error) { + rows, err := q.query(ctx, q.listWalletsStmt, ListWallets, arg.CursorID, arg.PageLimit) if err != nil { return nil, err } diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index 38f1bb7ce2..d0f2277b82 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -5,7 +5,9 @@ import ( "database/sql" "errors" "fmt" + "iter" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -140,41 +142,44 @@ func (s *PostgresStore) GetWallet(ctx context.Context, }) } -// ListWallets returns a slice of WalletInfo for all wallets stored in -// the database. It returns an empty slice if no wallets are found, or -// an error if the retrieval fails. -func (s *PostgresStore) ListWallets(ctx context.Context) ([]WalletInfo, - error) { +// ListWallets returns a page of wallets matching the given query. +func (s *PostgresStore) ListWallets(ctx context.Context, + query ListWalletsQuery) (page.Result[WalletInfo, uint32], error) { - rows, err := s.queries.ListWallets(ctx) + rows, err := s.queries.ListWallets(ctx, pgListWalletsParams(query.Page)) if err != nil { - return nil, fmt.Errorf("list wallets: %w", err) + return page.Result[WalletInfo, uint32]{}, + fmt.Errorf("list wallets page: %w", err) } - wallets := make([]WalletInfo, len(rows)) + items := make([]WalletInfo, len(rows)) for i, row := range rows { - info, err := buildPgWalletInfo(pgWalletRowParams{ - id: row.ID, - name: row.WalletName, - isImported: row.IsImported, - managerVersion: row.ManagerVersion, - isWatchOnly: row.IsWatchOnly, - syncedHeight: row.SyncedHeight, - syncedBlockHash: row.SyncedBlockHash, - syncedBlockTimestamp: row.SyncedBlockTimestamp, - birthdayHeight: row.BirthdayHeight, - birthdayTimestamp: row.BirthdayTimestamp, - birthdayBlockHash: row.BirthdayBlockHash, - birthdayBlockTimestamp: row.BirthdayBlockTimestamp, - }) - if err != nil { - return nil, err + item, errMap := pgListWalletRowToInfo(row) + if errMap != nil { + return page.Result[WalletInfo, uint32]{}, + fmt.Errorf("list wallets page: map row: %w", errMap) } - wallets[i] = *info + items[i] = *item } - return wallets, nil + result := page.BuildResult( + query.Page, items, + func(item WalletInfo) uint32 { + return item.ID + }, + ) + + return result, nil +} + +// IterWallets returns an iterator over paginated wallet results. +func (s *PostgresStore) IterWallets(ctx context.Context, + query ListWalletsQuery) iter.Seq2[WalletInfo, error] { + + return page.Iter( + ctx, query, s.ListWallets, nextListWalletsQuery, + ) } // UpdateWallet updates various properties of a wallet, such as its @@ -291,6 +296,44 @@ type pgWalletRowParams struct { birthdayBlockTimestamp sql.NullInt64 } +// pgListWalletRowToInfo converts a ListWallets result row to a WalletInfo +// struct for pagination. +func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*WalletInfo, error) { + return buildPgWalletInfo(pgWalletRowParams{ + id: row.ID, + name: row.WalletName, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayTimestamp: row.BirthdayTimestamp, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) +} + +// pgListWalletsParams translates a page request to ListWallets query +// parameters, handling optional cursor setup for pagination. +func pgListWalletsParams( + req page.Request[uint32]) sqlcpg.ListWalletsParams { + + params := sqlcpg.ListWalletsParams{ + PageLimit: int64(req.QueryLimit()), + } + + if cursor, ok := req.After(); ok { + params.CursorID = sql.NullInt64{ + Int64: int64(cursor), + Valid: true, + } + } + + return params +} + // buildPgWalletInfo constructs a WalletInfo from the given wallet row // parameters. func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index dae1a75308..6ce787db31 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -5,7 +5,9 @@ import ( "database/sql" "errors" "fmt" + "iter" + "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -140,41 +142,46 @@ func (s *SqliteStore) GetWallet(ctx context.Context, }) } -// ListWallets returns a slice of WalletInfo for all wallets stored in -// the database. It returns an empty slice if no wallets are found, or -// an error if the retrieval fails. -func (s *SqliteStore) ListWallets(ctx context.Context) ([]WalletInfo, - error) { +// ListWallets returns a page of wallets matching the given query. +func (s *SqliteStore) ListWallets(ctx context.Context, + query ListWalletsQuery) (page.Result[WalletInfo, uint32], error) { - rows, err := s.queries.ListWallets(ctx) + rows, err := s.queries.ListWallets( + ctx, sqliteListWalletsParams(query.Page), + ) if err != nil { - return nil, fmt.Errorf("list wallets: %w", err) + return page.Result[WalletInfo, uint32]{}, + fmt.Errorf("list wallets page: %w", err) } - wallets := make([]WalletInfo, len(rows)) + items := make([]WalletInfo, len(rows)) for i, row := range rows { - info, err := buildSqliteWalletInfo(sqliteWalletRowParams{ - id: row.ID, - name: row.WalletName, - isImported: row.IsImported, - managerVersion: row.ManagerVersion, - isWatchOnly: row.IsWatchOnly, - syncedHeight: row.SyncedHeight, - syncedBlockHash: row.SyncedBlockHash, - syncedBlockTimestamp: row.SyncedBlockTimestamp, - birthdayHeight: row.BirthdayHeight, - birthdayTimestamp: row.BirthdayTimestamp, - birthdayBlockHash: row.BirthdayBlockHash, - birthdayBlockTimestamp: row.BirthdayBlockTimestamp, - }) - if err != nil { - return nil, err + item, errMap := sqliteListWalletRowToInfo(row) + if errMap != nil { + return page.Result[WalletInfo, uint32]{}, + fmt.Errorf("list wallets page: map row: %w", errMap) } - wallets[i] = *info + items[i] = *item } - return wallets, nil + result := page.BuildResult( + query.Page, items, + func(item WalletInfo) uint32 { + return item.ID + }, + ) + + return result, nil +} + +// IterWallets returns an iterator over paginated wallet results. +func (s *SqliteStore) IterWallets(ctx context.Context, + query ListWalletsQuery) iter.Seq2[WalletInfo, error] { + + return page.Iter( + ctx, query, s.ListWallets, nextListWalletsQuery, + ) } // UpdateWallet updates various properties of a wallet, such as its @@ -290,6 +297,43 @@ type sqliteWalletRowParams struct { birthdayBlockTimestamp sql.NullInt64 } +// sqliteListWalletRowToInfo converts a ListWallets result row to a WalletInfo +// struct for pagination. +func sqliteListWalletRowToInfo( + row sqlcsqlite.ListWalletsRow) (*WalletInfo, error) { + + return buildSqliteWalletInfo(sqliteWalletRowParams{ + id: row.ID, + name: row.WalletName, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayTimestamp: row.BirthdayTimestamp, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) +} + +// sqliteListWalletsParams translates a page request to ListWallets query +// parameters, handling optional cursor setup for pagination. +func sqliteListWalletsParams( + req page.Request[uint32]) sqlcsqlite.ListWalletsParams { + + params := sqlcsqlite.ListWalletsParams{ + PageLimit: int64(req.QueryLimit()), + } + + if cursor, ok := req.After(); ok { + params.CursorID = int64(cursor) + } + + return params +} + // buildSqliteWalletInfo constructs a WalletInfo from the given wallet // row parameters. func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { diff --git a/wallet/internal/db/wallets_common.go b/wallet/internal/db/wallets_common.go new file mode 100644 index 0000000000..cb3ee35be1 --- /dev/null +++ b/wallet/internal/db/wallets_common.go @@ -0,0 +1,9 @@ +package db + +// nextListWalletsQuery returns a query with its pagination cursor advanced to +// the provided value. +func nextListWalletsQuery(q ListWalletsQuery, cursor uint32) ListWalletsQuery { + q.Page = q.Page.WithAfter(cursor) + + return q +} From 1bac78f2320bf4e78a6e36d4f70e08884bac0d06 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 8 Apr 2026 20:51:01 +0800 Subject: [PATCH 502/691] wallet: change `rootHashes` from map to be set --- wallet/internal/db/postgres_txstore_rollback.go | 16 +++++++--------- wallet/internal/db/sqlite_txstore_rollback.go | 16 +++++++--------- wallet/internal/db/tx_store_common.go | 11 ++++++++--- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/postgres_txstore_rollback.go index 42564b6bbc..5f5c6de7e4 100644 --- a/wallet/internal/db/postgres_txstore_rollback.go +++ b/wallet/internal/db/postgres_txstore_rollback.go @@ -32,7 +32,7 @@ var _ rollbackToBlockOps = (*pgRollbackToBlockOps)(nil) // listRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. func (o pgRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, - height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) { + height uint32) (map[uint32][]chainhash.Hash, error) { rollbackHeight, err := uint32ToInt32(height) if err != nil { @@ -162,12 +162,12 @@ func (o pgRollbackToBlockOps) markDescendantsFailed( } // groupRollbackCoinbaseRootsPg groups rollback-affected coinbase hashes by -// wallet so descendant invalidation can reuse wallet-scoped unmined queries. +// wallet while preserving the query order inside each wallet bucket. func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( - map[uint32]map[chainhash.Hash]struct{}, error) { + map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( - map[uint32]map[chainhash.Hash]struct{}, len(rows), + map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { walletID, err := int64ToUint32(row.WalletID) @@ -180,11 +180,9 @@ func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( return nil, fmt.Errorf("rollback coinbase hash: %w", err) } - if _, ok := rootHashesByWallet[walletID]; !ok { - rootHashesByWallet[walletID] = make(map[chainhash.Hash]struct{}) - } - - rootHashesByWallet[walletID][*txHash] = struct{}{} + rootHashesByWallet[walletID] = append( + rootHashesByWallet[walletID], *txHash, + ) } return rootHashesByWallet, nil diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite_txstore_rollback.go index 87f3f7396e..bcbc0f0c68 100644 --- a/wallet/internal/db/sqlite_txstore_rollback.go +++ b/wallet/internal/db/sqlite_txstore_rollback.go @@ -32,7 +32,7 @@ var _ rollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) // listRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. func (o sqliteRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, - height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) { + height uint32) (map[uint32][]chainhash.Hash, error) { rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, int64(height)) if err != nil { @@ -138,13 +138,13 @@ func (o sqliteRollbackToBlockOps) markDescendantsFailed( } // groupRollbackCoinbaseRootsSqlite groups rollback-affected coinbase hashes by -// wallet so descendant invalidation can reuse wallet-scoped unmined queries. +// wallet while preserving the query order inside each wallet bucket. func groupRollbackCoinbaseRootsSqlite( rows []sqlcsqlite.ListRollbackCoinbaseRootsRow) ( - map[uint32]map[chainhash.Hash]struct{}, error) { + map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( - map[uint32]map[chainhash.Hash]struct{}, len(rows), + map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { walletID, err := int64ToUint32(row.WalletID) @@ -157,11 +157,9 @@ func groupRollbackCoinbaseRootsSqlite( return nil, fmt.Errorf("rollback coinbase hash: %w", err) } - if _, ok := rootHashesByWallet[walletID]; !ok { - rootHashesByWallet[walletID] = make(map[chainhash.Hash]struct{}) - } - - rootHashesByWallet[walletID][*txHash] = struct{}{} + rootHashesByWallet[walletID] = append( + rootHashesByWallet[walletID], *txHash, + ) } return rootHashesByWallet, nil diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 24c7e7dbe7..9e6d07b41c 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -580,7 +580,7 @@ type rollbackToBlockOps interface { // listRollbackRootHashes returns the coinbase roots disconnected by the // rollback, grouped by wallet for the later descendant walk. listRollbackRootHashes(ctx context.Context, - height uint32) (map[uint32]map[chainhash.Hash]struct{}, error) + height uint32) (map[uint32][]chainhash.Hash, error) // rewindWalletSyncStateHeights clamps wallet sync-state references // below the rollback boundary before block rows are removed. @@ -706,7 +706,7 @@ func collectDescendantTxIDs(rootHashes map[chainhash.Hash]struct{}, // invalidateRollbackDescendants clears spend edges and marks failed every // unmined descendant discovered from the provided wallet-scoped rollback roots. func invalidateRollbackDescendants(ctx context.Context, - rootHashesByWallet map[uint32]map[chainhash.Hash]struct{}, + rootHashesByWallet map[uint32][]chainhash.Hash, ops rollbackToBlockOps) error { for walletID, rootHashes := range rootHashesByWallet { @@ -718,7 +718,12 @@ func invalidateRollbackDescendants(ctx context.Context, "wallet %d: %w", walletID, err) } - descendantIDs := collectDescendantTxIDs(rootHashes, candidates) + rootHashSet := make(map[chainhash.Hash]struct{}, len(rootHashes)) + for _, rootHash := range rootHashes { + rootHashSet[rootHash] = struct{}{} + } + + descendantIDs := collectDescendantTxIDs(rootHashSet, candidates) if len(descendantIDs) == 0 { continue } From 23314dc2049a98e4be3949a224f8bae167fc59a8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 18:37:12 +0800 Subject: [PATCH 503/691] wallet: exclude direct roots from descendant walk Keep direct conflict roots out of the descendant list so callers can preserve the root replacement state while still failing only the dependent branch. --- wallet/internal/db/tx_store_common.go | 35 +++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 9e6d07b41c..94f52c97d0 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -663,18 +663,22 @@ func collectDirectChildTxIDs(parentHash chainhash.Hash, return childIDs } -// collectDescendantTxIDs returns every unmined transaction that depends on any -// of the provided root hashes, including indirect descendants discovered -// through newly invalidated child hashes. -func collectDescendantTxIDs(rootHashes map[chainhash.Hash]struct{}, - candidates []unminedTxRecord) []int64 { +// collectDescendantTxIDs returns every discovered descendant in the original +// candidate order. Any ID also listed in rootIDs is excluded so direct roots +// can keep their own state transition instead of being treated as descendants. +func collectDescendantTxIDs(rootHashes []chainhash.Hash, + rootIDs []int64, candidates []unminedTxRecord) []int64 { invalidHashes := make(map[chainhash.Hash]struct{}, len(rootHashes)) - for hash := range rootHashes { + for _, hash := range rootHashes { invalidHashes[hash] = struct{}{} } invalidIDs := make(map[int64]struct{}, len(candidates)) + + // Walk the candidate set to a fixed point. Each time we discover one + // new descendant we add its hash to invalidHashes, which may cause + // later passes to discover txns that depend on that child. for changed := true; changed; { changed = false @@ -693,14 +697,20 @@ func collectDescendantTxIDs(rootHashes map[chainhash.Hash]struct{}, } } - descendantIDs := make([]int64, 0, len(invalidIDs)) + // Direct roots are handled separately by the caller, so remove them here + // before the ordered descendant slice is materialized. + for _, rootID := range rootIDs { + delete(invalidIDs, rootID) + } + + orderedIDs := make([]int64, 0, len(invalidIDs)) for _, candidate := range candidates { if _, ok := invalidIDs[candidate.id]; ok { - descendantIDs = append(descendantIDs, candidate.id) + orderedIDs = append(orderedIDs, candidate.id) } } - return descendantIDs + return orderedIDs } // invalidateRollbackDescendants clears spend edges and marks failed every @@ -718,12 +728,7 @@ func invalidateRollbackDescendants(ctx context.Context, "wallet %d: %w", walletID, err) } - rootHashSet := make(map[chainhash.Hash]struct{}, len(rootHashes)) - for _, rootHash := range rootHashes { - rootHashSet[rootHash] = struct{}{} - } - - descendantIDs := collectDescendantTxIDs(rootHashSet, candidates) + descendantIDs := collectDescendantTxIDs(rootHashes, nil, candidates) if len(descendantIDs) == 0 { continue } From 7a79b6d8ffca9251006c0d68f14ef6f799a8ad44 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 22:53:31 +0800 Subject: [PATCH 504/691] wallet: add InvalidateUnminedTx API to TxStore --- wallet/internal/db/data_types.go | 13 +++++++++++++ wallet/internal/db/interface.go | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 8d04730a62..1a7ad61a54 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -975,6 +975,19 @@ type DeleteTxParams struct { Txid chainhash.Hash } +// InvalidateUnminedTxParams contains the parameters for invalidating one +// wallet-owned unmined transaction branch. +type InvalidateUnminedTxParams struct { + // WalletID is the ID of the wallet containing the transaction. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // Txid is the hash of the unmined transaction to invalidate. + Txid chainhash.Hash +} + // -------------------- // UTXOStore Types // -------------------- diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 524ed32be9..c4463d2608 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -328,6 +328,17 @@ type TxStore interface { // unconfirmed-deletion path. DeleteTx(ctx context.Context, params DeleteTxParams) error + // InvalidateUnminedTx invalidates one unmined transaction branch as a + // single atomic wallet event. + // + // This method is intended for system-driven cleanup when a wallet-owned + // unmined transaction is no longer valid, for example after publisher-side + // rejection or conflict handling. Implementations must invalidate the root + // transaction and reconcile any dependent descendant state inside one + // database transaction. + InvalidateUnminedTx(ctx context.Context, + params InvalidateUnminedTxParams) error + // RollbackToBlock removes all blocks at and after a given height, // moving any transactions within those blocks back to the unconfirmed // pool. This operation is performed as a single, atomic database From cb76debfa6c151f14e1b3ab5e0690d443a0cfae4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 22:57:45 +0800 Subject: [PATCH 505/691] wallet: add shared unmined invalidation workflow --- .../db/tx_store_invalidateunmined_common.go | 144 ++++++++ .../tx_store_invalidateunmined_common_test.go | 343 ++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 wallet/internal/db/tx_store_invalidateunmined_common.go create mode 100644 wallet/internal/db/tx_store_invalidateunmined_common_test.go diff --git a/wallet/internal/db/tx_store_invalidateunmined_common.go b/wallet/internal/db/tx_store_invalidateunmined_common.go new file mode 100644 index 0000000000..6eeaf3ce53 --- /dev/null +++ b/wallet/internal/db/tx_store_invalidateunmined_common.go @@ -0,0 +1,144 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" +) + +var ( + // ErrInvalidateTx indicates that InvalidateUnminedTx rejected the requested + // tx because it is not a current active unmined row. + ErrInvalidateTx = errors.New("invalidate tx") +) + +// invalidateUnminedTxTarget is the normalized metadata the shared invalidation +// workflow needs for the root transaction. +type invalidateUnminedTxTarget struct { + // id is the backend row ID for the transaction being invalidated. + id int64 + + // txHash is the network transaction hash used for descendant discovery. + txHash chainhash.Hash + + // status is the wallet-relative state that must still be unmined. + status TxStatus + + // hasBlock reports whether the row is already confirmed in a block. + hasBlock bool + + // isCoinbase reports whether the row is a coinbase transaction. + isCoinbase bool +} + +// invalidateUnminedTxOps is the small backend adapter the shared +// InvalidateUnminedTx workflow needs. +// +// The shared invalidation algorithm is intentionally ordered: +// - load and validate the requested root transaction first +// - load the active unmined graph snapshot used for descendant discovery +// - discover every descendant that depends on the root before any mutation +// starts +// - clear wallet-owned spent-input edges for the root and every discovered +// descendant +// - mark the full invalidated branch failed in one batch update +// +// This sequencing keeps the invalidation event atomic and prevents a partially +// invalid branch from retaining wallet-owned spend edges if any later step +// fails. The backend adapters only supply query wiring and row-shape +// conversions. +type invalidateUnminedTxOps interface { + // loadInvalidateTarget loads the wallet-scoped root tx metadata. + loadInvalidateTarget(ctx context.Context, walletID uint32, + txHash chainhash.Hash) ( + invalidateUnminedTxTarget, error) + + // listUnminedTxRecords loads the wallet's active unmined transaction rows + // in the normalized shape the descendant walk expects. + listUnminedTxRecords(ctx context.Context, walletID int64) ( + []unminedTxRecord, error) + + // clearSpentUtxos restores any wallet-owned parent outputs spent by the + // given transaction row. + clearSpentUtxos(ctx context.Context, walletID int64, txID int64) error + + // markTxnsFailed batch-marks the provided tx rows as failed. + markTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error +} + +// validateUnminedTxTarget checks that the requested root is a current +// unmined non-coinbase transaction. +func validateUnminedTxTarget(target invalidateUnminedTxTarget) error { + if target.hasBlock { + return fmt.Errorf("tx %s is confirmed: %w", target.txHash, + ErrInvalidateTx) + } + + if target.isCoinbase { + return fmt.Errorf("tx %s is coinbase: %w", target.txHash, + ErrInvalidateTx) + } + + if !isUnminedStatus(target.status) { + return fmt.Errorf("tx %s has status %d: %w", target.txHash, + target.status, ErrInvalidateTx) + } + + return nil +} + +// invalidateUnminedTxWithOps invalidates one wallet-owned unmined transaction +// root together with any descendant branch that depends on it. +// +// The helper performs descendant discovery before any spend-edge or status +// mutation begins. It then clears the root spend, clears descendant spends, and +// finally marks the combined branch failed. Keeping that ordering in one shared +// helper ensures postgres and sqlite invalidate branches with identical wallet +// semantics. +func invalidateUnminedTxWithOps(ctx context.Context, + params InvalidateUnminedTxParams, ops invalidateUnminedTxOps) error { + + target, err := ops.loadInvalidateTarget(ctx, params.WalletID, params.Txid) + if err != nil { + return fmt.Errorf("load invalidate tx target: %w", err) + } + + err = validateUnminedTxTarget(target) + if err != nil { + return err + } + + candidates, err := ops.listUnminedTxRecords(ctx, int64(params.WalletID)) + if err != nil { + return fmt.Errorf("list unmined invalidation txns: %w", err) + } + + descendantIDs := collectDescendantTxIDs( + []chainhash.Hash{target.txHash}, nil, candidates, + ) + + err = ops.clearSpentUtxos(ctx, int64(params.WalletID), target.id) + if err != nil { + return fmt.Errorf("clear root spent utxos: %w", err) + } + + for _, descendantID := range descendantIDs { + err = ops.clearSpentUtxos(ctx, int64(params.WalletID), descendantID) + if err != nil { + return fmt.Errorf("clear descendant spent utxos: %w", err) + } + } + + failedIDs := make([]int64, 0, len(descendantIDs)+1) + failedIDs = append(failedIDs, target.id) + failedIDs = append(failedIDs, descendantIDs...) + + err = ops.markTxnsFailed(ctx, int64(params.WalletID), failedIDs) + if err != nil { + return fmt.Errorf("mark invalidated txns failed: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/tx_store_invalidateunmined_common_test.go b/wallet/internal/db/tx_store_invalidateunmined_common_test.go new file mode 100644 index 0000000000..ab9dceb8a2 --- /dev/null +++ b/wallet/internal/db/tx_store_invalidateunmined_common_test.go @@ -0,0 +1,343 @@ +package db + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var ( + errInvalidateCommonTest = errors.New("invalidate common test") + + errInvalidateMockLoadInvalidateTargetType = errors.New( + "loadInvalidateTarget result is not invalidateUnminedTxTarget", + ) + + errInvalidateMockListUnminedType = errors.New( + "listUnminedTxRecords result is not []unminedTxRecord", + ) +) + +// mockInvalidateUnminedTxOps is a mock implementation of +// invalidateUnminedTxOps. +type mockInvalidateUnminedTxOps struct { + mock.Mock +} + +// loadInvalidateTarget implements invalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, + walletID uint32, + txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + + args := m.Called(ctx, walletID, txHash) + if args.Get(0) == nil { + return invalidateUnminedTxTarget{}, args.Error(1) + } + + target, ok := args.Get(0).(invalidateUnminedTxTarget) + if !ok { + return invalidateUnminedTxTarget{}, + errInvalidateMockLoadInvalidateTargetType + } + + return target, args.Error(1) +} + +// listUnminedTxRecords implements invalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) listUnminedTxRecords(ctx context.Context, + walletID int64) ([]unminedTxRecord, error) { + + args := m.Called(ctx, walletID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + records, ok := args.Get(0).([]unminedTxRecord) + if !ok { + return nil, errInvalidateMockListUnminedType + } + + return records, args.Error(1) +} + +// clearSpentUtxos implements invalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, + walletID int64, txID int64) error { + + args := m.Called(ctx, walletID, txID) + + return args.Error(0) +} + +// markTxnsFailed implements invalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) markTxnsFailed(ctx context.Context, + walletID int64, txIDs []int64) error { + + args := m.Called(ctx, walletID, txIDs) + + return args.Error(0) +} + +// TestValidateInvalidateUnminedTxTarget verifies the root-state validation for +// InvalidateUnminedTx. +func TestValidateInvalidateUnminedTxTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target invalidateUnminedTxTarget + wantErr error + }{ + { + name: "pending root", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{1}, + status: TxStatusPending, + hasBlock: false, + }, + }, + { + name: "published root", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{2}, + status: TxStatusPublished, + hasBlock: false, + }, + }, + { + name: "confirmed root rejected", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{3}, + status: TxStatusPublished, + hasBlock: true, + }, + wantErr: ErrInvalidateTx, + }, + { + name: "failed root rejected", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{4}, + status: TxStatusFailed, + }, + wantErr: ErrInvalidateTx, + }, + { + name: "coinbase root rejected", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{5}, + status: TxStatusPublished, + isCoinbase: true, + }, + wantErr: ErrInvalidateTx, + }, + { + name: "orphaned root rejected", + target: invalidateUnminedTxTarget{ + txHash: chainhash.Hash{6}, + status: TxStatusOrphaned, + }, + wantErr: ErrInvalidateTx, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateUnminedTxTarget(test.target) + require.ErrorIs(t, err, test.wantErr) + }) + } +} + +// TestInvalidateUnminedTxWithOps verifies the shared invalidation workflow for +// one unmined root and its descendants. +func TestInvalidateUnminedTxWithOps(t *testing.T) { + t.Parallel() + + rootHash := chainhash.Hash{1} + childHash := chainhash.Hash{2} + grandchildHash := chainhash.Hash{3} + + candidates := []unminedTxRecord{{ + id: 2, + hash: childHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: rootHash, Index: 0}, + }}}, + }, { + id: 3, + hash: grandchildHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: childHash, Index: 0}, + }}}, + }} + + var ( + cleared []int64 + failedIDs []int64 + ) + + ops := &mockInvalidateUnminedTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadInvalidateTarget", mock.Anything, uint32(7), rootHash).Return( + invalidateUnminedTxTarget{ + id: 1, + txHash: rootHash, + status: TxStatusPublished, + }, nil).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + candidates, nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + nil).Run(func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + cleared = append(cleared, txID) + }).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return( + nil).Run(func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + cleared = append(cleared, txID) + }).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return( + nil).Run(func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + cleared = append(cleared, txID) + }).Once() + + ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{1, 2, 3}).Return( + nil).Run(func(args mock.Arguments) { + txIDs, ok := args.Get(2).([]int64) + require.True(t, ok) + failedIDs = append([]int64(nil), txIDs...) + }).Once() + + err := invalidateUnminedTxWithOps( + t.Context(), + InvalidateUnminedTxParams{WalletID: 7, Txid: rootHash}, + ops, + ) + require.NoError(t, err) + + // The root spend is cleared first, then each discovered descendant spend is + // cleared before the whole branch is marked failed in one batch update. + require.Equal(t, []int64{1, 2, 3}, cleared) + require.Equal(t, []int64{1, 2, 3}, failedIDs) +} + +// TestInvalidateUnminedTxWithOpsNoDescendants verifies that the shared helper +// still clears and fails the root when no dependent branch exists. +func TestInvalidateUnminedTxWithOpsNoDescendants(t *testing.T) { + t.Parallel() + + var ( + cleared []int64 + failedIDs []int64 + ) + + ops := &mockInvalidateUnminedTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadInvalidateTarget", + mock.Anything, uint32(8), chainhash.Hash{9}, + ).Return( + invalidateUnminedTxTarget{ + id: 4, + txHash: chainhash.Hash{9}, + status: TxStatusPending, + }, nil).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(8)).Return( + []unminedTxRecord(nil), nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(8), int64(4)).Return( + nil).Run(func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + cleared = append(cleared, txID) + }).Once() + + ops.On("markTxnsFailed", mock.Anything, int64(8), []int64{4}).Return( + nil).Run(func(args mock.Arguments) { + txIDs, ok := args.Get(2).([]int64) + require.True(t, ok) + failedIDs = append([]int64(nil), txIDs...) + }).Once() + + err := invalidateUnminedTxWithOps( + t.Context(), + InvalidateUnminedTxParams{WalletID: 8, Txid: chainhash.Hash{9}}, + ops, + ) + require.NoError(t, err) + require.Equal(t, []int64{4}, cleared) + require.Equal(t, []int64{4}, failedIDs) +} + +// TestInvalidateUnminedTxWithOpsErrors verifies that the shared helper returns +// load and graph-discovery errors before mutating any rows. +func TestInvalidateUnminedTxWithOpsErrors(t *testing.T) { + t.Parallel() + + t.Run("load target", func(t *testing.T) { + t.Parallel() + + ops := &mockInvalidateUnminedTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadInvalidateTarget", + mock.Anything, uint32(8), chainhash.Hash{1}).Return( + nil, errInvalidateCommonTest).Once() + + err := invalidateUnminedTxWithOps( + t.Context(), + InvalidateUnminedTxParams{ + WalletID: 8, + Txid: chainhash.Hash{1}, + }, + ops, + ) + require.ErrorIs(t, err, errInvalidateCommonTest) + require.ErrorContains(t, err, "load invalidate tx target") + }) + + t.Run("list descendants", func(t *testing.T) { + t.Parallel() + + ops := &mockInvalidateUnminedTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadInvalidateTarget", + mock.Anything, uint32(8), chainhash.Hash{2}).Return( + invalidateUnminedTxTarget{ + id: 5, + txHash: chainhash.Hash{2}, + status: TxStatusPublished, + }, nil).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(8)).Return( + nil, errInvalidateCommonTest).Once() + + err := invalidateUnminedTxWithOps( + t.Context(), + InvalidateUnminedTxParams{ + WalletID: 8, + Txid: chainhash.Hash{2}}, + ops, + ) + require.ErrorIs(t, err, errInvalidateCommonTest) + require.ErrorContains(t, err, "list unmined invalidation txns") + }) +} From a56f5cb8e4ae2560ecf07d185bc78ac6850dbdad Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 22:59:25 +0800 Subject: [PATCH 506/691] wallet: add postgres and sqlite InvalidateUnminedTx backends --- .../db/postgres_txstore_invalidateunmined.go | 123 +++++++++++++++++ .../db/sqlite_txstore_invalidateunmined.go | 125 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 wallet/internal/db/postgres_txstore_invalidateunmined.go create mode 100644 wallet/internal/db/sqlite_txstore_invalidateunmined.go diff --git a/wallet/internal/db/postgres_txstore_invalidateunmined.go b/wallet/internal/db/postgres_txstore_invalidateunmined.go new file mode 100644 index 0000000000..339d1ae436 --- /dev/null +++ b/wallet/internal/db/postgres_txstore_invalidateunmined.go @@ -0,0 +1,123 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" +) + +// InvalidateUnminedTx atomically invalidates one wallet-owned unmined +// transaction branch and marks the root plus descendants failed. +func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, + params InvalidateUnminedTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return invalidateUnminedTxWithOps( + ctx, params, pgInvalidateUnminedTxOps{qtx: qtx}, + ) + }) +} + +// pgInvalidateUnminedTxOps adapts postgres sqlc queries to the shared +// InvalidateUnminedTx workflow. +type pgInvalidateUnminedTxOps struct { + qtx *sqlcpg.Queries +} + +var _ invalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) + +// loadInvalidateTarget loads the root tx metadata used by the shared +// invalidation workflow. +func (o pgInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + + row, err := o.qtx.GetTransactionMetaByHash( + ctx, sqlcpg.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return invalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + ErrTxNotFound) + } + + return invalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + err) + } + + status, err := parseTxStatus(int64(row.TxStatus)) + if err != nil { + return invalidateUnminedTxTarget{}, err + } + + return invalidateUnminedTxTarget{ + id: row.ID, + txHash: txHash, + status: status, + hasBlock: row.BlockHeight.Valid, + isCoinbase: row.IsCoinbase, + }, nil +} + +// listUnminedTxRecords loads and decodes the wallet's active unmined +// transaction rows. +func (o pgInvalidateUnminedTxOps) listUnminedTxRecords( + ctx context.Context, walletID int64) ([]unminedTxRecord, error) { + + rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) + if err != nil { + return nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildUnminedTxRecords(rows, + func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + return row.ID, row.TxHash, row.RawTx + }, + ) +} + +// clearSpentUtxos restores any wallet-owned parent outputs spent by the given +// transaction row. +func (o pgInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, + walletID int64, txID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, sqlcpg.ClearUtxosSpentByTxIDParams{ + WalletID: walletID, + SpentByTxID: sql.NullInt64{ + Int64: txID, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("clear spent utxos: %w", err) + } + + return nil +} + +// markTxnsFailed marks the provided tx rows failed in one +// batch update. +func (o pgInvalidateUnminedTxOps) markTxnsFailed( + ctx context.Context, walletID int64, txIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int16(TxStatusFailed), + TxIds: txIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark txns failed: %w", err) + } + + return nil +} diff --git a/wallet/internal/db/sqlite_txstore_invalidateunmined.go b/wallet/internal/db/sqlite_txstore_invalidateunmined.go new file mode 100644 index 0000000000..fe32b1a2ae --- /dev/null +++ b/wallet/internal/db/sqlite_txstore_invalidateunmined.go @@ -0,0 +1,125 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// InvalidateUnminedTx atomically invalidates one wallet-owned unmined +// transaction branch and marks the root plus descendants failed. +func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, + params InvalidateUnminedTxParams) error { + + return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return invalidateUnminedTxWithOps( + ctx, params, sqliteInvalidateUnminedTxOps{qtx: qtx}, + ) + }) +} + +// sqliteInvalidateUnminedTxOps adapts sqlite sqlc queries to the shared +// InvalidateUnminedTx workflow. +type sqliteInvalidateUnminedTxOps struct { + qtx *sqlcsqlite.Queries +} + +var _ invalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) + +// loadInvalidateTarget loads the root tx metadata used by the shared +// invalidation workflow. +func (o sqliteInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + + row, err := o.qtx.GetTransactionMetaByHash( + ctx, sqlcsqlite.GetTransactionMetaByHashParams{ + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return invalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + ErrTxNotFound) + } + + return invalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + err) + } + + status, err := parseTxStatus(row.TxStatus) + if err != nil { + return invalidateUnminedTxTarget{}, err + } + + return invalidateUnminedTxTarget{ + id: row.ID, + txHash: txHash, + status: status, + hasBlock: row.BlockHeight.Valid, + isCoinbase: row.IsCoinbase, + }, nil +} + +// listUnminedTxRecords loads and decodes the wallet's active unmined +// transaction rows. +func (o sqliteInvalidateUnminedTxOps) listUnminedTxRecords( + ctx context.Context, walletID int64) ([]unminedTxRecord, error) { + + rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) + if err != nil { + return nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildUnminedTxRecords( + rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( + int64, []byte, []byte) { + + return row.ID, row.TxHash, row.RawTx + }, + ) +} + +// clearSpentUtxos restores any wallet-owned parent outputs spent by the given +// transaction row. +func (o sqliteInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, + walletID int64, txID int64) error { + + _, err := o.qtx.ClearUtxosSpentByTxID( + ctx, sqlcsqlite.ClearUtxosSpentByTxIDParams{ + WalletID: walletID, + SpentByTxID: sql.NullInt64{ + Int64: txID, + Valid: true, + }, + }, + ) + if err != nil { + return fmt.Errorf("clear spent utxos: %w", err) + } + + return nil +} + +// markTxnsFailed marks the provided tx rows failed in one +// batch update. +func (o sqliteInvalidateUnminedTxOps) markTxnsFailed( + ctx context.Context, walletID int64, txIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int64(TxStatusFailed), + TxIds: txIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark txns failed: %w", err) + } + + return nil +} From dc93381fc7fb65402830ffe51fd264752ba1dfda Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 14:55:29 +0800 Subject: [PATCH 507/691] wallet: embed invalidation ops in CreateTx adapters Embed the backend InvalidateUnminedTx adapters directly in the CreateTx backend structs so the later conflict-handling hooks can reuse the same query wiring. This keeps the backend composition explicit before the shared CreateTx flow starts depending on the promoted invalidation methods. --- wallet/internal/db/postgres_txstore_createtx.go | 8 ++++++-- wallet/internal/db/sqlite_txstore_createtx.go | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go index b940563ca2..bd3a711a44 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -28,13 +28,17 @@ func (s *PostgresStore) CreateTx(ctx context.Context, } return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return createTxWithOps(ctx, req, &pgCreateTxOps{qtx: qtx}) + return createTxWithOps(ctx, req, &pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + qtx: qtx, + }, + }) }) } // pgCreateTxOps adapts postgres sqlc queries to the shared CreateTx flow. type pgCreateTxOps struct { - qtx *sqlcpg.Queries + pgInvalidateUnminedTxOps blockHeight sql.NullInt32 } diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go index 9549dc1138..5c0f00be1b 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -28,13 +28,17 @@ func (s *SqliteStore) CreateTx(ctx context.Context, } return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return createTxWithOps(ctx, req, &sqliteCreateTxOps{qtx: qtx}) + return createTxWithOps(ctx, req, &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: qtx, + }, + }) }) } // sqliteCreateTxOps adapts sqlite sqlc queries to the shared CreateTx flow. type sqliteCreateTxOps struct { - qtx *sqlcsqlite.Queries + sqliteInvalidateUnminedTxOps blockHeight sql.NullInt64 } From 65f83692bd57c273da7909e9b66afe2d0e218023 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 14:57:25 +0800 Subject: [PATCH 508/691] wallet: add CreateTx conflict backend hooks Add the backend helpers that discover direct CreateTx conflict roots and record replacement state for the winning confirmed tx. Keeping these postgres and sqlite details together makes the later shared CreateTx flow change depend on one additive backend step. --- .../internal/db/postgres_txstore_createtx.go | 133 ++++++++++++++++++ wallet/internal/db/sqlite_txstore_createtx.go | 128 +++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go index bd3a711a44..8a7c1f8ee4 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -88,6 +88,97 @@ func (o *pgCreateTxOps) prepareBlock(ctx context.Context, return nil } +// listConflictTxns returns the direct conflict root IDs plus the matching tx +// hashes used for descendant discovery. +func (o *pgCreateTxOps) listConflictTxns(ctx context.Context, + req createTxRequest) ([]int64, []chainhash.Hash, error) { + + rootIDs, err := collectPgConflictRootIDs(ctx, o.qtx, req) + if err != nil { + return nil, nil, err + } + + if len(rootIDs) == 0 { + return nil, nil, nil + } + + rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.params.WalletID)) + if err != nil { + return nil, nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildPgConflictRoots(rows, rootIDs) +} + +// collectPgConflictRootIDs returns the active unmined spender row IDs +// that currently own any wallet-controlled input spent by the incoming tx. +func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, + req createTxRequest) (map[int64]struct{}, error) { + + if blockchain.IsCoinBaseTx(req.params.Tx) { + return map[int64]struct{}{}, nil + } + + rootIDs := make(map[int64]struct{}, len(req.params.Tx.TxIn)) + for inputIndex, txIn := range req.params.Tx.TxIn { + outputIndex, err := uint32ToInt32(txIn.PreviousOutPoint.Index) + if err != nil { + return nil, fmt.Errorf("convert input outpoint index %d: %w", + inputIndex, err) + } + + spentByTxID, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + WalletID: int64(req.params.WalletID), + TxHash: txIn.PreviousOutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue + } + + return nil, fmt.Errorf("lookup input conflict %d: %w", inputIndex, + err) + } + + if !spentByTxID.Valid { + continue + } + + rootIDs[spentByTxID.Int64] = struct{}{} + } + + return rootIDs, nil +} + +// buildPgConflictRoots maps the selected unmined rows into ordered root IDs and +// the matching root hashes used for descendant discovery. +func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, + rootIDSet map[int64]struct{}) ( + []int64, []chainhash.Hash, error) { + + rootIDs := make([]int64, 0, len(rootIDSet)) + + rootHashes := make([]chainhash.Hash, 0, len(rootIDSet)) + for _, row := range rows { + if _, ok := rootIDSet[row.ID]; !ok { + continue + } + + txHash, err := chainhash.NewHash(row.TxHash) + if err != nil { + return nil, nil, fmt.Errorf("tx hash: %w", err) + } + + rootIDs = append(rootIDs, row.ID) + rootHashes = append(rootHashes, *txHash) + } + + return rootIDs, rootHashes, nil +} + // insert stores one new postgres transaction row for CreateTx. func (o *pgCreateTxOps) insert(ctx context.Context, req createTxRequest) (int64, error) { @@ -123,6 +214,48 @@ func (o *pgCreateTxOps) markInputsSpent(ctx context.Context, return markInputsSpentPg(ctx, o.qtx, req.params, txID) } +// markTxnsReplaced marks the provided direct conflict roots replaced in one +// batch update. +func (o *pgCreateTxOps) markTxnsReplaced( + ctx context.Context, walletID int64, txIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int16(TxStatusReplaced), + TxIds: txIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark txns replaced: %w", err) + } + + return nil +} + +// insertReplacementEdges records replacement-history edges from each direct +// conflict root to the newly inserted confirmed transaction row. +func (o *pgCreateTxOps) insertReplacementEdges( + ctx context.Context, walletID int64, replacedTxIDs []int64, + replacementTxID int64) error { + + for _, replacedTxID := range replacedTxIDs { + _, err := o.qtx.InsertTxReplacementEdge( + ctx, sqlcpg.InsertTxReplacementEdgeParams{ + WalletID: walletID, + ReplacedTxID: replacedTxID, + ReplacementTxID: replacementTxID, + }, + ) + if err != nil { + return fmt.Errorf("insert replacement edge for %d: %w", + replacedTxID, err) + } + } + + return nil +} + // insertCreditsPg inserts one wallet-owned UTXO row for each credited output of // the transaction being stored. func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go index 5c0f00be1b..f533a3bd21 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -90,6 +90,92 @@ func (o *sqliteCreateTxOps) prepareBlock(ctx context.Context, return nil } +// listConflictTxns returns the direct conflict root IDs plus the matching tx +// hashes used for descendant discovery. +func (o *sqliteCreateTxOps) listConflictTxns(ctx context.Context, + req createTxRequest) ([]int64, []chainhash.Hash, error) { + + rootIDs, err := collectSqliteConflictRootIDs(ctx, o.qtx, req) + if err != nil { + return nil, nil, err + } + + if len(rootIDs) == 0 { + return nil, nil, nil + } + + rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.params.WalletID)) + if err != nil { + return nil, nil, fmt.Errorf("list unmined txns: %w", err) + } + + return buildSqliteConflictRoots(rows, rootIDs) +} + +// collectSqliteConflictRootIDs returns the active unmined spender row +// IDs that currently own any wallet-controlled input spent by the incoming tx. +func collectSqliteConflictRootIDs(ctx context.Context, + qtx *sqlcsqlite.Queries, + req createTxRequest) (map[int64]struct{}, error) { + + if blockchain.IsCoinBaseTx(req.params.Tx) { + return map[int64]struct{}{}, nil + } + + rootIDs := make(map[int64]struct{}, len(req.params.Tx.TxIn)) + for inputIndex, txIn := range req.params.Tx.TxIn { + spentByTxID, err := qtx.GetUtxoSpendByOutpoint( + ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + WalletID: int64(req.params.WalletID), + TxHash: txIn.PreviousOutPoint.Hash[:], + OutputIndex: int64(txIn.PreviousOutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue + } + + return nil, fmt.Errorf("lookup input conflict %d: %w", inputIndex, + err) + } + + if !spentByTxID.Valid { + continue + } + + rootIDs[spentByTxID.Int64] = struct{}{} + } + + return rootIDs, nil +} + +// buildSqliteConflictRoots maps the selected unmined rows into ordered root IDs +// and the matching root hashes used for descendant discovery. +func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, + rootIDSet map[int64]struct{}) ( + []int64, []chainhash.Hash, error) { + + rootIDs := make([]int64, 0, len(rootIDSet)) + + rootHashes := make([]chainhash.Hash, 0, len(rootIDSet)) + for _, row := range rows { + if _, ok := rootIDSet[row.ID]; !ok { + continue + } + + txHash, err := chainhash.NewHash(row.TxHash) + if err != nil { + return nil, nil, fmt.Errorf("tx hash: %w", err) + } + + rootIDs = append(rootIDs, row.ID) + rootHashes = append(rootHashes, *txHash) + } + + return rootIDs, rootHashes, nil +} + // insert stores one new sqlite transaction row for CreateTx. func (o *sqliteCreateTxOps) insert(ctx context.Context, req createTxRequest) (int64, error) { @@ -128,6 +214,48 @@ func (o *sqliteCreateTxOps) markInputsSpent(ctx context.Context, return markInputsSpentSqlite(ctx, o.qtx, req.params, txID) } +// markTxnsReplaced marks the provided direct conflict roots replaced in one +// batch update. +func (o *sqliteCreateTxOps) markTxnsReplaced( + ctx context.Context, walletID int64, txIDs []int64) error { + + _, err := o.qtx.UpdateTransactionStatusByIDs( + ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + WalletID: walletID, + Status: int64(TxStatusReplaced), + TxIds: txIDs, + }, + ) + if err != nil { + return fmt.Errorf("mark txns replaced: %w", err) + } + + return nil +} + +// insertReplacementEdges records replacement-history edges from each direct +// conflict root to the newly inserted confirmed transaction row. +func (o *sqliteCreateTxOps) insertReplacementEdges( + ctx context.Context, walletID int64, replacedTxIDs []int64, + replacementTxID int64) error { + + for _, replacedTxID := range replacedTxIDs { + _, err := o.qtx.InsertTxReplacementEdge( + ctx, sqlcsqlite.InsertTxReplacementEdgeParams{ + WalletID: walletID, + ReplacedTxID: replacedTxID, + ReplacementTxID: replacementTxID, + }, + ) + if err != nil { + return fmt.Errorf("insert replacement edge for %d: %w", + replacedTxID, err) + } + } + + return nil +} + // insertCreditsSqlite inserts one wallet-owned UTXO row for each credited // output of the transaction being stored. func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, From cb8ed4916a128f724f1a740aaf473ce62eb60771 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 20:49:49 +0800 Subject: [PATCH 509/691] wallet: reuse existing CreateTx rows Teach the shared CreateTx flow to reload an existing wallet-scoped tx row and promote that row when the same unmined tx later arrives with confirming block metadata. This prepares the existing-row reuse path before the later conflict handling changes extend the shared CreateTx flow further. --- .../internal/db/postgres_txstore_createtx.go | 66 ++++++++++--- wallet/internal/db/sqlite_txstore_createtx.go | 66 ++++++++++--- wallet/internal/db/tx_store_common.go | 93 +++++++++++++++++-- wallet/internal/db/tx_store_common_test.go | 60 ++++++++++-- 4 files changed, 238 insertions(+), 47 deletions(-) diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go index 8a7c1f8ee4..dd81f7688c 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -15,10 +15,11 @@ import ( // wallet-owned credits, and any spend edges created by its inputs. // // The full write runs inside ExecuteTx so the transaction row, created UTXOs, -// and spent-parent markers are either committed together or not at all. -// Received timestamps are normalized to UTC before insert. CreateTx is -// insert-only and returns ErrTxAlreadyExists if the wallet already stores the -// tx hash. +// spent-parent markers, and any required invalidation are either committed +// together or not at all. Received timestamps are normalized to UTC before +// insert. When the wallet already stores the same unmined transaction hash, +// CreateTx may promote that existing row to confirmed state instead of +// inserting a duplicate. func (s *PostgresStore) CreateTx(ctx context.Context, params CreateTxParams) error { @@ -45,26 +46,65 @@ type pgCreateTxOps struct { var _ createTxOps = (*pgCreateTxOps)(nil) -// hasExisting reports whether the wallet already stores the requested tx hash. -func (o *pgCreateTxOps) hasExisting(ctx context.Context, - req createTxRequest) (bool, error) { +// loadExisting loads any existing wallet-scoped row for the requested tx hash. +func (o *pgCreateTxOps) loadExisting(ctx context.Context, + req createTxRequest) (*createTxExistingTarget, error) { - _, err := o.qtx.GetTransactionMetaByHash( + meta, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcpg.GetTransactionMetaByHashParams{ WalletID: int64(req.params.WalletID), TxHash: req.txHash[:], }, ) - if err == nil { - return true, nil + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errCreateTxExistingNotFound + } + + return nil, fmt.Errorf("get tx metadata: %w", err) } - if errors.Is(err, sql.ErrNoRows) { - return false, nil + status, err := parseTxStatus(int64(meta.TxStatus)) + if err != nil { + return nil, err } - return false, fmt.Errorf("get tx metadata: %w", err) + return &createTxExistingTarget{ + id: meta.ID, + status: status, + hasBlock: meta.BlockHeight.Valid, + isCoinbase: meta.IsCoinbase, + }, nil +} + +// confirmExisting promotes one existing unmined row to its confirmed state. +func (o *pgCreateTxOps) confirmExisting(ctx context.Context, + req createTxRequest, + _ createTxExistingTarget) error { + + blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.params.Block) + if err != nil { + return fmt.Errorf("require confirming block: %w", err) + } + + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, sqlcpg.UpdateTransactionStateByHashParams{ + BlockHeight: sql.NullInt32{Int32: blockHeight, Valid: true}, + Status: int16(TxStatusPublished), + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", req.txHash, ErrTxNotFound) + } + + return nil } // prepareBlock validates the optional confirming block and caches the postgres diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go index f533a3bd21..3abea75519 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -15,10 +15,11 @@ import ( // credits, and any spend edges created by its inputs. // // The full write runs inside ExecuteTx so the transaction row, created UTXOs, -// and spent-parent markers are either committed together or not at all. -// Received timestamps are normalized to UTC before insert. CreateTx is -// insert-only and returns ErrTxAlreadyExists if the wallet already stores the -// tx hash. +// spent-parent markers, and any required invalidation are either committed +// together or not at all. Received timestamps are normalized to UTC before +// insert. When the wallet already stores the same unmined transaction hash, +// CreateTx may promote that existing row to confirmed state instead of +// inserting a duplicate. func (s *SqliteStore) CreateTx(ctx context.Context, params CreateTxParams) error { @@ -45,28 +46,65 @@ type sqliteCreateTxOps struct { var _ createTxOps = (*sqliteCreateTxOps)(nil) -// hasExisting reports whether the wallet already stores the requested tx hash. -func (o *sqliteCreateTxOps) hasExisting(ctx context.Context, - req createTxRequest) (bool, error) { +// loadExisting loads any existing wallet-scoped row for the requested tx hash. +func (o *sqliteCreateTxOps) loadExisting(ctx context.Context, + req createTxRequest) (*createTxExistingTarget, error) { - _, err := o.qtx.GetTransactionMetaByHash( + meta, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcsqlite.GetTransactionMetaByHashParams{ WalletID: int64(req.params.WalletID), TxHash: req.txHash[:], }, ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errCreateTxExistingNotFound + } + + return nil, fmt.Errorf("get tx metadata: %w", err) + } + + status, err := parseTxStatus(meta.TxStatus) + if err != nil { + return nil, err + } + + return &createTxExistingTarget{ + id: meta.ID, + status: status, + hasBlock: meta.BlockHeight.Valid, + isCoinbase: meta.IsCoinbase, + }, nil +} + +// confirmExisting promotes one existing unmined row to its confirmed state. +func (o *sqliteCreateTxOps) confirmExisting(ctx context.Context, + req createTxRequest, + _ createTxExistingTarget) error { - // Exit early if there is no error. - if err == nil { - return true, nil + blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.params.Block) + if err != nil { + return fmt.Errorf("require confirming block: %w", err) } - if errors.Is(err, sql.ErrNoRows) { - return false, nil + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ + BlockHeight: sql.NullInt64{Int64: blockHeight, Valid: true}, + Status: int64(TxStatusPublished), + WalletID: int64(req.params.WalletID), + TxHash: req.txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update tx state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", req.txHash, ErrTxNotFound) } - return false, fmt.Errorf("get tx metadata: %w", err) + return nil } // prepareBlock validates the optional confirming block and caches the sqlite diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 94f52c97d0..505a8852dd 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -256,6 +256,18 @@ func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { }, nil } +// createTxExistingTarget is the normalized metadata the shared CreateTx flow +// needs when the wallet already stores the requested tx hash. +type createTxExistingTarget struct { + id int64 + status TxStatus + hasBlock bool + isCoinbase bool +} + +var errCreateTxExistingNotFound = errors.New("create tx existing target not " + + "found") + // createTxOps is the small semantic adapter CreateTx needs from one SQL // backend. // @@ -265,8 +277,6 @@ func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { // inserting a duplicate // - validate and cache any confirming block metadata before later writes use // it -// - when the incoming tx is confirmed, discover and invalidate any direct -// conflict roots before the new row claims their wallet-owned inputs // - insert the base transaction row exactly once when no existing row can be // reused // - insert every wallet-owned credited output as a UTXO @@ -278,9 +288,15 @@ func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { // creation, and spent-input claims must either all observe the same tx row or // all roll back together. type createTxOps interface { - // hasExisting reports whether CreateTx would collide with an existing - // wallet-scoped transaction row for the same hash. - hasExisting(ctx context.Context, req createTxRequest) (bool, error) + // loadExisting loads any existing wallet-scoped transaction row for the + // same hash. + loadExisting(ctx context.Context, req createTxRequest) ( + *createTxExistingTarget, error) + + // confirmExisting reuses one existing row when CreateTx learns about the + // same transaction with confirming block context later. + confirmExisting(ctx context.Context, req createTxRequest, + existing createTxExistingTarget) error // prepareBlock validates and caches any optional confirming block metadata // the later insert step needs. @@ -298,6 +314,55 @@ type createTxOps interface { markInputsSpent(ctx context.Context, req createTxRequest, txID int64) error } +// checkReuseCreateTx reports whether CreateTx should reuse an existing wallet- +// scoped row instead of inserting a new one. +func checkReuseCreateTx(req createTxRequest, + existing createTxExistingTarget) bool { + + if req.params.Block == nil { + return false + } + + if req.params.Status != TxStatusPublished { + return false + } + + if existing.hasBlock { + return false + } + + if existing.isCoinbase { + return false + } + + if !isUnminedStatus(existing.status) { + return false + } + + return true +} + +// loadCreateTxExisting resolves any wallet-scoped row already stored for the +// requested tx hash and reports whether one was found. +func loadCreateTxExisting(ctx context.Context, req createTxRequest, + ops createTxOps) (*createTxExistingTarget, bool, error) { + + existing, err := ops.loadExisting(ctx, req) + if err != nil && !errors.Is(err, errCreateTxExistingNotFound) { + return nil, false, fmt.Errorf("load create tx target: %w", err) + } + + if errors.Is(err, errCreateTxExistingNotFound) { + return nil, false, nil + } + + if existing == nil { + return nil, false, nil + } + + return existing, true, nil +} + // createTxWithOps runs the backend-independent CreateTx orchestration once the // caller has opened a backend-specific SQL transaction. // @@ -307,14 +372,22 @@ type createTxOps interface { func createTxWithOps(ctx context.Context, req createTxRequest, ops createTxOps) error { - exists, err := ops.hasExisting(ctx, req) + existing, foundExisting, err := loadCreateTxExisting(ctx, req, ops) if err != nil { - return fmt.Errorf("prepare create tx: check existing tx: %w", err) + return err } - if exists { - return fmt.Errorf("prepare create tx: tx %s: %w", req.txHash, - ErrTxAlreadyExists) + if foundExisting { + if !checkReuseCreateTx(req, *existing) { + return fmt.Errorf("tx %s: %w", req.txHash, ErrTxAlreadyExists) + } + + err = ops.confirmExisting(ctx, req, *existing) + if err != nil { + return fmt.Errorf("confirm existing tx: %w", err) + } + + return nil } err = ops.prepareBlock(ctx, req) diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index e2d1910f50..282a63b35c 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -328,7 +328,7 @@ func TestCreateTxWithOpsInsert(t *testing.T) { // Assert: The shared flow executes the expected write sequence. require.Equal(t, - []string{"exists", "prepare-block", "insert", "credits", "inputs"}, + []string{"load-existing", "prepare-block", "insert", "credits", "inputs"}, ops.calls, ) require.Equal(t, int64(11), ops.creditsTxID) @@ -342,11 +342,38 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { t.Parallel() req := testCreateTxRequest(t) - ops := &stubCreateTxOps{hasExistingResult: true} + ops := &stubCreateTxOps{existing: &createTxExistingTarget{id: 4}} err := createTxWithOps(context.Background(), req, ops) require.ErrorIs(t, err, ErrTxAlreadyExists) - require.Equal(t, []string{"exists"}, ops.calls) + require.Equal(t, []string{"load-existing"}, ops.calls) +} + +// TestCreateTxWithOpsConfirmExisting verifies that the shared CreateTx flow can +// promote one existing unmined row to confirmed state instead of inserting a +// duplicate row. +func TestCreateTxWithOpsConfirmExisting(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 5, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{existing: &createTxExistingTarget{ + id: 7, + status: TxStatusPending, + hasBlock: false, + }} + + err = createTxWithOps(context.Background(), req, ops) + require.NoError(t, err) + require.Equal(t, []string{"load-existing", "confirm-existing"}, ops.calls) } // testBlock builds a simple block fixture for CreateTx validation tests. @@ -382,8 +409,8 @@ func testCoinbaseMsgTx() *wire.MsgTx { // stubCreateTxOps records how the shared CreateTx helper drives one backend // adapter while letting each test control the returned transaction IDs. type stubCreateTxOps struct { - hasExistingResult bool - insertTxID int64 + existing *createTxExistingTarget + insertTxID int64 calls []string insertReq createTxRequest @@ -393,14 +420,27 @@ type stubCreateTxOps struct { var _ createTxOps = (*stubCreateTxOps)(nil) -// hasExisting records that the shared flow checked whether the tx hash already +// loadExisting records that the shared flow checked whether the tx hash already // exists and returns the test-controlled result. -func (s *stubCreateTxOps) hasExisting(_ context.Context, - _ createTxRequest) (bool, error) { +func (s *stubCreateTxOps) loadExisting(_ context.Context, + _ createTxRequest) (*createTxExistingTarget, error) { + + s.calls = append(s.calls, "load-existing") - s.calls = append(s.calls, "exists") + if s.existing == nil { + return nil, errCreateTxExistingNotFound + } - return s.hasExistingResult, nil + return s.existing, nil +} + +// confirmExisting records that the shared flow promoted one existing row. +func (s *stubCreateTxOps) confirmExisting(_ context.Context, + _ createTxRequest, _ createTxExistingTarget) error { + + s.calls = append(s.calls, "confirm-existing") + + return nil } // prepareBlock records that the shared flow validated any optional block From e03633f73bd46781be62e99fee9987e538f03894 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 20:50:11 +0800 Subject: [PATCH 510/691] wallet: handle CreateTx conflicts in shared flow Extend the shared CreateTx flow to discover direct conflict roots, rewrite those roots to replaced state, and fail any dependent branch before the new winner claims the shared inputs. This commit keeps the shared conflict orchestration and its unit tests together so the replacement ordering stays reviewable in one place. --- wallet/internal/db/tx_store_common.go | 231 +++++++++++- wallet/internal/db/tx_store_common_test.go | 393 ++++++++++++++++++++- 2 files changed, 592 insertions(+), 32 deletions(-) diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 505a8852dd..d28e6e50bb 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -279,6 +279,11 @@ var errCreateTxExistingNotFound = errors.New("create tx existing target not " + // it // - insert the base transaction row exactly once when no existing row can be // reused +// - when the incoming tx is confirmed, discover any direct conflict roots +// before later writes claim shared inputs or rewrite that branch +// - if direct conflicts were found, rewrite those roots to replaced state, +// fail their descendants, and record replacement history before the new +// row claims their wallet-owned inputs // - insert every wallet-owned credited output as a UTXO // - attach any wallet-owned spent inputs to that final transaction row // @@ -288,6 +293,8 @@ var errCreateTxExistingNotFound = errors.New("create tx existing target not " + // creation, and spent-input claims must either all observe the same tx row or // all roll back together. type createTxOps interface { + invalidateUnminedTxOps + // loadExisting loads any existing wallet-scoped transaction row for the // same hash. loadExisting(ctx context.Context, req createTxRequest) ( @@ -302,6 +309,20 @@ type createTxOps interface { // the later insert step needs. prepareBlock(ctx context.Context, req createTxRequest) error + // listConflictTxns returns the direct wallet-owned conflict tx IDs plus the + // corresponding hashes used for descendant discovery. + listConflictTxns(ctx context.Context, req createTxRequest) ([]int64, + []chainhash.Hash, error) + + // markTxnsReplaced batch-marks the provided direct conflict roots + // as replaced. + markTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error + + // insertReplacementEdges records replacement-history edges from each direct + // conflict root to the newly inserted confirmed transaction row. + insertReplacementEdges(ctx context.Context, walletID int64, + replacedTxIDs []int64, replacementTxID int64) error + // insert writes the base transaction row and returns its new primary key. insert(ctx context.Context, req createTxRequest) (int64, error) @@ -319,22 +340,34 @@ type createTxOps interface { func checkReuseCreateTx(req createTxRequest, existing createTxExistingTarget) bool { + // Only a newly confirmed observation can reuse an existing row. + // Plain unmined inserts still create fresh unmined history + // instead of rewriting one existing record in place. if req.params.Block == nil { return false } + // Reuse is only for the mined published state that records + // the wallet's final view of the tx once a block anchors it. if req.params.Status != TxStatusPublished { return false } + // A row that already has a confirming block is already in its final mined + // form, so CreateTx must reject the duplicate instead of mutating it again. if existing.hasBlock { return false } + // Coinbase rows only reuse the orphaned state. That path restores the same + // coinbase hash after rollback disconnected its previous confirming block. if existing.isCoinbase { return false } + // Non-coinbase rows only reuse the current unmined states. + // Once a row is invalidated, UpdateTx/DeleteTx no longer + // treat it as a live unmined target. if !isUnminedStatus(existing.status) { return false } @@ -363,48 +396,174 @@ func loadCreateTxExisting(ctx context.Context, req createTxRequest, return existing, true, nil } -// createTxWithOps runs the backend-independent CreateTx orchestration once the -// caller has opened a backend-specific SQL transaction. +// collectConflictDescendants loads the live unmined graph snapshot and returns +// the descendant tx IDs for the provided direct conflict roots. // -// The helper inserts the base transaction row before it records credits or -// spent inputs so every later step can point at one stable row ID. If any later -// step fails, ExecuteTx rolls the whole write back. -func createTxWithOps(ctx context.Context, req createTxRequest, - ops createTxOps) error { +// NOTE: rootHashes is expected to be a set with unique tx hashes. +func collectConflictDescendants(ctx context.Context, walletID int64, + rootHashes []chainhash.Hash, rootIDs []int64, + ops createTxOps) ([]int64, error) { - existing, foundExisting, err := loadCreateTxExisting(ctx, req, ops) + candidates, err := ops.listUnminedTxRecords(ctx, walletID) if err != nil { - return err + return nil, fmt.Errorf("list create tx conflict candidates: %w", err) } - if foundExisting { - if !checkReuseCreateTx(req, *existing) { - return fmt.Errorf("tx %s: %w", req.txHash, ErrTxAlreadyExists) + descendantIDs := collectDescendantTxIDs(rootHashes, rootIDs, candidates) + + return descendantIDs, nil +} + +// handleRootTxns clears the direct root spends, marks those rows replaced, and +// records replacement edges to the winning confirmed tx. +func handleRootTxns(ctx context.Context, walletID int64, rootIDs []int64, + replacementTxID int64, ops createTxOps) error { + + for _, rootID := range rootIDs { + err := ops.clearSpentUtxos(ctx, walletID, rootID) + if err != nil { + return fmt.Errorf("clear replaced root spent utxos: %w", err) } + } - err = ops.confirmExisting(ctx, req, *existing) + err := ops.markTxnsReplaced(ctx, walletID, rootIDs) + if err != nil { + return fmt.Errorf("mark direct conflicts replaced: %w", err) + } + + err = ops.insertReplacementEdges(ctx, walletID, rootIDs, replacementTxID) + if err != nil { + return fmt.Errorf("record conflict replacement edges: %w", err) + } + + return nil +} + +// handleTxDescendants clears the discovered descendant spends and then marks +// that dependent branch failed. +func handleTxDescendants(ctx context.Context, walletID int64, + descendantIDs []int64, ops createTxOps) error { + + if len(descendantIDs) == 0 { + return nil + } + + for _, descendantID := range descendantIDs { + err := ops.clearSpentUtxos(ctx, walletID, descendantID) if err != nil { - return fmt.Errorf("confirm existing tx: %w", err) + return fmt.Errorf("clear failed descendant spent utxos: %w", err) } + } + err := ops.markTxnsFailed(ctx, walletID, descendantIDs) + if err != nil { + return fmt.Errorf("mark conflict descendants failed: %w", err) + } + + return nil +} + +// handleTxConflicts discovers the direct conflicting roots of a new confirmed +// tx, rewrites them to replaced state, and marks their descendants failed. +// +// The replacement algorithm is intentionally ordered: +// - load the direct conflicting roots first +// - load the live unmined graph snapshot used for descendant discovery +// - discover every descendant that depends on those roots before any mutation +// starts +// - handle the direct root txns first +// - handle the descendant txns second +// +// That sequencing preserves replacement history for the direct conflicts while +// still invalidating the dependent branch atomically inside one SQL +// transaction. +func handleTxConflicts(ctx context.Context, req createTxRequest, + replacementTxID int64, ops createTxOps) error { + + // Only confirmed inserts can replace an active unmined branch. + if req.params.Block == nil { return nil } - err = ops.prepareBlock(ctx, req) + // Load the direct roots first so every later step works from the currently + // visible spend edges on the shared parent inputs. + rootIDs, rootHashes, err := ops.listConflictTxns(ctx, req) if err != nil { - return fmt.Errorf("prepare create block assignment: %w", err) + return fmt.Errorf("list conflict txns: %w", err) } + // Exit early if there are no conflicts. + if len(rootIDs) == 0 { + return nil + } + + walletID := int64(req.params.WalletID) + + // Discover descendants before any mutation starts. + // Later rewrites can otherwise hide part of the displaced + // branch from the graph walk. + descendantIDs, err := collectConflictDescendants( + ctx, walletID, rootHashes, rootIDs, ops, + ) + if err != nil { + return err + } + + // Direct roots keep the replacement state and replacement history. + err = handleRootTxns(ctx, walletID, rootIDs, replacementTxID, ops) + if err != nil { + return err + } + + // Descendants clear their spend edges and then fall to the failed state. + err = handleTxDescendants(ctx, walletID, descendantIDs, ops) + if err != nil { + return err + } + + return nil +} + +// insertCreateTx completes the fresh-insert CreateTx path. +// +// The order is important: +// - insert first so the new winner row has a stable tx ID +// - reconcile conflicts next while the displaced unmined branch is still +// discoverable through the current spend edges +// - create wallet-owned credits after replacement handling +// - claim wallet-owned parent inputs last, because that rewires the shared +// spend edges to the new winner row +func insertCreateTx(ctx context.Context, req createTxRequest, + ops createTxOps) error { + + // Insert the winner row first so every later write can point at its stable + // primary key. In particular, replacement-history edges need the new tx ID. txID, err := ops.insert(ctx, req) if err != nil { return fmt.Errorf("insert tx: %w", err) } + // Reconcile any conflicting unmined branch before this tx claims the shared + // parent inputs. Conflict discovery looks at the current spend edges on + // those parents; once markInputsSpent rewires them to this new row, the old + // branch is no longer discoverable as the direct conflict root. + err = handleTxConflicts(ctx, req, txID, ops) + if err != nil { + return err + } + + // Credits only describe outputs created by the new tx itself, so they do + // not interfere with conflict discovery. Keep them after replacement + // handling so the branch rewrite stays grouped with the shared-input + // reconciliation. err = ops.insertCredits(ctx, req, txID) if err != nil { return fmt.Errorf("create tx credits: %w", err) } + // Claim wallet-owned parent inputs last. This is the write that makes the + // new tx the recorded spender of the shared parents, so doing it earlier + // would hide the displaced unmined branch from the replacement walk. err = ops.markInputsSpent(ctx, req, txID) if err != nil { return fmt.Errorf("create tx spends: %w", err) @@ -413,6 +572,42 @@ func createTxWithOps(ctx context.Context, req createTxRequest, return nil } +// createTxWithOps runs the backend-independent CreateTx orchestration once the +// caller has opened a backend-specific SQL transaction. +// +// The helper can either confirm an existing unmined row or insert a new row. +// For confirmed inserts it also reconciles any current direct conflict branch +// before the new row claims wallet-owned inputs. The helper owns that ordering +// so the backends only need to supply query wiring and type conversion. +func createTxWithOps(ctx context.Context, req createTxRequest, + ops createTxOps) error { + + existing, foundExisting, err := loadCreateTxExisting(ctx, req, ops) + if err != nil { + return err + } + + if foundExisting { + if !checkReuseCreateTx(req, *existing) { + return fmt.Errorf("tx %s: %w", req.txHash, ErrTxAlreadyExists) + } + + err = ops.confirmExisting(ctx, req, *existing) + if err != nil { + return fmt.Errorf("confirm existing tx: %w", err) + } + + return nil + } + + err = ops.prepareBlock(ctx, req) + if err != nil { + return fmt.Errorf("prepare create block assignment: %w", err) + } + + return insertCreateTx(ctx, req, ops) +} + // validateUpdateTxParams checks that UpdateTx received at least one mutable // field and that any requested state transition satisfies the transaction table // invariants. @@ -508,9 +703,7 @@ type updateTxOps interface { func updateTxWithOps(ctx context.Context, params UpdateTxParams, ops updateTxOps) error { - isCoinbase, err := ops.loadIsCoinbase( - ctx, params.WalletID, params.Txid, - ) + isCoinbase, err := ops.loadIsCoinbase(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("load update tx target: %w", err) } diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 282a63b35c..c425cb21f9 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -3,6 +3,7 @@ package db import ( "bytes" "context" + "errors" "testing" "time" @@ -328,7 +329,13 @@ func TestCreateTxWithOpsInsert(t *testing.T) { // Assert: The shared flow executes the expected write sequence. require.Equal(t, - []string{"load-existing", "prepare-block", "insert", "credits", "inputs"}, + []string{ + "load-existing", + "prepare-block", + "insert", + "credits", + "inputs", + }, ops.calls, ) require.Equal(t, int64(11), ops.creditsTxID) @@ -376,6 +383,45 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { require.Equal(t, []string{"load-existing", "confirm-existing"}, ops.calls) } +// TestCreateTxWithOpsReplaceConflicts verifies that the shared CreateTx flow +// rewrites direct conflict roots to replaced state after inserting the +// confirmed winner and before that winner claims the shared spent-parent edge. +func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 5, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{ + insertTxID: 11, + conflictIDs: []int64{5}, + conflictHashes: []chainhash.Hash{{9}}, + } + + err = createTxWithOps(context.Background(), req, ops) + require.NoError(t, err) + require.Equal(t, []string{ + "load-existing", + "prepare-block", + "insert", + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + "credits", + "inputs", + }, ops.calls) + require.Equal(t, int64(11), ops.replacedConflictTxID) +} + // testBlock builds a simple block fixture for CreateTx validation tests. func testBlock(height uint32) *Block { return &Block{ @@ -409,13 +455,26 @@ func testCoinbaseMsgTx() *wire.MsgTx { // stubCreateTxOps records how the shared CreateTx helper drives one backend // adapter while letting each test control the returned transaction IDs. type stubCreateTxOps struct { - existing *createTxExistingTarget - insertTxID int64 - - calls []string - insertReq createTxRequest - creditsTxID int64 - inputsTxID int64 + existing *createTxExistingTarget + insertTxID int64 + conflictIDs []int64 + conflictHashes []chainhash.Hash + unminedTxns []unminedTxRecord + + listConflictsErr error + listUnminedErr error + clearSpentErr error + markFailedErr error + + calls []string + insertReq createTxRequest + creditsTxID int64 + inputsTxID int64 + clearedTxIDs []int64 + replacedTxIDs []int64 + failedTxIDs []int64 + replacementEdgeTxIDs []int64 + replacedConflictTxID int64 } var _ createTxOps = (*stubCreateTxOps)(nil) @@ -427,16 +486,13 @@ func (s *stubCreateTxOps) loadExisting(_ context.Context, s.calls = append(s.calls, "load-existing") - if s.existing == nil { - return nil, errCreateTxExistingNotFound - } - return s.existing, nil } // confirmExisting records that the shared flow promoted one existing row. func (s *stubCreateTxOps) confirmExisting(_ context.Context, - _ createTxRequest, _ createTxExistingTarget) error { + _ createTxRequest, + _ createTxExistingTarget) error { s.calls = append(s.calls, "confirm-existing") @@ -453,6 +509,95 @@ func (s *stubCreateTxOps) prepareBlock(_ context.Context, return nil } +// listConflictTxns records that the shared flow checked for direct wallet-owned +// conflicts after insert and before input claims. +func (s *stubCreateTxOps) listConflictTxns(_ context.Context, + _ createTxRequest) ([]int64, []chainhash.Hash, error) { + + s.calls = append(s.calls, "list-conflicts") + + if s.listConflictsErr != nil { + return nil, nil, s.listConflictsErr + } + + return s.conflictIDs, s.conflictHashes, nil +} + +// loadInvalidateTarget satisfies the embedded invalidateUnminedTxOps interface +// for the shared CreateTx orchestration tests. +func (s *stubCreateTxOps) loadInvalidateTarget(_ context.Context, _ uint32, + _ chainhash.Hash) (invalidateUnminedTxTarget, error) { + + return invalidateUnminedTxTarget{}, nil +} + +// listUnminedTxRecords satisfies the embedded invalidateUnminedTxOps interface +// for the shared CreateTx orchestration tests. +func (s *stubCreateTxOps) listUnminedTxRecords(_ context.Context, + _ int64) ([]unminedTxRecord, error) { + + s.calls = append(s.calls, "list-unmined-records") + + if s.listUnminedErr != nil { + return nil, s.listUnminedErr + } + + return s.unminedTxns, nil +} + +// clearSpentUtxos satisfies the embedded invalidateUnminedTxOps interface for +// the shared CreateTx orchestration tests. +func (s *stubCreateTxOps) clearSpentUtxos(_ context.Context, _ int64, + txID int64) error { + + s.calls = append(s.calls, "clear-spent-utxos") + s.clearedTxIDs = append(s.clearedTxIDs, txID) + + if s.clearSpentErr != nil { + return s.clearSpentErr + } + + return nil +} + +// markTxnsFailed satisfies the embedded invalidateUnminedTxOps interface for +// the shared CreateTx orchestration tests. +func (s *stubCreateTxOps) markTxnsFailed(_ context.Context, _ int64, + txIDs []int64) error { + + s.calls = append(s.calls, "mark-txns-failed") + s.failedTxIDs = append([]int64(nil), txIDs...) + + if s.markFailedErr != nil { + return s.markFailedErr + } + + return nil +} + +// markTxnsReplaced satisfies the replacement hooks CreateTx uses for direct +// conflict reconciliation. +func (s *stubCreateTxOps) markTxnsReplaced(_ context.Context, _ int64, + txIDs []int64) error { + + s.calls = append(s.calls, "mark-txns-replaced") + s.replacedTxIDs = append([]int64(nil), txIDs...) + + return nil +} + +// insertReplacementEdges satisfies the replacement hooks CreateTx uses for +// direct conflict reconciliation. +func (s *stubCreateTxOps) insertReplacementEdges(_ context.Context, _ int64, + replacedTxIDs []int64, replacementTxID int64) error { + + s.calls = append(s.calls, "insert-replacement-edges") + s.replacementEdgeTxIDs = append([]int64(nil), replacedTxIDs...) + s.replacedConflictTxID = replacementTxID + + return nil +} + // insert records the request that the shared flow would store as a fresh // transaction row. func (s *stubCreateTxOps) insert(_ context.Context, @@ -503,6 +648,228 @@ func testCreateTxRequest(t *testing.T) createTxRequest { return req } +var errConflictMarkFailed = errors.New("mark failed") + +// TestCollectConflictDescendants verifies that the helper derives the direct +// root IDs and descendant IDs from the current unmined graph snapshot. +func TestCollectConflictDescendants(t *testing.T) { + t.Parallel() + + candidates := []unminedTxRecord{{ + id: 12, + hash: chainhash.Hash{2}, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, + }}}, + }, { + id: 13, + hash: chainhash.Hash{3}, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 0}, + }}}, + }} + ops := &stubCreateTxOps{unminedTxns: candidates} + + rootIDs := []int64{11, 12} + rootHashes := []chainhash.Hash{{1}, {2}} + + descendantIDs, err := collectConflictDescendants( + context.Background(), 7, rootHashes, rootIDs, ops, + ) + require.NoError(t, err) + require.Equal(t, []int64{13}, descendantIDs) + require.Equal(t, []string{"list-unmined-records"}, ops.calls) +} + +// TestHandleTxConflicts verifies the shared replacement flow for +// one direct conflict root and its dependent descendants. +func TestHandleTxConflicts(t *testing.T) { + t.Parallel() + + rootHash := chainhash.Hash{1} + childHash := chainhash.Hash{2} + grandchildHash := chainhash.Hash{3} + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + candidates := []unminedTxRecord{{ + id: 2, + hash: childHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: rootHash, Index: 0}, + }}}, + }, { + id: 3, + hash: grandchildHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: childHash, Index: 0}, + }}}, + }} + + ops := &stubCreateTxOps{ + conflictIDs: []int64{1}, + conflictHashes: []chainhash.Hash{rootHash}, + unminedTxns: candidates, + } + + err = handleTxConflicts(t.Context(), req, 9, ops) + require.NoError(t, err) + require.Equal(t, []string{ + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + "clear-spent-utxos", + "clear-spent-utxos", + "mark-txns-failed", + }, ops.calls) + require.Equal(t, []int64{1, 2, 3}, ops.clearedTxIDs) + require.Equal(t, []int64{1}, ops.replacedTxIDs) + require.Equal(t, []int64{2, 3}, ops.failedTxIDs) + require.Equal(t, []int64{1}, ops.replacementEdgeTxIDs) + require.Equal(t, int64(9), ops.replacedConflictTxID) +} + +// TestHandleTxConflictsKeepsDirectRootsReplaced verifies that a +// direct conflict root stays replaced even when it also spends another direct +// root and would otherwise appear in the descendant walk. +func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { + t.Parallel() + + rootAHash := chainhash.Hash{1} + rootBHash := chainhash.Hash{2} + childHash := chainhash.Hash{3} + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{ + conflictIDs: []int64{1, 2}, + conflictHashes: []chainhash.Hash{rootAHash, rootBHash}, + unminedTxns: []unminedTxRecord{{ + id: 2, + hash: rootBHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: rootAHash, Index: 0}, + }}}, + }, { + id: 3, + hash: childHash, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{Hash: rootBHash, Index: 0}, + }}}, + }}, + } + + err = handleTxConflicts(t.Context(), req, 9, ops) + require.NoError(t, err) + require.Equal(t, []string{ + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + "clear-spent-utxos", + "mark-txns-failed", + }, ops.calls) + require.Equal(t, []int64{1, 2, 3}, ops.clearedTxIDs) + require.Equal(t, []int64{1, 2}, ops.replacedTxIDs) + require.Equal(t, []int64{3}, ops.failedTxIDs) + require.Equal(t, []int64{1, 2}, ops.replacementEdgeTxIDs) +} + +// TestHandleTxConflictsNoDescendants verifies that the helper +// skips the descendant-failure batch when no dependent branch exists. +func TestHandleTxConflictsNoDescendants(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{ + conflictIDs: []int64{1}, + conflictHashes: []chainhash.Hash{{1}}, + } + + err = handleTxConflicts(t.Context(), req, 9, ops) + require.NoError(t, err) + require.Equal(t, []string{ + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + }, ops.calls) + require.Equal(t, []int64{1}, ops.clearedTxIDs) + require.Equal(t, []int64{1}, ops.replacedTxIDs) + require.Empty(t, ops.failedTxIDs) + require.Equal(t, []int64{1}, ops.replacementEdgeTxIDs) + require.Equal(t, int64(9), ops.replacedConflictTxID) +} + +// TestHandleTxConflictsMarkFailedError verifies that the helper records +// replacement edges before returning descendant failure errors. +func TestHandleTxConflictsMarkFailedError(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &stubCreateTxOps{ + conflictIDs: []int64{1}, + conflictHashes: []chainhash.Hash{{1}}, + unminedTxns: []unminedTxRecord{{ + id: 2, + hash: chainhash.Hash{2}, + tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + }}}, + }}, + markFailedErr: errConflictMarkFailed, + } + + err = handleTxConflicts(t.Context(), req, 9, ops) + require.ErrorIs(t, err, errConflictMarkFailed) + require.ErrorContains(t, err, "mark conflict descendants failed") + require.Equal(t, []string{ + "list-conflicts", + "list-unmined-records", + "clear-spent-utxos", + "mark-txns-replaced", + "insert-replacement-edges", + "clear-spent-utxos", + "mark-txns-failed", + }, ops.calls) +} + // TestUpdateTxWithOpsLabelAndState verifies that the shared UpdateTx workflow // can apply both a label patch and a state patch in one atomic sequence. func TestUpdateTxWithOpsLabelAndState(t *testing.T) { From 72f4cd5f9983423898a9f6e35059d4265c029fcc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 20:50:24 +0800 Subject: [PATCH 511/691] wallet: add CreateTx conflict itests Add integration coverage for the CreateTx conflict and reconfirmation paths so both SQL backends exercise the shared replacement flow against real database state. --- .../internal/db/itest/tx_utxo_store_test.go | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index 5195bba11a..ae64bc07b3 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -301,6 +301,207 @@ func TestCreateTxRejectsDuplicateTx(t *testing.T) { require.True(t, ok) } +// TestCreateTxConfirmsExistingUnminedRow verifies that CreateTx reuses one live +// unmined row when the same transaction later arrives with confirming block +// metadata. +// +// Scenario: +// - One wallet already stores the transaction in its unmined history. +// +// Setup: +// - Create one wallet-owned credited transaction in pending state. +// - Create one matching confirming block fixture for the same transaction +// hash. +// +// Action: +// - Call CreateTx again with the same transaction hash and confirming block. +// +// Assertions: +// - The existing row remains stored once. +// - The transaction becomes confirmed and published. +// - The existing label remains unchanged. +func TestCreateTxConfirmsExistingUnminedRow(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-confirm-existing-unmined") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 250) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 7000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000500, 0), + Status: db.TxStatusPending, + Label: "seed", + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000600, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Label: "ignored", + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }) + require.NoError(t, err) + require.NotNil(t, info.Block) + require.Equal(t, confirmedBlock.Height, info.Block.Height) + require.Equal(t, db.TxStatusPublished, info.Status) + require.Equal(t, "seed", info.Label) + + unminedTxs, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }) + require.NoError(t, err) + require.Empty(t, unminedTxs) + + confirmedTxs, err := store.ListTxns(t.Context(), db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: confirmedBlock.Height, + EndHeight: confirmedBlock.Height, + }) + require.NoError(t, err) + require.Len(t, confirmedTxs, 1) + require.Equal(t, tx.TxHash(), confirmedTxs[0].Hash) +} + +// TestCreateTxConfirmedWinnerInvalidatesConflictBranch verifies that a newly +// confirmed transaction invalidates the conflicting unmined branch before it +// claims the shared wallet-owned input. +// +// Scenario: +// - One wallet-owned output already has one unmined spend chain. +// - A confirmed conflicting transaction later arrives for the same outpoint. +// +// Setup: +// - Create one wallet-owned parent credit. +// - Insert one unmined child and one unmined grandchild depending on it. +// - Build one confirmed conflicting transaction spending the same parent +// outpoint. +// +// Action: +// - Insert the confirmed conflicting transaction through CreateTx. +// +// Assertions: +// - The direct conflicting root becomes replaced. +// - The descendant row becomes failed. +// - The confirmed winner is stored. +// - The parent outpoint remains spent instead of returning to the UTXO set. +func TestCreateTxConfirmedWinnerInvalidatesConflictBranch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-confirmed-conflict-winner") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001500, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + spentOutPoint := wire.OutPoint{Hash: parentTx.TxHash(), Index: 0} + firstChild := newRegularTx( + []wire.OutPoint{spentOutPoint}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: firstChild, + Received: time.Unix(1710001510, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + grandchild := newRegularTx( + []wire.OutPoint{{Hash: firstChild.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 3000, PkScript: []byte{0x52}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: grandchild, + Received: time.Unix(1710001520, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + confirmedBlock := CreateBlockFixture(t, store.Queries(), 260) + confirmedWinner := newRegularTx( + []wire.OutPoint{spentOutPoint}, + []*wire.TxOut{{Value: 3500, PkScript: []byte{0x53}}}, + ) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedWinner, + Received: time.Unix(1710001530, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }) + require.NoError(t, err) + + childInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: firstChild.TxHash(), + }) + require.NoError(t, err) + require.Nil(t, childInfo.Block) + require.Equal(t, db.TxStatusReplaced, childInfo.Status) + + grandchildInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: grandchild.TxHash(), + }) + require.NoError(t, err) + require.Nil(t, grandchildInfo.Block) + require.Equal(t, db.TxStatusFailed, grandchildInfo.Status) + + winnerInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: confirmedWinner.TxHash(), + }) + require.NoError(t, err) + require.NotNil(t, winnerInfo.Block) + require.Equal(t, db.TxStatusPublished, winnerInfo.Status) + + _, err = store.GetUtxo(t.Context(), db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: spentOutPoint, + }) + require.ErrorIs(t, err, db.ErrUtxoNotFound) +} + // TestGetTxReturnsStoredPendingTx verifies that GetTx rebuilds the public // transaction view for one stored unmined row. // From 58d6cd3aef9cf940fc1595e77d68eca090a37a40 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 23:19:17 +0800 Subject: [PATCH 512/691] wallet: integrate orphan handling into RollbackToBlock --- .../internal/db/itest/tx_utxo_store_test.go | 87 +++++++++++++++++++ .../internal/db/postgres_txstore_rollback.go | 30 +++++++ wallet/internal/db/sqlite_txstore_rollback.go | 31 +++++++ wallet/internal/db/tx_store_common.go | 32 ++++++- 4 files changed, 179 insertions(+), 1 deletion(-) diff --git a/wallet/internal/db/itest/tx_utxo_store_test.go b/wallet/internal/db/itest/tx_utxo_store_test.go index ae64bc07b3..85968760fc 100644 --- a/wallet/internal/db/itest/tx_utxo_store_test.go +++ b/wallet/internal/db/itest/tx_utxo_store_test.go @@ -1279,6 +1279,7 @@ func TestDeleteTxRemovesParentWithFailedChild(t *testing.T) { // - Roll back the block that confirmed the coinbase root. // // Assertions: +// - The disconnected coinbase root becomes orphaned. // - Both unmined descendants become failed. // - The spend edges from the coinbase root and child are cleared. func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { @@ -1337,6 +1338,14 @@ func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { err = store.RollbackToBlock(t.Context(), block.Height) require.NoError(t, err) + coinbaseInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: coinbaseTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusOrphaned, coinbaseInfo.Status) + require.Nil(t, coinbaseInfo.Block) + childInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ WalletID: walletID, Txid: childTx.TxHash(), @@ -1357,6 +1366,84 @@ func TestRollbackToBlockFailsCoinbaseDescendants(t *testing.T) { require.Empty(t, childSpendingTxIDs(t, store, walletID, childTx.TxHash())) } +// TestCreateTxReconfirmsOrphanedCoinbase verifies that CreateTx can restore an +// orphaned coinbase row to confirmed history when the same coinbase hash later +// re-enters the best chain. +// +// Scenario: +// - One wallet already stores one orphaned coinbase transaction after +// rollback. +// +// Setup: +// - Create and confirm one wallet-owned coinbase transaction. +// - Roll back the confirming block so the coinbase becomes orphaned. +// - Create one new confirming block for the same tx hash. +// +// Action: +// - Call CreateTx again with the same coinbase transaction and new block. +// +// Assertions: +// - The existing row becomes confirmed and published again. +// - The wallet-owned coinbase output returns to the current UTXO set. +func TestCreateTxReconfirmsOrphanedCoinbase(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-reconfirm-orphaned-coinbase") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + block := CreateBlockFixture(t, queries, 310) + coinbaseTx := newCoinbaseTx(addr.ScriptPubKey) + + err := store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710001540, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + err = store.RollbackToBlock(t.Context(), block.Height) + require.NoError(t, err) + + orphanInfo, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: coinbaseTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusOrphaned, orphanInfo.Status) + require.Nil(t, orphanInfo.Block) + + newBlock := CreateBlockFixture(t, queries, 311) + err = store.CreateTx(t.Context(), db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710001550, 0), + Block: &newBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + info, err := store.GetTx(t.Context(), db.GetTxQuery{ + WalletID: walletID, + Txid: coinbaseTx.TxHash(), + }) + require.NoError(t, err) + require.Equal(t, db.TxStatusPublished, info.Status) + require.NotNil(t, info.Block) + require.Equal(t, newBlock.Height, info.Block.Height) + require.True(t, walletUtxoExists(t, store, walletID, wire.OutPoint{ + Hash: coinbaseTx.TxHash(), Index: 0, + })) +} + // TestGetUtxoReturnsCurrentWalletOutput verifies that GetUtxo returns a stored // wallet-owned output created by an unmined transaction. func TestGetUtxoReturnsCurrentWalletOutput(t *testing.T) { diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/postgres_txstore_rollback.go index 5f5c6de7e4..5c5576c2b5 100644 --- a/wallet/internal/db/postgres_txstore_rollback.go +++ b/wallet/internal/db/postgres_txstore_rollback.go @@ -104,6 +104,36 @@ func (o pgRollbackToBlockOps) deleteBlocksAtOrAboveHeight( return nil } +// markTxRootsOrphaned rewrites each disconnected coinbase root to the +// orphaned state once its confirming block has been deleted. +func (o pgRollbackToBlockOps) markTxRootsOrphaned(ctx context.Context, + walletID uint32, rootHashes []chainhash.Hash) error { + + for _, txHash := range rootHashes { + // Rollback already removed the confirming block rows. + // The remaining coinbase row must therefore clear its + // block reference and become orphaned in the same + // row-local state patch. + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, sqlcpg.UpdateTransactionStateByHashParams{ + BlockHeight: sql.NullInt32{}, + Status: int16(TxStatusOrphaned), + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update rollback coinbase state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + } + + return nil +} + // listUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. func (o pgRollbackToBlockOps) listUnminedTxRecords( diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite_txstore_rollback.go index bcbc0f0c68..81230b7d60 100644 --- a/wallet/internal/db/sqlite_txstore_rollback.go +++ b/wallet/internal/db/sqlite_txstore_rollback.go @@ -78,6 +78,37 @@ func (o sqliteRollbackToBlockOps) deleteBlocksAtOrAboveHeight( return nil } +// markTxRootsOrphaned rewrites each disconnected coinbase root to the +// orphaned state once its confirming block has been deleted. +func (o sqliteRollbackToBlockOps) markTxRootsOrphaned( + ctx context.Context, walletID uint32, + rootHashes []chainhash.Hash) error { + + for _, txHash := range rootHashes { + // Rollback already removed the confirming block rows. + // The remaining coinbase row must therefore clear its + // block reference and become orphaned in the same + // row-local state patch. + rows, err := o.qtx.UpdateTransactionStateByHash( + ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ + BlockHeight: sql.NullInt64{}, + Status: int64(TxStatusOrphaned), + WalletID: int64(walletID), + TxHash: txHash[:], + }, + ) + if err != nil { + return fmt.Errorf("update rollback coinbase state query: %w", err) + } + + if rows == 0 { + return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + } + } + + return nil +} + // listUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. func (o sqliteRollbackToBlockOps) listUnminedTxRecords( diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index d28e6e50bb..ea45e64947 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -362,7 +362,10 @@ func checkReuseCreateTx(req createTxRequest, // Coinbase rows only reuse the orphaned state. That path restores the same // coinbase hash after rollback disconnected its previous confirming block. if existing.isCoinbase { - return false + // Both sides must still be coinbase history, and the existing + // row must be the rollback-created orphan that is waiting for a + // confirming block again. + return req.isCoinbase && existing.status == TxStatusOrphaned } // Non-coinbase rows only reuse the current unmined states. @@ -856,6 +859,11 @@ type rollbackToBlockOps interface { // rollback boundary after sync-state references have been rewound. deleteBlocksAtOrAboveHeight(ctx context.Context, height uint32) error + // markTxRootsOrphaned rewrites the disconnected coinbase roots to the + // orphaned state after their confirming blocks are deleted. + markTxRootsOrphaned(ctx context.Context, walletID uint32, + rootHashes []chainhash.Hash) error + // listUnminedTxRecords loads the wallet's current unmined transaction // rows in the normalized shape the descendant walk expects. listUnminedTxRecords(ctx context.Context, @@ -1017,6 +1025,23 @@ func invalidateRollbackDescendants(ctx context.Context, return nil } +// markTxRootsOrphaned rewrites every disconnected coinbase root to the +// orphaned state before descendant invalidation completes. +func markTxRootsOrphaned(ctx context.Context, + rootHashesByWallet map[uint32][]chainhash.Hash, + ops rollbackToBlockOps) error { + + for walletID, rootHashes := range rootHashesByWallet { + err := ops.markTxRootsOrphaned(ctx, walletID, rootHashes) + if err != nil { + return fmt.Errorf("mark rollback coinbase roots orphaned for "+ + "wallet %d: %w", walletID, err) + } + } + + return nil +} + // rollbackToBlockWithOps runs the shared RollbackToBlock sequence inside one // backend-specific SQL transaction. // @@ -1041,6 +1066,11 @@ func rollbackToBlockWithOps(ctx context.Context, height uint32, return fmt.Errorf("delete blocks at or above height: %w", err) } + err = markTxRootsOrphaned(ctx, rootHashesByWallet, ops) + if err != nil { + return err + } + err = invalidateRollbackDescendants(ctx, rootHashesByWallet, ops) if err != nil { return err From d7278d5edc5d0c2c97ef4885c2f90659371b413a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 27 Mar 2026 23:23:12 +0800 Subject: [PATCH 513/691] docs: add tx invalidation flow guide --- docs/developer/README.md | 11 +- docs/developer/adr/0006-wtxmgr-sql-schema.md | 2 +- docs/developer/tx_invalidation_flows.md | 164 +++++++++++++++++++ docs/developer/utxo_data_model.md | 2 + 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 docs/developer/tx_invalidation_flows.md diff --git a/docs/developer/README.md b/docs/developer/README.md index 38ed43f839..4551496f42 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -28,6 +28,15 @@ Best practices for writing clear, effective, and maintainable unit tests. --- +## 🧾 Transaction Invalidation Flows + +A focused guide to the wallet tx-store event model for transaction invalidation, +publisher cleanup, and rollback handling. + +**[➡️ Read the Transaction Invalidation Flows Guide](./tx_invalidation_flows.md)** + +--- + ## 🏛️ Engineering Guide A deep dive into the core design philosophy, architectural patterns, and Go implementation details that guide the development of `btcwallet`. @@ -48,4 +57,4 @@ Formal documentation of significant architectural decisions, their context, and A detailed guide to creating Bitcoin transactions using the `PsbtManager` interface, covering various scenarios and best practices. -**[➡️ Read the PSBT Workflows Guide](./psbt_workflows.md)** \ No newline at end of file +**[➡️ Read the PSBT Workflows Guide](./psbt_workflows.md)** diff --git a/docs/developer/adr/0006-wtxmgr-sql-schema.md b/docs/developer/adr/0006-wtxmgr-sql-schema.md index 9fbf0c924b..f2e4516eee 100644 --- a/docs/developer/adr/0006-wtxmgr-sql-schema.md +++ b/docs/developer/adr/0006-wtxmgr-sql-schema.md @@ -48,7 +48,7 @@ Two designs were considered for tracking transaction validity: * Cons: Introduces a field that could theoretically drift from the underlying facts. This is partially mitigated by `CHECK` constraints (`check_confirmed_published`, `check_coinbase_confirmation_state`) and coinbase reorg triggers; transition correctness for `failed`/`replaced` remains a write-path responsibility validated by tests. * **Derived status from other columns (alternative):** - * At coarse granularity (pending vs active vs invalid), most states are derivable: `orphaned` = `is_coinbase AND block_height IS NULL`; `replaced` and `failed` (direct victim) = existence in `tx_replacements.replaced_tx_id`. + * At coarse granularity (pending vs active vs invalid), some states are partly derivable: `orphaned` = `is_coinbase AND block_height IS NULL`; direct `replaced` victims can be identified from `tx_replacements.replaced_tx_id`. Direct `failed` victims are not derivable from replacement edges alone because upstream invalidation intentionally records no replacement edge. * However, a full replacement requires preserving the distinct invalid states (`replaced`, `failed`, `orphaned`), which have different operational semantics: RBF (`replaced`) allows re-spending the same inputs with a new tx; `orphaned` coinbase can recover on reconfirmation; `failed` (double-spend) is permanent. Collapsing these into a single boolean loses information needed for recovery logic and user-facing audit. * A boolean decomposition (e.g., `is_broadcast` + `is_invalid`) also introduces problems: `is_broadcast` does not equal `published` — a tx can be valid/published because it was received from the network, not broadcast by this wallet; boolean pairs create ambiguous combinations (e.g., `is_broadcast=false, is_invalid=true`) requiring additional constraints. * A fully-derived alternative that preserves the same information would require at least three columns (`is_broadcast`/source marker, `invalidation_reason` enum, optional `invalidated_by_tx_id`) — strictly more schema surface than a single `tx_status` column. diff --git a/docs/developer/tx_invalidation_flows.md b/docs/developer/tx_invalidation_flows.md new file mode 100644 index 0000000000..c0efe9a015 --- /dev/null +++ b/docs/developer/tx_invalidation_flows.md @@ -0,0 +1,164 @@ +# Transaction Invalidation Flows + +This document defines how the SQL wallet store applies invalidation-related +write workflows when one wallet event changes tx history. It complements +[Wallet Data Model and Lifecycle](./utxo_data_model.md), which defines the +stored states, and +[ADR 0006: Wallet Transaction Manager SQL Schema](./adr/0006-wtxmgr-sql-schema.md), +which defines the schema and its invariants. + +It focuses on the workflow phases, the invariants each phase must preserve, +and the backend guarantees that follow from that model. + +## 1. Wallet Events and Scope + +From the wallet's point of view, a small set of events can change tx history: + +- tx ingest, e.g. when the wallet learns about a newly created or newly + confirmed tx. +- row-local metadata patching, e.g. when `UpdateTx` changes a label or + block/status fields without rewriting graph edges. +- invalidation of an unmined branch, e.g. when publisher-side cleanup fails + one local spend and its dependent descendants. +- rollback of confirmed history at a block boundary, e.g. when a reorg + disconnects blocks and rewinds formerly confirmed wallet history. + +The first two events place invalidation in the broader tx-store model. The +branch-mutating workflows described here are the ones that discover dependent +descendants, clear now-invalid spend edges, rewrite history, and commit the +whole result atomically. + +## 2. Terminology + +- **Root:** The first tx row directly affected by the wallet event. +- **Descendant:** An unmined tx that spends an output created by a root or by a + later discovered descendant. +- **Branch:** One root plus every descendant discovered from that root set. +- **Spend edge:** The wallet-owned relationship that records which tx spends a + previously created output. + +## 3. Core Invariants + +Every invalidation or rollback workflow must preserve the same wallet-visible +invariants. + +- **Atomicity:** Each wallet event executes in one database transaction. The + store must not commit a partially invalidated branch or a partially applied + rollback. +- **No dangling spend edges:** If one tx becomes invalid, the store must clear + any spend references that would otherwise keep invalid UTXO relationships + alive. +- **Retained invalid history:** Invalid, replaced, failed, or orphaned rows + remain part of the wallet's historical view. The workflow rewrites state; it + does not erase audit history. +- **Event-owned graph mutation:** Row-local patching must stay row-local. + Descendant traversal, spend-edge cleanup, replacement tracking, and rollback + orphaning belong only to the workflows that own those mutations. +- **Event-specific root states:** Different wallet events may drive the same + branch through different root-state outcomes. A direct conflict root may + become `replaced`, while a descendant invalidated by that same event becomes + `failed`. + +## 4. Workflow Phases + +When one branch-mutating event runs, the store follows the same overall phases. + +### 4.1 Discover roots + +The workflow first identifies the root rows directly affected by the wallet +event. Those roots may come from the tx being invalidated, from direct conflict +rows discovered during confirmed ingest, or from coinbase rows disconnected by +rollback. + +### 4.2 Snapshot candidate txns + +Before mutation starts, the workflow loads the current unmined tx set needed +for descendant discovery. This snapshot must happen first so later state +rewrites do not hide part of the branch that still needs cleanup. + +### 4.3 Discover descendants + +The workflow then walks the unmined candidate set to a fixed point. Each newly +discovered descendant expands the invalid parent set, which may reveal later +txns farther down the branch. + +### 4.4 Clear spend edges + +Before any affected row becomes visibly invalid, the workflow clears the +wallet-owned spend edges claimed by that root or descendant set. This prevents +the store from exposing rewritten rows that still appear to spend outputs from +an invalid branch. + +### 4.5 Rewrite roots + +After the root set is fully known and its spend edges are safe to rewrite, the +workflow applies the event-specific root outcome. The root state depends on the +wallet event, not just on the graph shape. + +### 4.6 Rewrite descendants + +Once the root outcome is fixed, the workflow rewrites dependent descendants to +their derived invalid state. Direct roots and descendants do not always land in +the same state, so they are handled as separate phases. + +### 4.7 Commit atomically + +Either every phase above commits together or none of them does. The workflow +must not leave behind half-rewritten states, partially cleared spend edges, or +only part of the affected branch updated. + +## 5. Event Outcomes + +The workflow phases stay the same across events, but the resulting root state +depends on which wallet event started the flow. + +| Wallet event | Root outcome | Descendant outcome | Example | +| --- | --- | --- | --- | +| `InvalidateUnminedTx` | `failed` | `failed` | Publisher-side cleanup rejects one local unmined branch. | +| `CreateTx` conflict handling | direct conflict roots become `replaced` | dependent descendants become `failed` | A newly confirmed winner claims wallet-owned inputs already spent by an unmined branch. | +| `RollbackToBlock` | disconnected coinbase roots become `orphaned` | dependent descendants become `failed` | A reorg disconnects the confirming block for the root branch. | + +Row-local metadata patching does not enter this branch workflow because it does +not discover descendants or rewrite spend edges. + +## 6. Worked Example + +Consider one branch with three txns: + +- `A` is the root tx. +- `B` spends an output created by `A`. +- `C` spends an output created by `B`. + +The same branch can be rewritten differently depending on the initiating event: + +- Under `InvalidateUnminedTx(A)`, `A`, `B`, and `C` all become `failed`. +- Under confirmed conflict handling against `A`, `A` becomes `replaced` while + `B` and `C` become `failed`. +- Under rollback that disconnects confirmed coinbase `A`, `A` becomes + `orphaned` while `B` and `C` become `failed`. + +In every case, descendant discovery happens before mutation and spend-edge +cleanup happens before the rewritten states become visible. + +## 7. Backend Guarantees + +Postgres and sqlite may differ internally in query bindings, row types, and +helper structure. They must still preserve the same workflow guarantees. + +- The same wallet event yields the same final tx states. +- The same descendant branch is invalidated for the same root set. +- Spend-edge cleanup happens before invalid state becomes visible. +- The whole event remains all-or-nothing at the SQL transaction boundary. + +These guarantees keep the SQL stores aligned with the legacy `kvdb` backend at +the event level, even when the SQL stores retain richer explicit invalid- +history states internally. + +## 8. Relationship to Other Docs + +- [`wallet/internal/db/interface.go`](../../wallet/internal/db/interface.go) + describes the caller-facing `TxStore` contract. +- [Wallet Data Model and Lifecycle](./utxo_data_model.md) explains the + persisted states and the "retain history" policy. +- [ADR 0006: Wallet Transaction Manager SQL Schema](./adr/0006-wtxmgr-sql-schema.md) + defines the schema-level invariants that these workflows must preserve. diff --git a/docs/developer/utxo_data_model.md b/docs/developer/utxo_data_model.md index 5e99026ef2..19c43d90fa 100644 --- a/docs/developer/utxo_data_model.md +++ b/docs/developer/utxo_data_model.md @@ -2,6 +2,8 @@ This document defines the core data model, state machines, and design philosophy of the `btcwallet` transaction subsystem. It serves as the definitive reference for understanding how the wallet manages money, history, and state. +For the exact write-path rules that apply transaction ingress, invalidation, and rollback to the stored graph, see [Transaction Invalidation Flows](./tx_invalidation_flows.md). + ## 1. Design Philosophy The `btcwallet` architecture is built upon a specific worldview: **The wallet is a UTXO Manager.** From 32555cfd966a04bb1cc58aac96cdcd173c6776ab Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 11:52:23 +0800 Subject: [PATCH 514/691] wallet: rename `transaction` to `tx` --- wallet/internal/db/interface.go | 4 ++-- wallet/internal/db/tx_store_common.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index c4463d2608..017b49d02a 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -76,11 +76,11 @@ var ( // ErrTxNotFound is returned when a transaction is not found in the // database. - ErrTxNotFound = errors.New("transaction not found") + ErrTxNotFound = errors.New("tx not found") // ErrTxAlreadyExists is returned when CreateTx is asked to insert a // wallet-scoped transaction hash that already exists. - ErrTxAlreadyExists = errors.New("transaction already exists") + ErrTxAlreadyExists = errors.New("tx already exists") // ErrBlockNotFound is returned when a transaction operation references a // block height that does not exist in the shared blocks table. diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index ea45e64947..dc9c0b3b52 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -18,7 +18,7 @@ var ( // ErrInvalidStatus is returned when a transaction status is unknown or not // allowed for the requested operation. - ErrInvalidStatus = errors.New("invalid transaction status") + ErrInvalidStatus = errors.New("invalid tx status") // ErrIndexOutOfRange is returned when a referenced transaction input or // output index does not exist. @@ -36,7 +36,7 @@ var ( // ErrDeleteRequiresLeaf indicates that DeleteTx only accepts unmined // transactions with no child spenders. - ErrDeleteRequiresLeaf = errors.New("delete requires a leaf transaction") + ErrDeleteRequiresLeaf = errors.New("delete requires a leaf tx") ) // serializeMsgTx serializes a wire.MsgTx so it can be stored in the From b24e1f56bc5ad03a3d5bbb32b49855b78e19f28e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 11:53:45 +0800 Subject: [PATCH 515/691] wallet: keep UpdateTx row-local Reject failed, replaced, and orphaned state patches through UpdateTx so callers cannot invalidate a tx branch without reconciling dependent state. This keeps UpdateTx focused on row-local metadata changes and makes the event-shaped APIs responsible for graph-affecting lifecycle updates. --- wallet/internal/db/interface.go | 6 +- wallet/internal/db/tx_store_common.go | 23 ++++---- wallet/internal/db/tx_store_common_test.go | 64 ++++++++++++++++++++++ 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 017b49d02a..4be4dfd46d 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -299,8 +299,10 @@ type TxStore interface { // immutable transaction facts such as the serialized transaction bytes, // created credits, or spent-input edges. // - // UpdateTx is the only public tx-store API that may attach, replace, or - // clear confirming block metadata. + // UpdateTx is row-local only. It may attach, replace, or clear confirming + // block metadata for one tx, but it must not perform branch invalidation. + // Callers must use CreateTx, InvalidateUnminedTx, or RollbackToBlock for + // graph-affecting lifecycle changes. UpdateTx(ctx context.Context, params UpdateTxParams) error // GetTx retrieves a transaction record by its hash. It takes a context diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index dc9c0b3b52..90fc273cca 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -636,11 +636,14 @@ func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { ErrInvalidParam, state.Status, ErrInvalidStatus) } - // Only disconnected coinbase rows become orphaned. Ordinary - // transactions use the replaced/failed states instead, so UpdateTx - // must reject orphaned transitions for non-coinbase history. - if !isCoinbase && state.Status == TxStatusOrphaned { - return fmt.Errorf("%w: non-coinbase txns cannot be orphaned: %w", + // UpdateTx is row-local only. Any invalidating or orphaning transition must + // flow through the event-shaped APIs that also reconcile dependent branch + // state. + if state.Status == TxStatusFailed || + state.Status == TxStatusReplaced || + state.Status == TxStatusOrphaned { + + return fmt.Errorf("%w: UpdateTx cannot invalidate txns: %w", ErrInvalidParam, ErrInvalidStatus) } @@ -651,11 +654,11 @@ func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { ErrInvalidParam, ErrInvalidStatus) } - // A unmined coinbase row only appears after rollback disconnects its block, - // at which point the row must be marked orphaned rather than treated as an - // active unmined transaction. - if isCoinbase && state.Block == nil && state.Status != TxStatusOrphaned { - return fmt.Errorf("%w: unmined coinbase txns must be orphaned: %w", + // Coinbase state transitions are event-shaped only. CreateTx records the + // mined fact, while RollbackToBlock clears the block reference and rewrites + // the row orphaned. UpdateTx therefore never patches coinbase state. + if isCoinbase { + return fmt.Errorf("%w: UpdateTx cannot patch coinbase tx state: %w", ErrInvalidParam, ErrInvalidStatus) } diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index c425cb21f9..9ea245ed8a 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -916,6 +916,70 @@ func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { require.Equal(t, []string{"load"}, ops.calls) } +// TestUpdateTxWithOpsRejectsInvalidatingStates verifies that UpdateTx rejects +// branch-affecting state transitions before any backend write begins. +func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + isCoinbase bool + state UpdateTxState + }{ + { + name: "failed rejected", + state: UpdateTxState{ + Status: TxStatusFailed, + }, + }, + { + name: "replaced rejected", + state: UpdateTxState{ + Status: TxStatusReplaced, + }, + }, + { + name: "orphaned rejected", + state: UpdateTxState{ + Status: TxStatusOrphaned, + }, + }, + { + name: "coinbase orphaned rejected", + isCoinbase: true, + state: UpdateTxState{ + Status: TxStatusOrphaned, + }, + }, + { + name: "coinbase confirmed patch rejected", + isCoinbase: true, + state: UpdateTxState{ + Status: TxStatusPublished, + Block: testBlock(55), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + params := UpdateTxParams{ + WalletID: 5, + Txid: chainhash.Hash{1}, + State: &test.state, + } + ops := &stubUpdateTxOps{isCoinbase: test.isCoinbase} + + err := updateTxWithOps(context.Background(), params, ops) + require.ErrorIs(t, err, ErrInvalidParam) + require.ErrorIs(t, err, ErrInvalidStatus) + require.Equal(t, []string{"load"}, ops.calls) + }) + } +} + // stubUpdateTxOps records how the shared UpdateTx helper drives one backend // adapter while letting tests control the loaded metadata. type stubUpdateTxOps struct { From 13e70884c6429f341bd5af8948e3a9da3d2aad0e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 8 Apr 2026 02:52:44 +0800 Subject: [PATCH 516/691] wallet: use mock.Mock in utxo helper tests Replace the shared LeaseOutput and ReleaseOutput test stubs with mock-based adapters so the helper tests can assert the fallback paths through explicit expectations. This also drops redundant local call-tracking now that the mock expectations already prove the helper call order. --- wallet/internal/db/mock_test.go | 13 ++ wallet/internal/db/utxo_store_common_test.go | 215 +++++++++++-------- 2 files changed, 137 insertions(+), 91 deletions(-) create mode 100644 wallet/internal/db/mock_test.go diff --git a/wallet/internal/db/mock_test.go b/wallet/internal/db/mock_test.go new file mode 100644 index 0000000000..9370a9a464 --- /dev/null +++ b/wallet/internal/db/mock_test.go @@ -0,0 +1,13 @@ +package db + +import ( + "errors" + "fmt" +) + +var errMockType = errors.New("mock arg type") + +// mockTypeError wraps the shared mock type sentinel with call-site context. +func mockTypeError(detail string) error { + return fmt.Errorf("%w: %s", errMockType, detail) +} diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 700319ef49..220bcf6c03 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -196,9 +197,13 @@ func TestLeaseOutputWithOps(t *testing.T) { ID: LockID{7}, Duration: time.Minute, } - ops := &stubLeaseOutputOps{ - acquireExpiration: time.Unix(333, 0).In(time.FixedZone("X", 3600)), - } + acquireExpiration := time.Unix(333, 0).In(time.FixedZone("X", 3600)) + ops := &mockLeaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + nowMatcher := mock.AnythingOfType("time.Time") + ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + acquireExpiration, nil).Once() // Act: Run the shared LeaseOutput flow. lease, err := leaseOutputWithOps(context.Background(), params, ops) @@ -208,7 +213,6 @@ func TestLeaseOutputWithOps(t *testing.T) { require.Equal(t, params.OutPoint, lease.OutPoint) require.Equal(t, LockID(params.ID), lease.LockID) require.Equal(t, time.UTC, lease.Expiration.Location()) - require.Equal(t, []string{"acquire"}, ops.calls) } // TestLeaseOutputWithOpsRejectsNonPositiveDuration verifies that the shared @@ -222,11 +226,13 @@ func TestLeaseOutputWithOpsRejectsNonPositiveDuration(t *testing.T) { ID: LockID{7}, Duration: 0, } - ops := &stubLeaseOutputOps{} + ops := &mockLeaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) _, err := leaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) - require.Empty(t, ops.calls) + ops.AssertNotCalled(t, "acquire", mock.Anything, mock.Anything, + mock.Anything, mock.Anything) } // TestLeaseOutputWithOpsMissingUtxo verifies that the shared LeaseOutput helper @@ -240,13 +246,17 @@ func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { ID: LockID{7}, Duration: time.Minute, } - ops := &stubLeaseOutputOps{ - acquireErr: errLeaseOutputNoRow, - } + ops := &mockLeaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + nowMatcher := mock.AnythingOfType("time.Time") + ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + time.Time{}, errLeaseOutputNoRow).Once() + + ops.On("hasUtxo", mock.Anything, params).Return(false, nil).Once() _, err := leaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrUtxoNotFound) - require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) } // TestLeaseOutputWithOpsAlreadyLeased verifies that the shared LeaseOutput @@ -260,14 +270,17 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { ID: LockID{7}, Duration: time.Minute, } - ops := &stubLeaseOutputOps{ - acquireErr: errLeaseOutputNoRow, - hasUtxoResult: true, - } + ops := &mockLeaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + nowMatcher := mock.AnythingOfType("time.Time") + ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + time.Time{}, errLeaseOutputNoRow).Once() + + ops.On("hasUtxo", mock.Anything, params).Return(true, nil).Once() _, err := leaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrOutputAlreadyLeased) - require.Equal(t, []string{"acquire", "has-utxo"}, ops.calls) } // TestReleaseOutputWithOps verifies that the shared ReleaseOutput helper @@ -281,17 +294,19 @@ func TestReleaseOutputWithOps(t *testing.T) { OutPoint: testLeaseOutPoint(), ID: [32]byte{9}, } - ops := &stubReleaseOutputOps{ - lookupUtxoIDResult: 11, - releaseRows: 1, - } + ops := &mockReleaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + int64(1), nil).Once() // Act: Run the shared ReleaseOutput flow. err := releaseOutputWithOps(context.Background(), params, ops) // Assert: The helper stops after the successful delete path. require.NoError(t, err) - require.Equal(t, []string{"lookup-utxo", "release"}, ops.calls) } // TestReleaseOutputWithOpsMissingUtxo verifies that the shared ReleaseOutput @@ -304,13 +319,14 @@ func TestReleaseOutputWithOpsMissingUtxo(t *testing.T) { OutPoint: testLeaseOutPoint(), ID: [32]byte{9}, } - ops := &stubReleaseOutputOps{ - lookupUtxoIDErr: errReleaseOutputUtxoNotFound, - } + ops := &mockReleaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("lookupUtxoID", mock.Anything, params).Return( + int64(0), errReleaseOutputUtxoNotFound).Once() err := releaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrUtxoNotFound) - require.Equal(t, []string{"lookup-utxo"}, ops.calls) } // TestReleaseOutputWithOpsWrongLock verifies that the shared ReleaseOutput @@ -323,17 +339,20 @@ func TestReleaseOutputWithOpsWrongLock(t *testing.T) { OutPoint: testLeaseOutPoint(), ID: [32]byte{9}, } - ops := &stubReleaseOutputOps{ - lookupUtxoIDResult: 11, - activeLockIDResult: []byte{1, 2, 3}, - } + ops := &mockReleaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + releaseTimeMatcher := mock.AnythingOfType("time.Time") + ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + int64(0), nil).Once() + + ops.On("activeLockID", mock.Anything, uint32(5), int64(11), + releaseTimeMatcher).Return([]byte{1, 2, 3}, nil).Once() err := releaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrOutputUnlockNotAllowed) - require.Equal(t, - []string{"lookup-utxo", "release", "active-lock"}, - ops.calls, - ) } // TestReleaseOutputWithOpsMissingActiveLease verifies that the shared @@ -347,94 +366,108 @@ func TestReleaseOutputWithOpsMissingActiveLease(t *testing.T) { OutPoint: testLeaseOutPoint(), ID: [32]byte{9}, } - ops := &stubReleaseOutputOps{ - lookupUtxoIDResult: 11, - activeLockIDErr: errReleaseOutputNoActiveLease, - } + ops := &mockReleaseOutputOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + releaseTimeMatcher := mock.AnythingOfType("time.Time") + ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + int64(0), nil).Once() + + ops.On("activeLockID", mock.Anything, uint32(5), int64(11), + releaseTimeMatcher).Return(nil, errReleaseOutputNoActiveLease).Once() err := releaseOutputWithOps(context.Background(), params, ops) require.NoError(t, err) - require.Equal(t, - []string{"lookup-utxo", "release", "active-lock"}, - ops.calls, - ) } -// stubLeaseOutputOps records how the shared LeaseOutput helper drives one -// backend adapter while letting each test control the returned results. -type stubLeaseOutputOps struct { - acquireExpiration time.Time - acquireErr error - hasUtxoResult bool - - calls []string +// mockLeaseOutputOps is a mock implementation of leaseOutputOps. +type mockLeaseOutputOps struct { + mock.Mock } -var _ leaseOutputOps = (*stubLeaseOutputOps)(nil) +var _ leaseOutputOps = (*mockLeaseOutputOps)(nil) + +// acquire implements leaseOutputOps. +func (m *mockLeaseOutputOps) acquire(ctx context.Context, + params LeaseOutputParams, start time.Time, + expiration time.Time) (time.Time, error) { -// acquire records the shared write attempt and returns the test-controlled -// lease result. -func (s *stubLeaseOutputOps) acquire(_ context.Context, - _ LeaseOutputParams, _ time.Time, _ time.Time) (time.Time, error) { + args := m.Called(ctx, params, start, expiration) - s.calls = append(s.calls, "acquire") + leaseExpiration, ok := args.Get(0).(time.Time) + if !ok { + return time.Time{}, errMockType + } - return s.acquireExpiration, s.acquireErr + return leaseExpiration, args.Error(1) } -// hasUtxo records the fallback ownership lookup and returns the test-controlled -// result. -func (s *stubLeaseOutputOps) hasUtxo(_ context.Context, - _ LeaseOutputParams) (bool, error) { +// hasUtxo implements leaseOutputOps. +func (m *mockLeaseOutputOps) hasUtxo(ctx context.Context, + params LeaseOutputParams) (bool, error) { - s.calls = append(s.calls, "has-utxo") + args := m.Called(ctx, params) - return s.hasUtxoResult, nil + hasUtxo, ok := args.Get(0).(bool) + if !ok { + return false, errMockType + } + + return hasUtxo, args.Error(1) } -// stubReleaseOutputOps records how the shared ReleaseOutput helper drives one -// backend adapter while letting each test control the returned results. -type stubReleaseOutputOps struct { - lookupUtxoIDResult int64 - lookupUtxoIDErr error - releaseRows int64 - releaseErr error - activeLockIDResult []byte - activeLockIDErr error - - calls []string +// mockReleaseOutputOps is a mock implementation of releaseOutputOps. +type mockReleaseOutputOps struct { + mock.Mock } -var _ releaseOutputOps = (*stubReleaseOutputOps)(nil) +var _ releaseOutputOps = (*mockReleaseOutputOps)(nil) + +// lookupUtxoID implements releaseOutputOps. +func (m *mockReleaseOutputOps) lookupUtxoID(ctx context.Context, + params ReleaseOutputParams) (int64, error) { -// lookupUtxoID records the shared outpoint lookup and returns the test- -// controlled result. -func (s *stubReleaseOutputOps) lookupUtxoID(_ context.Context, - _ ReleaseOutputParams) (int64, error) { + args := m.Called(ctx, params) - s.calls = append(s.calls, "lookup-utxo") + utxoID, ok := args.Get(0).(int64) + if !ok { + return 0, errMockType + } - return s.lookupUtxoIDResult, s.lookupUtxoIDErr + return utxoID, args.Error(1) } -// release records the shared delete attempt and returns the test-controlled row -// count. -func (s *stubReleaseOutputOps) release(_ context.Context, _ uint32, - _ int64, _ [32]byte) (int64, error) { +// release implements releaseOutputOps. +func (m *mockReleaseOutputOps) release(ctx context.Context, walletID uint32, + utxoID int64, lockID [32]byte) (int64, error) { + + args := m.Called(ctx, walletID, utxoID, lockID) - s.calls = append(s.calls, "release") + releasedRows, ok := args.Get(0).(int64) + if !ok { + return 0, errMockType + } - return s.releaseRows, s.releaseErr + return releasedRows, args.Error(1) } -// activeLockID records the fallback active-lock lookup and returns the test- -// controlled result. -func (s *stubReleaseOutputOps) activeLockID(_ context.Context, _ uint32, - _ int64, _ time.Time) ([]byte, error) { +// activeLockID implements releaseOutputOps. +func (m *mockReleaseOutputOps) activeLockID(ctx context.Context, + walletID uint32, utxoID int64, now time.Time) ([]byte, error) { + + args := m.Called(ctx, walletID, utxoID, now) + if args.Get(0) == nil { + return nil, args.Error(1) + } - s.calls = append(s.calls, "active-lock") + lockID, ok := args.Get(0).([]byte) + if !ok { + return nil, errMockType + } - return s.activeLockIDResult, s.activeLockIDErr + return lockID, args.Error(1) } // testLeaseOutPoint builds one stable outpoint fixture for shared UTXO lease From 0df6578b461535e933609a4404227b533f0300bd Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 8 Apr 2026 02:06:05 +0800 Subject: [PATCH 517/691] wallet: use mock.Mock in tx store helper tests Replace the shared CreateTx and UpdateTx helper adapters with mock-based test doubles so expectations live directly in the helper setup instead of custom call-recording structs. This keeps the shared tx-store helper tests aligned with the existing invalidation mock style before later coverage commits add more cases. --- wallet/internal/db/tx_store_common_test.go | 674 ++++++++++++--------- 1 file changed, 380 insertions(+), 294 deletions(-) diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 9ea245ed8a..2236e1744a 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -319,28 +320,55 @@ func TestNewCreateTxRequest(t *testing.T) { func TestCreateTxWithOpsInsert(t *testing.T) { t.Parallel() - // Arrange: Build one prepared CreateTx request and one stub adapter. + // Arrange: Build one prepared CreateTx request and one mock adapter. req := testCreateTxRequest(t) - ops := &stubCreateTxOps{insertTxID: 11} + + var ( + insertReq createTxRequest + creditsID int64 + inputsID int64 + ) + + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadExisting", mock.Anything, req).Return( + nil, errCreateTxExistingNotFound).Once() + + ops.On("prepareBlock", mock.Anything, req).Return(nil).Once() + + ops.On("insert", mock.Anything, req).Return(int64(11), nil).Run( + func(args mock.Arguments) { + reqArg, ok := args.Get(1).(createTxRequest) + require.True(t, ok) + insertReq = reqArg + }, + ).Once() + + ops.On("insertCredits", mock.Anything, req, int64(11)).Return(nil).Run( + func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + creditsID = txID + }, + ).Once() + + ops.On("markInputsSpent", mock.Anything, req, int64(11)).Return(nil).Run( + func(args mock.Arguments) { + txID, ok := args.Get(2).(int64) + require.True(t, ok) + inputsID = txID + }, + ).Once() // Act: Run createTxWithOps. err := createTxWithOps(context.Background(), req, ops) require.NoError(t, err) - // Assert: The shared flow executes the expected write sequence. - require.Equal(t, - []string{ - "load-existing", - "prepare-block", - "insert", - "credits", - "inputs", - }, - ops.calls, - ) - require.Equal(t, int64(11), ops.creditsTxID) - require.Equal(t, int64(11), ops.inputsTxID) - require.Equal(t, req.txHash, ops.insertReq.txHash) + // Assert: The shared flow uses the inserted tx ID consistently. + require.Equal(t, int64(11), creditsID) + require.Equal(t, int64(11), inputsID) + require.Equal(t, req.txHash, insertReq.txHash) } // TestCreateTxWithOpsDuplicate verifies that the shared CreateTx helper maps an @@ -349,11 +377,14 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { t.Parallel() req := testCreateTxRequest(t) - ops := &stubCreateTxOps{existing: &createTxExistingTarget{id: 4}} + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadExisting", mock.Anything, req).Return( + &createTxExistingTarget{id: 4}, nil).Once() err := createTxWithOps(context.Background(), req, ops) require.ErrorIs(t, err, ErrTxAlreadyExists) - require.Equal(t, []string{"load-existing"}, ops.calls) } // TestCreateTxWithOpsConfirmExisting verifies that the shared CreateTx flow can @@ -372,15 +403,20 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{existing: &createTxExistingTarget{ + existing := createTxExistingTarget{ id: 7, status: TxStatusPending, hasBlock: false, - }} + } + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadExisting", mock.Anything, req).Return(&existing, nil).Once() + + ops.On("confirmExisting", mock.Anything, req, existing).Return(nil).Once() err = createTxWithOps(context.Background(), req, ops) require.NoError(t, err) - require.Equal(t, []string{"load-existing", "confirm-existing"}, ops.calls) } // TestCreateTxWithOpsReplaceConflicts verifies that the shared CreateTx flow @@ -399,27 +435,40 @@ func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{ - insertTxID: 11, - conflictIDs: []int64{5}, - conflictHashes: []chainhash.Hash{{9}}, - } + rootIDs := []int64{5} + rootHashes := []chainhash.Hash{{9}} + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadExisting", mock.Anything, req).Return( + nil, errCreateTxExistingNotFound).Once() + + ops.On("prepareBlock", mock.Anything, req).Return(nil).Once() + + ops.On("insert", mock.Anything, req).Return(int64(11), nil).Once() + + ops.On("listConflictTxns", mock.Anything, req).Return( + rootIDs, rootHashes, nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(5)).Return( + []unminedTxRecord(nil), nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(5), int64(5)).Return(nil). + Once() + + ops.On("markTxnsReplaced", mock.Anything, int64(5), []int64{5}).Return(nil). + Once() + + ops.On("insertReplacementEdges", mock.Anything, int64(5), []int64{5}, + int64(11)).Return(nil).Once() + + ops.On("insertCredits", mock.Anything, req, int64(11)).Return(nil).Once() + + ops.On("markInputsSpent", mock.Anything, req, int64(11)).Return(nil).Once() err = createTxWithOps(context.Background(), req, ops) require.NoError(t, err) - require.Equal(t, []string{ - "load-existing", - "prepare-block", - "insert", - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - "credits", - "inputs", - }, ops.calls) - require.Equal(t, int64(11), ops.replacedConflictTxID) } // testBlock builds a simple block fixture for CreateTx validation tests. @@ -452,183 +501,221 @@ func testCoinbaseMsgTx() *wire.MsgTx { return tx } -// stubCreateTxOps records how the shared CreateTx helper drives one backend -// adapter while letting each test control the returned transaction IDs. -type stubCreateTxOps struct { - existing *createTxExistingTarget - insertTxID int64 - conflictIDs []int64 - conflictHashes []chainhash.Hash - unminedTxns []unminedTxRecord - - listConflictsErr error - listUnminedErr error - clearSpentErr error - markFailedErr error - - calls []string - insertReq createTxRequest - creditsTxID int64 - inputsTxID int64 - clearedTxIDs []int64 - replacedTxIDs []int64 - failedTxIDs []int64 - replacementEdgeTxIDs []int64 - replacedConflictTxID int64 +// mockCreateTxOps is a mock implementation of createTxOps. +type mockCreateTxOps struct { + mock.Mock } -var _ createTxOps = (*stubCreateTxOps)(nil) +var _ createTxOps = (*mockCreateTxOps)(nil) + +// loadExisting implements createTxOps. +func (m *mockCreateTxOps) loadExisting(ctx context.Context, + req createTxRequest) (*createTxExistingTarget, error) { -// loadExisting records that the shared flow checked whether the tx hash already -// exists and returns the test-controlled result. -func (s *stubCreateTxOps) loadExisting(_ context.Context, - _ createTxRequest) (*createTxExistingTarget, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } - s.calls = append(s.calls, "load-existing") + existing, ok := args.Get(0).(*createTxExistingTarget) + if !ok { + return nil, mockTypeError("loadExisting result") + } - return s.existing, nil + return existing, args.Error(1) } -// confirmExisting records that the shared flow promoted one existing row. -func (s *stubCreateTxOps) confirmExisting(_ context.Context, - _ createTxRequest, - _ createTxExistingTarget) error { +// confirmExisting implements createTxOps. +func (m *mockCreateTxOps) confirmExisting(ctx context.Context, + req createTxRequest, existing createTxExistingTarget) error { - s.calls = append(s.calls, "confirm-existing") + args := m.Called(ctx, req, existing) - return nil + return args.Error(0) } -// prepareBlock records that the shared flow validated any optional block -// assignment before insert. -func (s *stubCreateTxOps) prepareBlock(_ context.Context, - _ createTxRequest) error { +// prepareBlock implements createTxOps. +func (m *mockCreateTxOps) prepareBlock(ctx context.Context, + req createTxRequest) error { - s.calls = append(s.calls, "prepare-block") + args := m.Called(ctx, req) - return nil + return args.Error(0) } -// listConflictTxns records that the shared flow checked for direct wallet-owned -// conflicts after insert and before input claims. -func (s *stubCreateTxOps) listConflictTxns(_ context.Context, - _ createTxRequest) ([]int64, []chainhash.Hash, error) { +// listConflictTxns implements createTxOps. +func (m *mockCreateTxOps) listConflictTxns(ctx context.Context, + req createTxRequest) ([]int64, []chainhash.Hash, error) { - s.calls = append(s.calls, "list-conflicts") + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, nil, args.Error(2) + } + + txIDs, ok := args.Get(0).([]int64) + if !ok { + return nil, nil, mockTypeError("listConflictTxns ids") + } - if s.listConflictsErr != nil { - return nil, nil, s.listConflictsErr + hashes, ok := args.Get(1).([]chainhash.Hash) + if !ok { + return nil, nil, mockTypeError("listConflictTxns hashes") } - return s.conflictIDs, s.conflictHashes, nil + return txIDs, hashes, args.Error(2) } -// loadInvalidateTarget satisfies the embedded invalidateUnminedTxOps interface -// for the shared CreateTx orchestration tests. -func (s *stubCreateTxOps) loadInvalidateTarget(_ context.Context, _ uint32, - _ chainhash.Hash) (invalidateUnminedTxTarget, error) { +// loadInvalidateTarget implements invalidateUnminedTxOps. +func (m *mockCreateTxOps) loadInvalidateTarget(ctx context.Context, + walletID uint32, + txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { - return invalidateUnminedTxTarget{}, nil + var zeroTarget invalidateUnminedTxTarget + + args := m.Called(ctx, walletID, txHash) + if args.Get(0) == nil { + return zeroTarget, args.Error(1) + } + + target, ok := args.Get(0).(invalidateUnminedTxTarget) + if !ok { + return zeroTarget, mockTypeError("loadInvalidateTarget result") + } + + return target, args.Error(1) } -// listUnminedTxRecords satisfies the embedded invalidateUnminedTxOps interface -// for the shared CreateTx orchestration tests. -func (s *stubCreateTxOps) listUnminedTxRecords(_ context.Context, - _ int64) ([]unminedTxRecord, error) { +// listUnminedTxRecords implements invalidateUnminedTxOps. +func (m *mockCreateTxOps) listUnminedTxRecords(ctx context.Context, + walletID int64) ([]unminedTxRecord, error) { - s.calls = append(s.calls, "list-unmined-records") + args := m.Called(ctx, walletID) + if args.Get(0) == nil { + return nil, args.Error(1) + } - if s.listUnminedErr != nil { - return nil, s.listUnminedErr + records, ok := args.Get(0).([]unminedTxRecord) + if !ok { + return nil, mockTypeError("listUnminedTxRecords result") } - return s.unminedTxns, nil + return records, args.Error(1) } -// clearSpentUtxos satisfies the embedded invalidateUnminedTxOps interface for -// the shared CreateTx orchestration tests. -func (s *stubCreateTxOps) clearSpentUtxos(_ context.Context, _ int64, +// clearSpentUtxos implements invalidateUnminedTxOps. +func (m *mockCreateTxOps) clearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { - s.calls = append(s.calls, "clear-spent-utxos") - s.clearedTxIDs = append(s.clearedTxIDs, txID) + args := m.Called(ctx, walletID, txID) - if s.clearSpentErr != nil { - return s.clearSpentErr - } + return args.Error(0) +} + +// markTxnsFailed implements invalidateUnminedTxOps. +func (m *mockCreateTxOps) markTxnsFailed(ctx context.Context, walletID int64, + txIDs []int64) error { + + args := m.Called(ctx, walletID, txIDs) - return nil + return args.Error(0) } -// markTxnsFailed satisfies the embedded invalidateUnminedTxOps interface for -// the shared CreateTx orchestration tests. -func (s *stubCreateTxOps) markTxnsFailed(_ context.Context, _ int64, +// markTxnsReplaced implements createTxOps. +func (m *mockCreateTxOps) markTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error { - s.calls = append(s.calls, "mark-txns-failed") - s.failedTxIDs = append([]int64(nil), txIDs...) + args := m.Called(ctx, walletID, txIDs) + + return args.Error(0) +} + +// insertReplacementEdges implements createTxOps. +func (m *mockCreateTxOps) insertReplacementEdges(ctx context.Context, + walletID int64, replacedTxIDs []int64, replacementTxID int64) error { - if s.markFailedErr != nil { - return s.markFailedErr + args := m.Called(ctx, walletID, replacedTxIDs, replacementTxID) + + return args.Error(0) +} + +// insert implements createTxOps. +func (m *mockCreateTxOps) insert(ctx context.Context, + req createTxRequest) (int64, error) { + + args := m.Called(ctx, req) + + txID, ok := args.Get(0).(int64) + if !ok { + return 0, mockTypeError("insert result") } - return nil + return txID, args.Error(1) } -// markTxnsReplaced satisfies the replacement hooks CreateTx uses for direct -// conflict reconciliation. -func (s *stubCreateTxOps) markTxnsReplaced(_ context.Context, _ int64, - txIDs []int64) error { +// insertCredits implements createTxOps. +func (m *mockCreateTxOps) insertCredits(ctx context.Context, + req createTxRequest, txID int64) error { - s.calls = append(s.calls, "mark-txns-replaced") - s.replacedTxIDs = append([]int64(nil), txIDs...) + args := m.Called(ctx, req, txID) - return nil + return args.Error(0) } -// insertReplacementEdges satisfies the replacement hooks CreateTx uses for -// direct conflict reconciliation. -func (s *stubCreateTxOps) insertReplacementEdges(_ context.Context, _ int64, - replacedTxIDs []int64, replacementTxID int64) error { +// markInputsSpent implements createTxOps. +func (m *mockCreateTxOps) markInputsSpent(ctx context.Context, + req createTxRequest, txID int64) error { - s.calls = append(s.calls, "insert-replacement-edges") - s.replacementEdgeTxIDs = append([]int64(nil), replacedTxIDs...) - s.replacedConflictTxID = replacementTxID + args := m.Called(ctx, req, txID) - return nil + return args.Error(0) } -// insert records the request that the shared flow would store as a fresh -// transaction row. -func (s *stubCreateTxOps) insert(_ context.Context, - req createTxRequest) (int64, error) { +// mockUpdateTxOps is a mock implementation of updateTxOps. +type mockUpdateTxOps struct { + mock.Mock +} + +var _ updateTxOps = (*mockUpdateTxOps)(nil) + +// loadIsCoinbase implements updateTxOps. +func (m *mockUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, + txHash chainhash.Hash) (bool, error) { + + args := m.Called(ctx, walletID, txHash) + + isCoinbase, ok := args.Get(0).(bool) + if !ok { + return false, mockTypeError("loadIsCoinbase result") + } - s.calls = append(s.calls, "insert") - s.insertReq = req + return isCoinbase, args.Error(1) +} + +// prepareState implements updateTxOps. +func (m *mockUpdateTxOps) prepareState(ctx context.Context, + state UpdateTxState) error { - return s.insertTxID, nil + args := m.Called(ctx, state) + + return args.Error(0) } -// insertCredits records the transaction ID the shared flow used when -// reconciling wallet-owned outputs. -func (s *stubCreateTxOps) insertCredits(_ context.Context, - _ createTxRequest, txID int64) error { +// updateLabel implements updateTxOps. +func (m *mockUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, + txHash chainhash.Hash, label string) error { - s.calls = append(s.calls, "credits") - s.creditsTxID = txID + args := m.Called(ctx, walletID, txHash, label) - return nil + return args.Error(0) } -// markInputsSpent records the transaction ID the shared flow used when -// attaching wallet-owned spent inputs. -func (s *stubCreateTxOps) markInputsSpent(_ context.Context, - _ createTxRequest, txID int64) error { +// updateState implements updateTxOps. +func (m *mockUpdateTxOps) updateState(ctx context.Context, walletID uint32, + txHash chainhash.Hash, state UpdateTxState) error { - s.calls = append(s.calls, "inputs") - s.inputsTxID = txID + args := m.Called(ctx, walletID, txHash, state) - return nil + return args.Error(0) } // testCreateTxRequest builds one valid normalized CreateTx request for the @@ -668,17 +755,20 @@ func TestCollectConflictDescendants(t *testing.T) { PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 0}, }}}, }} - ops := &stubCreateTxOps{unminedTxns: candidates} + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) rootIDs := []int64{11, 12} rootHashes := []chainhash.Hash{{1}, {2}} + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + candidates, nil).Once() + descendantIDs, err := collectConflictDescendants( context.Background(), 7, rootHashes, rootIDs, ops, ) require.NoError(t, err) require.Equal(t, []int64{13}, descendantIDs) - require.Equal(t, []string{"list-unmined-records"}, ops.calls) } // TestHandleTxConflicts verifies the shared replacement flow for @@ -712,29 +802,36 @@ func TestHandleTxConflicts(t *testing.T) { }}}, }} - ops := &stubCreateTxOps{ - conflictIDs: []int64{1}, - conflictHashes: []chainhash.Hash{rootHash}, - unminedTxns: candidates, - } + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{rootHash}, nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + candidates, nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + Once() + + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + Once() + + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + int64(9)).Return(nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). + Once() + + ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{2, 3}). + Return(nil).Once() err = handleTxConflicts(t.Context(), req, 9, ops) require.NoError(t, err) - require.Equal(t, []string{ - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - "clear-spent-utxos", - "clear-spent-utxos", - "mark-txns-failed", - }, ops.calls) - require.Equal(t, []int64{1, 2, 3}, ops.clearedTxIDs) - require.Equal(t, []int64{1}, ops.replacedTxIDs) - require.Equal(t, []int64{2, 3}, ops.failedTxIDs) - require.Equal(t, []int64{1}, ops.replacementEdgeTxIDs) - require.Equal(t, int64(9), ops.replacedConflictTxID) } // TestHandleTxConflictsKeepsDirectRootsReplaced verifies that a @@ -755,10 +852,17 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{ - conflictIDs: []int64{1, 2}, - conflictHashes: []chainhash.Hash{rootAHash, rootBHash}, - unminedTxns: []unminedTxRecord{{ + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1, 2}, + []chainhash.Hash{rootAHash, rootBHash}, + nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord{{ id: 2, hash: rootBHash, tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ @@ -770,25 +874,23 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootBHash, Index: 0}, }}}, - }}, - } + }}, nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}).Return(nil). + Once() + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1, 2}, + int64(9)).Return(nil).Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). + Once() + ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{3}).Return(nil). + Once() err = handleTxConflicts(t.Context(), req, 9, ops) require.NoError(t, err) - require.Equal(t, []string{ - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - "clear-spent-utxos", - "mark-txns-failed", - }, ops.calls) - require.Equal(t, []int64{1, 2, 3}, ops.clearedTxIDs) - require.Equal(t, []int64{1, 2}, ops.replacedTxIDs) - require.Equal(t, []int64{3}, ops.failedTxIDs) - require.Equal(t, []int64{1, 2}, ops.replacementEdgeTxIDs) } // TestHandleTxConflictsNoDescendants verifies that the helper @@ -805,25 +907,25 @@ func TestHandleTxConflictsNoDescendants(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{ - conflictIDs: []int64{1}, - conflictHashes: []chainhash.Hash{{1}}, - } + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord(nil), nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + Once() + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + int64(9)).Return(nil).Once() err = handleTxConflicts(t.Context(), req, 9, ops) require.NoError(t, err) - require.Equal(t, []string{ - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - }, ops.calls) - require.Equal(t, []int64{1}, ops.clearedTxIDs) - require.Equal(t, []int64{1}, ops.replacedTxIDs) - require.Empty(t, ops.failedTxIDs) - require.Equal(t, []int64{1}, ops.replacementEdgeTxIDs) - require.Equal(t, int64(9), ops.replacedConflictTxID) } // TestHandleTxConflictsMarkFailedError verifies that the helper records @@ -840,10 +942,15 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { }) require.NoError(t, err) - ops := &stubCreateTxOps{ - conflictIDs: []int64{1}, - conflictHashes: []chainhash.Hash{{1}}, - unminedTxns: []unminedTxRecord{{ + ops := &mockCreateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord{{ id: 2, hash: chainhash.Hash{2}, tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ @@ -852,22 +959,22 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { Index: 0, }, }}}, - }}, - markFailedErr: errConflictMarkFailed, - } + }}, nil).Once() + + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + Once() + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + int64(9)).Return(nil).Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + Once() + ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{2}).Return( + errConflictMarkFailed).Once() err = handleTxConflicts(t.Context(), req, 9, ops) require.ErrorIs(t, err, errConflictMarkFailed) require.ErrorContains(t, err, "mark conflict descendants failed") - require.Equal(t, []string{ - "list-conflicts", - "list-unmined-records", - "clear-spent-utxos", - "mark-txns-replaced", - "insert-replacement-edges", - "clear-spent-utxos", - "mark-txns-failed", - }, ops.calls) } // TestUpdateTxWithOpsLabelAndState verifies that the shared UpdateTx workflow @@ -885,19 +992,45 @@ func TestUpdateTxWithOpsLabelAndState(t *testing.T) { Status: TxStatusPublished, }, } - ops := &stubUpdateTxOps{isCoinbase: false} + + var ( + updatedLabel string + updatedState UpdateTxState + ) + + ops := &mockUpdateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + Return(false, nil).Once() + + ops.On("prepareState", mock.Anything, UpdateTxState{ + Status: TxStatusPublished, + }).Return(nil).Once() + + ops.On("updateLabel", mock.Anything, uint32(5), chainhash.Hash{1}, + label).Return(nil).Run(func(args mock.Arguments) { + labelArg, ok := args.Get(3).(string) + require.True(t, ok) + updatedLabel = labelArg + }).Once() + + ops.On("updateState", mock.Anything, uint32(5), chainhash.Hash{1}, + UpdateTxState{Status: TxStatusPublished}).Return(nil).Run( + func(args mock.Arguments) { + stateArg, ok := args.Get(3).(UpdateTxState) + require.True(t, ok) + updatedState = stateArg + }, + ).Once() // Act: Run updateTxWithOps against a stub backend adapter. err := updateTxWithOps(context.Background(), params, ops) require.NoError(t, err) - // Assert: The shared flow loads, prepares, and applies both patches. - require.Equal(t, - []string{"load", "prepare-state", "label", "state"}, - ops.calls, - ) - require.Equal(t, label, ops.updatedLabel) - require.Equal(t, TxStatusPublished, ops.updatedState.Status) + // Assert: The shared flow applies both patches. + require.Equal(t, label, updatedLabel) + require.Equal(t, TxStatusPublished, updatedState.Status) } // TestUpdateTxWithOpsEmptyPatch verifies that the shared UpdateTx helper @@ -909,11 +1042,14 @@ func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { WalletID: 5, Txid: chainhash.Hash{1}, } - ops := &stubUpdateTxOps{isCoinbase: false} + ops := &mockUpdateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}).Return( + false, nil).Once() err := updateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) - require.Equal(t, []string{"load"}, ops.calls) } // TestUpdateTxWithOpsRejectsInvalidatingStates verifies that UpdateTx rejects @@ -970,65 +1106,15 @@ func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { Txid: chainhash.Hash{1}, State: &test.state, } - ops := &stubUpdateTxOps{isCoinbase: test.isCoinbase} + ops := &mockUpdateTxOps{} + t.Cleanup(func() { ops.AssertExpectations(t) }) + + ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + Return(test.isCoinbase, nil).Once() err := updateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) require.ErrorIs(t, err, ErrInvalidStatus) - require.Equal(t, []string{"load"}, ops.calls) }) } } - -// stubUpdateTxOps records how the shared UpdateTx helper drives one backend -// adapter while letting tests control the loaded metadata. -type stubUpdateTxOps struct { - isCoinbase bool - calls []string - updatedLabel string - updatedState UpdateTxState -} - -var _ updateTxOps = (*stubUpdateTxOps)(nil) - -// loadIsCoinbase records that the shared flow loaded the existing transaction -// row metadata. -func (s *stubUpdateTxOps) loadIsCoinbase(_ context.Context, _ uint32, - _ chainhash.Hash) (bool, error) { - - s.calls = append(s.calls, "load") - - return s.isCoinbase, nil -} - -// prepareState records that the shared flow validated and prepared one state -// patch before applying it. -func (s *stubUpdateTxOps) prepareState(_ context.Context, - _ UpdateTxState) error { - - s.calls = append(s.calls, "prepare-state") - - return nil -} - -// updateLabel records the label value the shared flow asked the backend to -// write. -func (s *stubUpdateTxOps) updateLabel(_ context.Context, _ uint32, - _ chainhash.Hash, label string) error { - - s.calls = append(s.calls, "label") - s.updatedLabel = label - - return nil -} - -// updateState records the state patch the shared flow asked the backend to -// write. -func (s *stubUpdateTxOps) updateState(_ context.Context, _ uint32, - _ chainhash.Hash, state UpdateTxState) error { - - s.calls = append(s.calls, "state") - s.updatedState = state - - return nil -} From 3af194b101f4ef87cb82ce0952908ea92c7b2585 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:13:32 +0800 Subject: [PATCH 518/691] wallet: add tx status and utxo helper coverage --- wallet/internal/db/data_types_test.go | 33 +++++++ wallet/internal/db/utxo_store_common_test.go | 97 ++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 wallet/internal/db/data_types_test.go diff --git a/wallet/internal/db/data_types_test.go b/wallet/internal/db/data_types_test.go new file mode 100644 index 0000000000..122f80b249 --- /dev/null +++ b/wallet/internal/db/data_types_test.go @@ -0,0 +1,33 @@ +package db + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestTxStatusString verifies the public string form for every persisted tx +// status value. +func TestTxStatusString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status TxStatus + want string + }{ + {name: "pending", status: TxStatusPending, want: "pending"}, + {name: "published", status: TxStatusPublished, want: "published"}, + {name: "replaced", status: TxStatusReplaced, want: "replaced"}, + {name: "failed", status: TxStatusFailed, want: "failed"}, + {name: "orphaned", status: TxStatusOrphaned, want: "orphaned"}, + {name: "unknown", status: TxStatus(99), want: "unknown"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, test.status.String()) + }) + } +} diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 220bcf6c03..543262213c 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -2,9 +2,11 @@ package db import ( "context" + "database/sql" "testing" "time" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/stretchr/testify/mock" @@ -475,3 +477,98 @@ func (m *mockReleaseOutputOps) activeLockID(ctx context.Context, func testLeaseOutPoint() wire.OutPoint { return wire.OutPoint{Hash: chainhash.Hash{7}, Index: 1} } + +// TestBuildUtxoInfoMaxAmount verifies that buildUtxoInfo preserves the largest +// valid satoshi amount. +func TestBuildUtxoInfoMaxAmount(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{10} + info, err := buildUtxoInfo( + hash[:], 3, int64(btcutil.MaxSatoshi), []byte{0x53}, + time.Unix(333, 0), false, nil, + ) + + require.NoError(t, err) + require.Equal(t, btcutil.Amount(btcutil.MaxSatoshi), info.Amount) + require.Equal(t, UnminedHeight, info.Height) +} + +// TestBuildUtxoInfoInvalidHash verifies that buildUtxoInfo rejects malformed +// hash bytes. +func TestBuildUtxoInfoInvalidHash(t *testing.T) { + t.Parallel() + + _, err := buildUtxoInfo( + []byte{1, 2, 3}, 6, 1000, []byte{0x56}, time.Unix(666, 0), false, nil, + ) + + require.Error(t, err) +} + +// TestBuildLeasedOutputInvalidHash verifies that buildLeasedOutput rejects +// malformed hash bytes. +func TestBuildLeasedOutputInvalidHash(t *testing.T) { + t.Parallel() + + lockID := make([]byte, 32) + _, err := buildLeasedOutput([]byte{1, 2, 3}, 7, lockID, time.Unix(777, 0)) + + require.Error(t, err) +} + +// TestUtxoInfoFromSqliteRowInvalidOutputIndex verifies that the sqlite row +// decoder rejects output indexes outside the uint32 range. +func TestUtxoInfoFromSqliteRowInvalidOutputIndex(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{13} + _, err := utxoInfoFromSqliteRow( + hash[:], -1, 1000, []byte{0x57}, time.Unix(888, 0), false, + sql.NullInt64{}, + ) + + require.ErrorContains(t, err, "utxo output index") +} + +// TestUtxoInfoFromSqliteRowInvalidBlockHeight verifies that the sqlite row +// decoder rejects invalid confirmed block heights. +func TestUtxoInfoFromSqliteRowInvalidBlockHeight(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{14} + _, err := utxoInfoFromSqliteRow( + hash[:], 0, 1000, []byte{0x58}, time.Unix(999, 0), false, + sql.NullInt64{Int64: -1, Valid: true}, + ) + + require.ErrorContains(t, err, "utxo block height") +} + +// TestUtxoInfoFromPgRowInvalidOutputIndex verifies that the postgres row +// decoder rejects output indexes outside the uint32 range. +func TestUtxoInfoFromPgRowInvalidOutputIndex(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{15} + _, err := utxoInfoFromPgRow( + hash[:], -1, 1000, []byte{0x59}, time.Unix(1000, 0), false, + sql.NullInt32{}, + ) + + require.ErrorContains(t, err, "utxo output index") +} + +// TestUtxoInfoFromPgRowInvalidBlockHeight verifies that the postgres row +// decoder rejects invalid confirmed block heights. +func TestUtxoInfoFromPgRowInvalidBlockHeight(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{16} + _, err := utxoInfoFromPgRow( + hash[:], 0, 1000, []byte{0x5a}, time.Unix(1001, 0), false, + sql.NullInt32{Int32: -1, Valid: true}, + ) + + require.ErrorContains(t, err, "utxo block height") +} From 1755e504630a6f2cc8fdad7e726a6107b6c6a534 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:14:21 +0800 Subject: [PATCH 519/691] wallet: add tx store unit coverage Add coverage for the shared tx-store helpers and backend error wrapping so the db package catches the current delete and rollback edge cases on top of the enhance branch wording. --- .../db/tx_store_backend_error_test.go | 114 ++++++++++++++++++ wallet/internal/db/tx_store_common_test.go | 35 ++++++ 2 files changed, 149 insertions(+) create mode 100644 wallet/internal/db/tx_store_backend_error_test.go diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go new file mode 100644 index 0000000000..71a6afb259 --- /dev/null +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -0,0 +1,114 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +var errDummy = errors.New("dummy") + +// errorDBTX forces sqlc exec/query calls down their wrapped error paths. +type errorDBTX struct { + execErr error + queryErr error +} + +// ExecContext implements the sqlc DBTX interface. +func (e errorDBTX) ExecContext(context.Context, string, + ...interface{}) (sql.Result, error) { + + return nil, e.execErr +} + +// PrepareContext implements the sqlc DBTX interface. +func (e errorDBTX) PrepareContext(context.Context, + string) (*sql.Stmt, error) { + + return nil, errDummy +} + +// QueryContext implements the sqlc DBTX interface. +func (e errorDBTX) QueryContext(context.Context, string, + ...interface{}) (*sql.Rows, error) { + + return nil, e.queryErr +} + +// QueryRowContext implements the sqlc DBTX interface. +func (e errorDBTX) QueryRowContext(context.Context, string, + ...interface{}) *sql.Row { + + return &sql.Row{} +} + +// TestPgDeleteAndRollbackOpsWrapBackendErrors verifies that the postgres delete +// and rollback adapters preserve their step-specific error context when sqlc +// exec and query calls fail. +func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcpg.New(errorDBTX{ + execErr: errDummy, + queryErr: errDummy, + }) + deleteOps := pgDeleteTxOps{qtx: qtx} + rollbackOps := pgRollbackToBlockOps{qtx: qtx} + + err := deleteOps.clearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxo rows") + + err = deleteOps.deleteCreatedUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "delete created utxo rows") + + _, err = deleteOps.deleteUnminedTransaction( + t.Context(), 1, chainhash.Hash{1}, + ) + require.ErrorContains(t, err, "delete unmined tx row") + + _, err = rollbackOps.listUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = rollbackOps.clearDescendantSpends(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear descendant spends") + + err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark descendants failed") +} + +// TestSqliteDeleteAndRollbackOpsWrapBackendErrors verifies that the sqlite +// delete and rollback adapters preserve their step-specific error context when +// sqlc exec and query calls fail. +func TestSqliteDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + deleteOps := sqliteDeleteTxOps{qtx: qtx} + rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + + err := deleteOps.clearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxo rows") + + err = deleteOps.deleteCreatedUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "delete created utxo rows") + + _, err = deleteOps.deleteUnminedTransaction( + t.Context(), 1, chainhash.Hash{1}, + ) + require.ErrorContains(t, err, "delete unmined tx row") + + _, err = rollbackOps.listUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = rollbackOps.clearDescendantSpends(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear descendant spends") + + err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark descendants failed") +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 2236e1744a..c0fd402693 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -80,6 +80,41 @@ func TestParseTxStatus(t *testing.T) { } } +// TestParseTxStatusNegativeValue verifies that parseTxStatus rejects negative +// stored values before they can map into the public TxStatus enum. +func TestParseTxStatusNegativeValue(t *testing.T) { + t.Parallel() + + _, err := parseTxStatus(-1) + require.ErrorIs(t, err, ErrInvalidStatus) +} + +// TestIsUnminedStatus verifies the delete-specific classification for each +// tx status. +func TestIsUnminedStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status TxStatus + want bool + }{ + {name: "pending", status: TxStatusPending, want: true}, + {name: "published", status: TxStatusPublished, want: true}, + {name: "replaced", status: TxStatusReplaced, want: false}, + {name: "failed", status: TxStatusFailed, want: false}, + {name: "orphaned", status: TxStatusOrphaned, want: false}, + {name: "unknown", status: TxStatus(99), want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, isUnminedStatus(test.status)) + }) + } +} + // TestBuildTxInfo verifies the shared row-to-domain conversion used by both // SQL backends when returning a valid TxInfo value. func TestBuildTxInfo(t *testing.T) { From f67a7669852bcc84b8ff59790dbec4860627243d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:08 +0800 Subject: [PATCH 520/691] wallet: add tx corruption itest fixtures --- .../db/itest/tx_corruption_pg_test.go | 161 ++++++++++++++ .../db/itest/tx_corruption_sqlite_test.go | 205 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 wallet/internal/db/itest/tx_corruption_pg_test.go create mode 100644 wallet/internal/db/itest/tx_corruption_sqlite_test.go diff --git a/wallet/internal/db/itest/tx_corruption_pg_test.go b/wallet/internal/db/itest/tx_corruption_pg_test.go new file mode 100644 index 0000000000..f69010c1fd --- /dev/null +++ b/wallet/internal/db/itest/tx_corruption_pg_test.go @@ -0,0 +1,161 @@ +//go:build itest && test_db_postgres + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// These postgres-only helpers intentionally drop backend CHECK constraints in +// the isolated test database so corruption itests can persist rows that normal +// store writes would reject. The follow-up read paths must treat those rows as +// corruption instead of silently accepting them. + +// corruptTransactionStatus writes an invalid tx status after dropping the +// validating constraints that normally reject it. The corruption itests use +// this to verify that reads reject impossible tx states. +func corruptTransactionStatus(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, status int64) { + t.Helper() + + for _, stmt := range []string{ + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS valid_status", + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS check_orphaned_coinbase_only", + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS check_confirmed_published", + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS check_coinbase_not_pending", + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS check_coinbase_confirmation_state", + } { + _, err := store.DB().ExecContext(t.Context(), stmt) + require.NoError(t, err) + } + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE transactions SET tx_status = $1 WHERE wallet_id = $2 AND tx_hash = $3", + status, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// corruptTransactionHash writes malformed tx-hash bytes after dropping the +// fixed-length hash check. The corruption itests then verify that hash +// decoding fails with the expected error path. +func corruptTransactionHash(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, hash []byte) { + t.Helper() + + _, err := store.DB().ExecContext( + t.Context(), + "ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_tx_hash_check", + ) + require.NoError(t, err) + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE transactions SET tx_hash = $1 WHERE wallet_id = $2 AND tx_hash = $3", + hash, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// corruptTransactionBlockHeight writes an invalid block height after dropping +// the non-negative height check and creating a matching block row. The +// corruption itests use this to verify that reads reject impossible +// confirmation metadata. +func corruptTransactionBlockHeight(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, height int64) { + t.Helper() + + _, err := store.DB().ExecContext( + t.Context(), + "ALTER TABLE blocks DROP CONSTRAINT IF EXISTS blocks_block_height_check", + ) + require.NoError(t, err) + + blockHash := RandomHash() + _, err = store.DB().ExecContext( + t.Context(), + "INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3) "+ + "ON CONFLICT (block_height) DO UPDATE SET header_hash = EXCLUDED.header_hash, "+ + "block_timestamp = EXCLUDED.block_timestamp", + height, blockHash[:], time.Now().Unix(), + ) + require.NoError(t, err) + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE transactions SET block_height = $1 WHERE wallet_id = $2 AND tx_hash = $3", + height, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// corruptUtxoOutputIndex writes an invalid output index after dropping the +// non-negative output-index check. The corruption itests then verify that UTXO +// decoding rejects the malformed persisted value. +func corruptUtxoOutputIndex(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { + t.Helper() + + _, err := store.DB().ExecContext( + t.Context(), + "ALTER TABLE utxos DROP CONSTRAINT IF EXISTS utxos_output_index_check", + ) + require.NoError(t, err) + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE utxos SET output_index = $1 WHERE output_index = $2 "+ + "AND tx_id = (SELECT id FROM transactions WHERE wallet_id = $3 AND tx_hash = $4)", + newIndex, int64(oldIndex), int64(walletID), txHash[:], + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} + +// corruptActiveLeaseLockID writes an invalid lease lock ID after dropping the +// fixed-length lock-id check. The corruption itests use this to verify that +// lease reads reject malformed lock identifiers. +func corruptActiveLeaseLockID(t *testing.T, store *db.PostgresStore, + walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { + t.Helper() + + _, err := store.DB().ExecContext( + t.Context(), + "ALTER TABLE utxo_leases DROP CONSTRAINT IF EXISTS utxo_leases_lock_id_check", + ) + require.NoError(t, err) + + result, err := store.DB().ExecContext( + t.Context(), + "UPDATE utxo_leases SET lock_id = $1 WHERE wallet_id = $2 AND utxo_id = ("+ + "SELECT u.id FROM utxos u JOIN transactions t ON t.id = u.tx_id "+ + "WHERE t.wallet_id = $3 AND t.tx_hash = $4 AND u.output_index = $5)", + lockID, int64(walletID), int64(walletID), txHash[:], int64(outputIndex), + ) + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) +} diff --git a/wallet/internal/db/itest/tx_corruption_sqlite_test.go b/wallet/internal/db/itest/tx_corruption_sqlite_test.go new file mode 100644 index 0000000000..ad9090fe7c --- /dev/null +++ b/wallet/internal/db/itest/tx_corruption_sqlite_test.go @@ -0,0 +1,205 @@ +//go:build itest && !test_db_postgres + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// These sqlite-only helpers intentionally bypass CHECK constraints in the +// isolated test database so corruption itests can persist rows that normal +// store writes would reject. The follow-up read paths must treat those rows as +// corruption instead of silently accepting them. + +// corruptTransactionStatus writes an invalid tx status into one stored row while +// sqlite check constraints are disabled inside the surrounding transaction. The +// corruption itests use this to verify that reads reject impossible tx states. +func corruptTransactionStatus(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, status int64) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE transactions SET tx_status = ? WHERE wallet_id = ? AND tx_hash = ?", + status, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} + +// corruptTransactionHash writes malformed tx-hash bytes into one stored row +// while sqlite check constraints are disabled. The corruption itests then +// verify that hash decoding fails with the expected error path. +func corruptTransactionHash(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, hash []byte) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE transactions SET tx_hash = ? WHERE wallet_id = ? AND tx_hash = ?", + hash, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} + +// corruptTransactionBlockHeight writes an invalid block height after first +// creating a matching block row in sqlite. The corruption itests use this to +// verify that reads reject impossible confirmation metadata. +func corruptTransactionBlockHeight(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, height int64) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + blockHash := RandomHash() + _, err = tx.ExecContext( + t.Context(), + "INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?) "+ + "ON CONFLICT(block_height) DO UPDATE SET header_hash = excluded.header_hash, "+ + "block_timestamp = excluded.block_timestamp", + height, blockHash[:], time.Now().Unix(), + ) + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE transactions SET block_height = ? WHERE wallet_id = ? AND tx_hash = ?", + height, int64(walletID), txHash[:], + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} + +// corruptUtxoOutputIndex writes an invalid output index into one stored UTXO +// while sqlite check constraints are disabled. The corruption itests then +// verify that UTXO decoding rejects the malformed persisted value. +func corruptUtxoOutputIndex(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE utxos SET output_index = ? WHERE output_index = ? "+ + "AND tx_id = (SELECT id FROM transactions WHERE wallet_id = ? AND tx_hash = ?)", + newIndex, int64(oldIndex), int64(walletID), txHash[:], + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} + +// corruptActiveLeaseLockID writes an invalid lease lock ID into one active +// lease row while sqlite check constraints are disabled. The corruption itests +// use this to verify that lease reads reject malformed lock identifiers. +func corruptActiveLeaseLockID(t *testing.T, store *db.SqliteStore, + walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { + t.Helper() + + tx, err := store.DB().BeginTx(t.Context(), nil) + require.NoError(t, err) + defer func() { + _, _ = tx.ExecContext( + t.Context(), "PRAGMA ignore_check_constraints = OFF", + ) + _ = tx.Rollback() + }() + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = ON") + require.NoError(t, err) + + result, err := tx.ExecContext( + t.Context(), + "UPDATE utxo_leases SET lock_id = ? WHERE wallet_id = ? AND utxo_id = ("+ + "SELECT u.id FROM utxos u JOIN transactions t ON t.id = u.tx_id "+ + "WHERE t.wallet_id = ? AND t.tx_hash = ? AND u.output_index = ?)", + lockID, int64(walletID), int64(walletID), txHash[:], int64(outputIndex), + ) + require.NoError(t, err) + + _, err = tx.ExecContext(t.Context(), "PRAGMA ignore_check_constraints = OFF") + require.NoError(t, err) + + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + require.NoError(t, tx.Commit()) +} From 1925551735495db7cce451969ec92838024415f6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:08 +0800 Subject: [PATCH 521/691] wallet: add create tx edge-case itests --- .../itest/tx_store_create_edge_cases_test.go | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_create_edge_cases_test.go diff --git a/wallet/internal/db/itest/tx_store_create_edge_cases_test.go b/wallet/internal/db/itest/tx_store_create_edge_cases_test.go new file mode 100644 index 0000000000..368272d6ab --- /dev/null +++ b/wallet/internal/db/itest/tx_store_create_edge_cases_test.go @@ -0,0 +1,102 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateTxRejectsUnknownCreditAddress verifies that credited outputs must +// resolve to a wallet-owned address before CreateTx can store the UTXO row. +func TestCreateTxRejectsUnknownCreditAddress(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-unknown-credit-address") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2500, PkScript: []byte{0x51}}}, + ) + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000800, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.ErrorIs(t, err, db.ErrAddressNotFound) + + _, err = store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }, + ) + require.ErrorIs(t, err, db.ErrTxNotFound) +} + +// TestCreateTxRejectsInvalidParams verifies that CreateTx returns shared +// parameter-validation errors before opening a backend transaction. +func TestCreateTxRejectsInvalidParams(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalid-create-tx-params") + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Status: db.TxStatusPending, + }, + ) + require.ErrorContains(t, err, "tx is required") +} + +// TestCreateTxRejectsDuplicateConfirmedTransaction verifies that duplicate +// confirmed inserts fail instead of silently creating a second row. +func TestCreateTxRejectsDuplicateConfirmedTransaction(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-duplicate-confirmed-tx") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 261) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 4500, PkScript: addr.ScriptPubKey}}, + ) + params := db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000850, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + } + + err := store.CreateTx(t.Context(), params) + require.NoError(t, err) + + err = store.CreateTx(t.Context(), params) + require.ErrorIs(t, err, db.ErrTxAlreadyExists) +} From 9e8ec3691ed2b403e9d0e565eb93a5d74d8e32ed Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:09 +0800 Subject: [PATCH 522/691] wallet: add tx store corruption itests --- .../db/itest/tx_store_corruption_test.go | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_corruption_test.go diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go new file mode 100644 index 0000000000..026cc773d6 --- /dev/null +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -0,0 +1,298 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestGetAndListTxRejectCorruptedStatus verifies that tx reads fail loudly when +// the stored status escapes the supported enum. +func TestGetAndListTxRejectCorruptedStatus(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-tx-status") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 265) + + pendingTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2100, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: pendingTx, + Received: time.Unix(1710000895, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + confirmedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 3100, PkScript: addr.ScriptPubKey}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedTx, + Received: time.Unix(1710000896, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + corruptTransactionStatus(t, store, walletID, pendingTx.TxHash(), 99) + + _, err = store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: pendingTx.TxHash(), + }, + ) + require.ErrorContains(t, err, "invalid tx status") + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }, + ) + require.ErrorContains(t, err, "invalid tx status") + + corruptTransactionStatus(t, store, walletID, confirmedTx.TxHash(), 99) + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: confirmedBlock.Height, + EndHeight: confirmedBlock.Height, + }, + ) + require.ErrorContains(t, err, "invalid tx status") +} + +// TestDeleteTxRejectsCorruptedStatus verifies that DeleteTx rejects stored rows +// with an invalid wallet-visible status code. +func TestDeleteTxRejectsCorruptedStatus(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-corrupted-status") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2300, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000897, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + corruptTransactionStatus(t, store, walletID, tx.TxHash(), 99) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + }, + ) + require.ErrorContains(t, err, "invalid tx status") +} + +// TestListTxnsRejectsCorruptedUnminedHash verifies that unmined transaction +// listings fail when a stored transaction hash cannot be decoded. +func TestListTxnsRejectsCorruptedUnminedHash(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-unmined-hash") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2700, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001400, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + corruptTransactionHash(t, store, walletID, tx.TxHash(), []byte{1, 2, 3}) + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }, + ) + require.ErrorContains(t, err, "tx hash") +} + +// TestGetTxRejectsCorruptedConfirmedBlockHeight verifies that confirmed reads +// fail when the joined block height cannot map back into the public block model. +func TestGetTxRejectsCorruptedConfirmedBlockHeight(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-confirmed-height") + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 280) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2800, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001410, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }, + ) + require.NoError(t, err) + + corruptTransactionBlockHeight(t, store, walletID, tx.TxHash(), -1) + + _, err = store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: tx.TxHash(), + }, + ) + require.ErrorContains(t, err, "block height") +} + +// TestDeleteTxRejectsCorruptedLiveChild verifies that DeleteTx surfaces child +// decode failures while checking the live leaf invariant. +func TestDeleteTxRejectsCorruptedLiveChild(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-corrupted-child") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001015, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4000, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001020, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + corruptTransactionHash(t, store, walletID, childTx.TxHash(), []byte{1, 2, 3}) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }, + ) + require.ErrorContains(t, err, "tx hash") +} + +// TestRollbackToBlockRejectsCorruptedCoinbaseRootHash verifies that rollback +// fails loudly when a disconnected coinbase root carries an invalid stored hash. +func TestRollbackToBlockRejectsCorruptedCoinbaseRootHash(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-rollback-corrupted-coinbase-hash") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + coinbaseBlock := CreateBlockFixture(t, queries, 290) + coinbaseTx := newCoinbaseTx(addr.ScriptPubKey) + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: coinbaseTx, + Received: time.Unix(1710001420, 0), + Block: &coinbaseBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + corruptTransactionHash(t, store, walletID, coinbaseTx.TxHash(), []byte{1, 2, 3}) + + err = store.RollbackToBlock(t.Context(), coinbaseBlock.Height) + require.ErrorContains(t, err, "rollback coinbase hash") +} From 6398d925683c805a68617f0dcbecfbb4c3a56db1 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:09 +0800 Subject: [PATCH 523/691] wallet: add tx store edge-case itests --- .../db/itest/tx_store_edge_cases_test.go | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_edge_cases_test.go diff --git a/wallet/internal/db/itest/tx_store_edge_cases_test.go b/wallet/internal/db/itest/tx_store_edge_cases_test.go new file mode 100644 index 0000000000..68a7a2864e --- /dev/null +++ b/wallet/internal/db/itest/tx_store_edge_cases_test.go @@ -0,0 +1,202 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestDeleteTxRejectsConfirmedAndMissing verifies DeleteTx's live-unconfirmed +// precondition and not-found handling. +func TestDeleteTxRejectsConfirmedAndMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-confirmed-or-missing") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 260) + + confirmedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedTx, + Received: time.Unix(1710000900, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: confirmedTx.TxHash(), + }, + ) + require.ErrorContains(t, err, "delete requires an unmined transaction") + + confirmedInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: confirmedTx.TxHash(), + }, + ) + require.NoError(t, err) + require.NotNil(t, confirmedInfo.Block) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: RandomHash(), + }, + ) + require.ErrorIs(t, err, db.ErrTxNotFound) +} + +// TestDeleteTxRejectsNonLeafExternalChild verifies that DeleteTx scans the raw +// unmined graph, not only wallet-owned credit edges, when enforcing leaf-only +// deletion. +func TestDeleteTxRejectsNonLeafExternalChild(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-delete-non-leaf-external-child") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{ + {Value: 6000, PkScript: addr.ScriptPubKey}, + {Value: 500, PkScript: []byte{0x51}}, + }, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001000, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 1}}, + []*wire.TxOut{{Value: 300, PkScript: []byte{0x52}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710001010, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + err = store.DeleteTx( + t.Context(), + db.DeleteTxParams{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }, + ) + require.ErrorIs(t, err, db.ErrDeleteRequiresLeaf) + + parentInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: parentTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusPending, parentInfo.Status) + + childInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: childTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusPending, childInfo.Status) +} + +// TestTxReadsReturnQueryErrorsWhenClosed verifies that transaction read and +// update methods wrap backend query errors when the store is closed. +func TestTxReadsReturnQueryErrorsWhenClosed(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-closed-tx-reads") + err := store.Close() + require.NoError(t, err) + + label := "closed" + err = store.UpdateTx( + t.Context(), + db.UpdateTxParams{ + WalletID: walletID, + Txid: RandomHash(), + Label: &label, + }, + ) + require.ErrorContains(t, err, "begin tx") + + _, err = store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: RandomHash(), + }, + ) + require.ErrorContains(t, err, "get tx") + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + UnminedOnly: true, + }, + ) + require.ErrorContains(t, err, "list txns without block") + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: 1, + EndHeight: 1, + }, + ) + require.ErrorContains(t, err, "list txns by height") +} From 2d967130f37a2099c1707038ef8abde183ae9818 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:09 +0800 Subject: [PATCH 524/691] wallet: add utxo store edge-case itests --- .../db/itest/utxo_store_edge_cases_test.go | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 wallet/internal/db/itest/utxo_store_edge_cases_test.go diff --git a/wallet/internal/db/itest/utxo_store_edge_cases_test.go b/wallet/internal/db/itest/utxo_store_edge_cases_test.go new file mode 100644 index 0000000000..1b72df0855 --- /dev/null +++ b/wallet/internal/db/itest/utxo_store_edge_cases_test.go @@ -0,0 +1,305 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestLeaseOutputMissingUtxo verifies that leasing a missing outpoint returns +// the public not-found error. +func TestLeaseOutputMissingUtxo(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-lease-missing-utxo") + + _, err := store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: RandomHash(), + OutPoint: wire.OutPoint{Hash: RandomHash(), Index: 0}, + Duration: time.Minute, + }, + ) + require.ErrorIs(t, err, db.ErrUtxoNotFound) +} + +// TestReleaseOutputMissingUtxo verifies that releasing a missing outpoint +// returns the public not-found error. +func TestReleaseOutputMissingUtxo(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-release-missing-utxo") + + err := store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: RandomHash(), + OutPoint: wire.OutPoint{Hash: RandomHash(), Index: 0}, + }, + ) + require.ErrorIs(t, err, db.ErrUtxoNotFound) +} + +// TestReleaseOutputTwiceIsNoOp verifies that a second release becomes a no-op +// after the original lease has already been removed. +func TestReleaseOutputTwiceIsNoOp(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-release-output-twice") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 270) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8000, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001300, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + leaseOutPoint := wire.OutPoint{Hash: tx.TxHash(), Index: 0} + leaseID := RandomHash() + _, err = store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: leaseOutPoint, + Duration: time.Hour, + }, + ) + require.NoError(t, err) + + err = store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: leaseOutPoint, + }, + ) + require.NoError(t, err) + + err = store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: leaseOutPoint, + }, + ) + require.NoError(t, err) + + leases, err := store.ListLeasedOutputs(t.Context(), walletID) + require.NoError(t, err) + require.Empty(t, leases) +} + +// TestListLeasedOutputsRejectsCorruptedLockID verifies that active lease reads +// fail loudly when the stored lock ID cannot be decoded. +func TestListLeasedOutputsRejectsCorruptedLockID(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-lease-lock-id") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 271) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8100, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001430, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }, + ) + require.NoError(t, err) + + corruptActiveLeaseLockID(t, store, walletID, tx.TxHash(), 0, []byte{1, 2, 3}) + + _, err = store.ListLeasedOutputs(t.Context(), walletID) + require.ErrorContains(t, err, "lock id") +} + +// TestListLeasedOutputsRejectsCorruptedOutputIndex verifies that active lease +// reads fail when the joined UTXO output index falls outside the public range. +func TestListLeasedOutputsRejectsCorruptedOutputIndex(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-corrupted-lease-output-index") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + queries := store.Queries() + confirmedBlock := CreateBlockFixture(t, queries, 272) + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 8200, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001440, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + leaseID := RandomHash() + _, err = store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: wire.OutPoint{Hash: tx.TxHash(), Index: 0}, + Duration: time.Minute, + }, + ) + require.NoError(t, err) + + corruptUtxoOutputIndex(t, store, walletID, tx.TxHash(), 0, -1) + + _, err = store.ListLeasedOutputs(t.Context(), walletID) + require.ErrorContains(t, err, "lease output index") +} + +// TestGetUtxoAndLeaseRejectLargeOutputIndex verifies backend-specific handling +// for outpoint indexes that exceed the supported SQL integer range. +func TestGetUtxoAndLeaseRejectLargeOutputIndex(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-large-output-index") + outPoint := wire.OutPoint{Hash: RandomHash(), Index: ^uint32(0)} + + _, err := store.GetUtxo( + t.Context(), + db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: outPoint, + }, + ) + + _, leaseErr := store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: RandomHash(), + OutPoint: outPoint, + Duration: time.Minute, + }, + ) + + releaseErr := store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: RandomHash(), + OutPoint: outPoint, + }, + ) + + if _, ok := any(store).(*db.PostgresStore); ok { + require.ErrorContains(t, err, "convert output index") + require.ErrorContains(t, leaseErr, "convert output index") + require.ErrorContains(t, releaseErr, "could not cast") + return + } + + require.ErrorIs(t, err, db.ErrUtxoNotFound) + require.ErrorIs(t, leaseErr, db.ErrUtxoNotFound) + require.ErrorIs(t, releaseErr, db.ErrUtxoNotFound) +} + +// TestUtxoReadsReturnQueryErrorsWhenClosed verifies that UTXO read methods wrap +// backend query errors when the underlying connection is closed. +func TestUtxoReadsReturnQueryErrorsWhenClosed(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-closed-utxo-reads") + err := store.Close() + require.NoError(t, err) + + _, err = store.GetUtxo( + t.Context(), + db.GetUtxoQuery{ + WalletID: walletID, + OutPoint: wire.OutPoint{Hash: RandomHash(), Index: 0}, + }, + ) + require.ErrorContains(t, err, "get utxo") + + _, err = store.ListUTXOs( + t.Context(), + db.ListUtxosQuery{WalletID: walletID}, + ) + require.ErrorContains(t, err, "list utxos") + + _, err = store.ListLeasedOutputs(t.Context(), walletID) + require.ErrorContains(t, err, "list active utxo leases") + + _, err = store.Balance( + t.Context(), + db.BalanceParams{WalletID: walletID}, + ) + require.ErrorContains(t, err, "balance") +} From 3566ab4edebab4c4781100893b7317d12f750413 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Sat, 28 Mar 2026 02:08:09 +0800 Subject: [PATCH 525/691] wallet: add postgres tx store overflow itests --- .../db/itest/tx_store_pg_only_test.go | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_pg_only_test.go diff --git a/wallet/internal/db/itest/tx_store_pg_only_test.go b/wallet/internal/db/itest/tx_store_pg_only_test.go new file mode 100644 index 0000000000..f04c81f96a --- /dev/null +++ b/wallet/internal/db/itest/tx_store_pg_only_test.go @@ -0,0 +1,81 @@ +//go:build itest && test_db_postgres + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestCreateTxRejectsBlockHeightOverflowPostgres verifies postgres-specific +// block-height conversion failures surface before insert. +func TestCreateTxRejectsBlockHeightOverflowPostgres(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-overflow-height-pg") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 1000, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710005000, 0), + Block: &db.Block{ + Height: ^uint32(0), + Hash: RandomHash(), + Timestamp: time.Unix(1710005001, 0), + }, + Status: db.TxStatusPublished, + }, + ) + require.ErrorContains(t, err, "convert block height") +} + +// TestListTxnsRejectHeightOverflowPostgres verifies postgres-specific height +// conversion failures for confirmed transaction listings. +func TestListTxnsRejectHeightOverflowPostgres(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-height-overflow-pg") + + _, err := store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: ^uint32(0), + EndHeight: 0, + }, + ) + require.ErrorContains(t, err, "convert start height") + + _, err = store.ListTxns( + t.Context(), + db.ListTxnsQuery{ + WalletID: walletID, + StartHeight: 0, + EndHeight: ^uint32(0), + }, + ) + require.ErrorContains(t, err, "convert end height") +} + +// TestRollbackToBlockRejectsHeightOverflowPostgres verifies postgres-specific +// rollback height conversion failures. +func TestRollbackToBlockRejectsHeightOverflowPostgres(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + err := store.RollbackToBlock(t.Context(), ^uint32(0)) + require.ErrorContains(t, err, "convert rollback height") +} From c965077b27cbda2f158dfc4fda9a17521cc023d4 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 21:33:24 +0800 Subject: [PATCH 526/691] wallet: add backend tx store error-path coverage Adapt the backend tx-store helper coverage to the current rollback and CreateTx helper names so the tests keep exercising the wrapped backend error paths on top of the current enhance branch. --- .../db/tx_store_backend_error_test.go | 219 ++++++++++++++++++ .../internal/db/tx_store_backend_rows_test.go | 215 +++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 wallet/internal/db/tx_store_backend_rows_test.go diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go index 71a6afb259..330be1e71b 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -5,8 +5,10 @@ import ( "database/sql" "errors" "testing" + "time" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" @@ -112,3 +114,220 @@ func TestSqliteDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark descendants failed") } + +// TestPgTxStoreOpsWrapBackendErrors verifies that the postgres tx-store helper +// adapters preserve step-specific error context for create, invalidate, +// rollback, update, and release workflows. +func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcpg.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + createOps := &pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{qtx: qtx}, + } + invalidateOps := pgInvalidateUnminedTxOps{qtx: qtx} + rollbackOps := pgRollbackToBlockOps{qtx: qtx} + updateOps := &pgUpdateTxOps{qtx: qtx} + releaseOps := pgReleaseOutputOps{qtx: qtx} + + err := createOps.markTxnsReplaced( + t.Context(), 1, []int64{2}, + ) + require.ErrorContains(t, err, "mark txns replaced") + + err = createOps.insertReplacementEdges( + t.Context(), 1, []int64{2}, 3, + ) + require.ErrorContains(t, err, "insert replacement edge") + + err = markInputsSpentPg(t.Context(), qtx, CreateTxParams{ + WalletID: 1, + Tx: testRegularMsgTx(), + Received: time.Unix(1, 0), + Status: TxStatusPending, + }, 7) + require.ErrorContains(t, err, "mark spent input 0") + + _, err = invalidateOps.listUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = invalidateOps.clearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxos") + + err = invalidateOps.markTxnsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark txns failed") + + _, err = rollbackOps.listRollbackRootHashes(t.Context(), 1) + require.ErrorContains(t, err, "query rollback coinbase roots") + + err = rollbackOps.rewindWalletSyncStateHeights(t.Context(), 1) + require.ErrorContains(t, err, "rewind wallet sync state heights query") + + err = rollbackOps.deleteBlocksAtOrAboveHeight(t.Context(), 1) + require.ErrorContains(t, err, "delete blocks at or above height query") + + err = rollbackOps.markTxRootsOrphaned( + t.Context(), 1, []chainhash.Hash{{1}}, + ) + require.ErrorContains(t, err, "update rollback coinbase state query") + + updateOps.blockHeight = sql.NullInt32{} + updateOps.status = int16(TxStatusPublished) + err = updateOps.updateState(t.Context(), 1, chainhash.Hash{1}, + UpdateTxState{Status: TxStatusPublished}) + require.ErrorContains(t, err, "update tx state query") + + err = updateOps.updateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + require.ErrorContains(t, err, "update tx label query") + + _, err = releaseOps.release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "release lease row") +} + +// TestSqliteTxStoreOpsWrapBackendErrors verifies that the sqlite tx-store +// helper adapters preserve step-specific error context for create, invalidate, +// rollback, update, and release workflows. +func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + createOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: qtx, + }, + } + invalidateOps := sqliteInvalidateUnminedTxOps{qtx: qtx} + rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + updateOps := &sqliteUpdateTxOps{qtx: qtx} + releaseOps := sqliteReleaseOutputOps{qtx: qtx} + + err := createOps.markTxnsReplaced( + t.Context(), 1, []int64{2}, + ) + require.ErrorContains(t, err, "mark txns replaced") + + err = createOps.insertReplacementEdges( + t.Context(), 1, []int64{2}, 3, + ) + require.ErrorContains(t, err, "insert replacement edge") + + err = markInputsSpentSqlite(t.Context(), qtx, CreateTxParams{ + WalletID: 1, + Tx: testRegularMsgTx(), + Received: time.Unix(1, 0), + Status: TxStatusPending, + }, 7) + require.ErrorContains(t, err, "mark spent input 0") + + _, err = invalidateOps.listUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = invalidateOps.clearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxos") + + err = invalidateOps.markTxnsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark txns failed") + + _, err = rollbackOps.listRollbackRootHashes(t.Context(), 1) + require.ErrorContains(t, err, "query rollback coinbase roots") + + err = rollbackOps.rewindWalletSyncStateHeights(t.Context(), 1) + require.ErrorContains(t, err, "rewind wallet sync state heights query") + + err = rollbackOps.deleteBlocksAtOrAboveHeight(t.Context(), 1) + require.ErrorContains(t, err, "delete blocks at or above height query") + + err = rollbackOps.markTxRootsOrphaned( + t.Context(), 1, []chainhash.Hash{{1}}, + ) + require.ErrorContains(t, err, "update rollback coinbase state query") + + updateOps.blockHeight = sql.NullInt64{} + updateOps.status = int64(TxStatusPublished) + err = updateOps.updateState(t.Context(), 1, chainhash.Hash{1}, + UpdateTxState{Status: TxStatusPublished}) + require.ErrorContains(t, err, "update tx state query") + + err = updateOps.updateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + require.ErrorContains(t, err, "update tx label query") + + _, err = releaseOps.release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "release lease row") +} + +// TestPgBackendHelpersRejectOverflow verifies the remaining postgres helper +// branches that fail before issuing any SQL query. +func TestPgBackendHelpersRejectOverflow(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 1, + Tx: &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: ^uint32(0), + }, + }}, + TxOut: []*wire.TxOut{{Value: 1, PkScript: []byte{0x51}}}, + }, + Received: time.Unix(1, 0), + Status: TxStatusPending, + }) + require.NoError(t, err) + + _, err = collectPgConflictRootIDs( + t.Context(), nil, req, + ) + require.ErrorContains(t, err, "convert input outpoint index 0") + + _, err = creditExistsPg(t.Context(), nil, 1, chainhash.Hash{1}, ^uint32(0)) + require.ErrorContains(t, err, "convert credit index") + + err = markInputsSpentPg(t.Context(), nil, CreateTxParams{ + WalletID: 1, + Tx: &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: ^uint32(0), + }, + }}, + }, + Status: TxStatusPending, + }, 3) + require.ErrorContains(t, err, "convert input outpoint index 0") + + err = pgRollbackToBlockOps{}.rewindWalletSyncStateHeights( + t.Context(), ^uint32(0), + ) + require.ErrorContains(t, err, "convert rollback height") + + err = pgRollbackToBlockOps{}.deleteBlocksAtOrAboveHeight( + t.Context(), ^uint32(0), + ) + require.ErrorContains(t, err, "convert rollback height") + + _, _, err = buildPgConflictRoots([]sqlcpg.ListUnminedTransactionsRow{{ + ID: 1, + TxHash: []byte{1}, + TxStatus: 0, + }}, map[int64]struct{}{1: {}}) + require.ErrorContains(t, err, "tx hash") + + _, err = (&pgLeaseOutputOps{}).acquire(t.Context(), LeaseOutputParams{ + WalletID: 1, + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, + ID: [32]byte{1}, + }, time.Now(), time.Now().Add(time.Minute)) + require.ErrorContains(t, err, "convert output index") + + _, err = (&pgLeaseOutputOps{}).hasUtxo(t.Context(), LeaseOutputParams{ + WalletID: 1, + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, + ID: [32]byte{1}, + }) + require.ErrorContains(t, err, "convert output index") +} diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/tx_store_backend_rows_test.go new file mode 100644 index 0000000000..a5257c8c92 --- /dev/null +++ b/wallet/internal/db/tx_store_backend_rows_test.go @@ -0,0 +1,215 @@ +package db + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +// staticResult is a minimal sql.Result stub with a caller-controlled row count. +type staticResult struct { + rows int64 +} + +// LastInsertId implements sql.Result. +func (r staticResult) LastInsertId() (int64, error) { + return 0, nil +} + +// RowsAffected implements sql.Result. +func (r staticResult) RowsAffected() (int64, error) { + return r.rows, nil +} + +// rowDBTX is a sqlc DBTX stub that lets tests mix fixed exec counts with +// query-row scan failures from a temporary sqlite handle. +type rowDBTX struct { + row *sql.Row + queryErr error + execErr error + rows int64 +} + +// ExecContext implements the sqlc DBTX interface. +func (r rowDBTX) ExecContext(context.Context, string, + ...interface{}) (sql.Result, error) { + + if r.execErr != nil { + return nil, r.execErr + } + + return staticResult{rows: r.rows}, nil +} + +// PrepareContext implements the sqlc DBTX interface. +func (r rowDBTX) PrepareContext(context.Context, string) (*sql.Stmt, error) { + return nil, errDummy +} + +// QueryContext implements the sqlc DBTX interface. +func (r rowDBTX) QueryContext(context.Context, string, + ...interface{}) (*sql.Rows, error) { + + return nil, r.queryErr +} + +// QueryRowContext implements the sqlc DBTX interface. +func (r rowDBTX) QueryRowContext(context.Context, string, + ...interface{}) *sql.Row { + + if r.row != nil { + return r.row + } + + return &sql.Row{} +} + +// newSQLiteRow creates a query row backed by an in-memory sqlite database so +// sqlc scan paths can fail without standing up a real store. +func newSQLiteRow(t *testing.T, query string, args ...interface{}) *sql.Row { + t.Helper() + + db, err := sql.Open("sqlite", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + return db.QueryRowContext(t.Context(), query, args...) +} + +// TestPgCreateTxOpsAdditionalBranches covers remaining postgres CreateTx helper +// branches that are hard to reach through public integration tests alone. +func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ctx := context.Background() + + _, err := (&pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + qtx: sqlcpg.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + }), + }, + }).loadExisting(ctx, req) + require.ErrorContains(t, err, "get tx metadata") + + block := &Block{ + Hash: chainhash.Hash{3}, + Height: 7, + Timestamp: time.Unix(77, 0), + } + err = (&pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + qtx: sqlcpg.New(rowDBTX{ + row: newSQLiteRow( + t, "SELECT ?, ?, ?", + int64(block.Height), block.Hash[:], + block.Timestamp.Unix(), + ), + rows: 0, + }), + }, + }).confirmExisting(ctx, createTxRequest{ + params: CreateTxParams{WalletID: 1, Block: block}, + txHash: chainhash.Hash{9}, + }, createTxExistingTarget{}) + require.ErrorIs(t, err, ErrTxNotFound) + + _, _, err = (&pgCreateTxOps{ + pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + qtx: sqlcpg.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT ?", int64(5)), + queryErr: errDummy, + }), + }, + }).listConflictTxns(ctx, req) + require.ErrorContains(t, err, "list unmined txns") +} + +// TestSqliteCreateTxOpsAdditionalBranches covers remaining sqlite CreateTx +// helper branches that are hard to reach through public integration tests +// alone. +func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ctx := context.Background() + + _, err := (&sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + }), + }, + }).loadExisting(ctx, req) + require.ErrorContains(t, err, "get tx metadata") + + block := &Block{ + Hash: chainhash.Hash{4}, + Height: 8, + Timestamp: time.Unix(88, 0), + } + err = (&sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow( + t, "SELECT ?, ?, ?", + int64(block.Height), block.Hash[:], + block.Timestamp.Unix(), + ), + rows: 0, + }), + }, + }).confirmExisting(ctx, createTxRequest{ + params: CreateTxParams{WalletID: 1, Block: block}, + txHash: chainhash.Hash{9}, + }, createTxExistingTarget{}) + require.ErrorIs(t, err, ErrTxNotFound) + + err = (&sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + }), + }, + }).prepareBlock(ctx, createTxRequest{ + params: CreateTxParams{WalletID: 1, Block: block}, + }) + require.ErrorContains(t, err, "get block by height") + + _, _, err = (&sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT ?", int64(5)), + queryErr: errDummy, + }), + }, + }).listConflictTxns(ctx, req) + require.ErrorContains(t, err, "list unmined txns") +} + +// TestSqliteReleaseOutputOpsAdditionalBranches covers the remaining sqlite +// release-helper query-row error wrappers. +func TestSqliteReleaseOutputOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + })} + + _, err := ops.lookupUtxoID(context.Background(), ReleaseOutputParams{ + WalletID: 1, + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, + }) + require.ErrorContains(t, err, "lookup utxo row") + + _, err = ops.activeLockID(context.Background(), 1, 2, time.Now()) + require.ErrorContains(t, err, "lookup active lease row") +} From 4b2f0c8ac0910294aeb6a9ae90a4f27424609c73 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:16:02 +0800 Subject: [PATCH 527/691] wallet: add shared tx store helper coverage Add additive coverage for the remaining shared CreateTx and UpdateTx helper branches on top of the mock-based tx-store helper adapters. This keeps the helper-only edge cases in one tests-only commit while adapting the old coverage to the current enhance helper names and error wording. --- wallet/internal/db/tx_store_common_test.go | 306 +++++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index c0fd402693..64c4e9a854 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -1153,3 +1153,309 @@ func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { }) } } + +var errCreateTxTest = errors.New("create tx test") + +// TestCheckReuseCreateTx verifies the shared reuse decision for existing rows. +func TestCheckReuseCreateTx(t *testing.T) { + t.Parallel() + + coinbaseReq, err := newCreateTxRequest(CreateTxParams{ + WalletID: 9, + Tx: testCoinbaseMsgTx(), + Received: time.Unix(555, 0), + Block: testBlock(22), + Status: TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + confirmedReq, err := newCreateTxRequest(CreateTxParams{ + WalletID: 9, + Tx: testRegularMsgTx(), + Received: time.Unix(556, 0), + Block: testBlock(23), + Status: TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }) + require.NoError(t, err) + + tests := []struct { + name string + req createTxRequest + existing createTxExistingTarget + want bool + }{ + { + name: "confirmed unmined row reused", + req: confirmedReq, + existing: createTxExistingTarget{ + status: TxStatusPending, + }, + want: true, + }, + { + name: "missing block not reused", + req: testCreateTxRequest(t), + existing: createTxExistingTarget{ + status: TxStatusPending, + }, + }, + { + name: "existing confirmed row not reused", + req: confirmedReq, + existing: createTxExistingTarget{ + status: TxStatusPublished, + hasBlock: true, + }, + }, + { + name: "non coinbase orphan not reused", + req: confirmedReq, + existing: createTxExistingTarget{ + status: TxStatusOrphaned, + }, + }, + { + name: "orphaned coinbase reused", + req: coinbaseReq, + existing: createTxExistingTarget{ + status: TxStatusOrphaned, + isCoinbase: true, + }, + want: true, + }, + { + name: "coinbase row not reused for non coinbase tx", + req: confirmedReq, + existing: createTxExistingTarget{ + status: TxStatusOrphaned, + isCoinbase: true, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, + checkReuseCreateTx(test.req, test.existing)) + }) + } +} + +// TestLoadCreateTxExisting verifies not-found and wrapped-error handling for +// the shared existing-row lookup. +func TestLoadCreateTxExisting(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("loadExisting", mock.Anything, req).Return( + nil, errCreateTxExistingNotFound).Once() + + existing, found, err := loadCreateTxExisting(context.Background(), req, ops) + require.NoError(t, err) + require.False(t, found) + require.Nil(t, existing) + + ops.On("loadExisting", mock.Anything, req).Return(nil, nil).Once() + + existing, found, err = loadCreateTxExisting(context.Background(), req, ops) + require.NoError(t, err) + require.False(t, found) + require.Nil(t, existing) + + ops.On("loadExisting", mock.Anything, req).Return( + nil, errCreateTxTest, + ).Once() + + _, _, err = loadCreateTxExisting(context.Background(), req, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "load create tx target") +} + +// TestHandleRootTxnsClearError verifies that root spend-clearing failures are +// returned before the helper mutates any later replacement state. +func TestHandleRootTxnsClearError(t *testing.T) { + t.Parallel() + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("clearSpentUtxos", mock.Anything, int64(9), int64(1)).Return( + errCreateTxTest).Once() + + err := handleRootTxns(context.Background(), 9, []int64{1}, 11, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "clear replaced root spent utxos") +} + +// TestHandleTxConflictsEdgeError verifies that replacement edge writes are +// wrapped after the branch state has been updated. +func TestHandleTxConflictsEdgeError(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord(nil), nil).Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + nil, + ).Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( + nil, + ).Once() + ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + int64(9)).Return(errCreateTxTest).Once() + + err = handleTxConflicts(context.Background(), req, 9, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "record conflict replacement edges") +} + +// TestHandleTxConflictsListError verifies that the helper returns +// descendant-discovery load failures before mutating the branch. +func TestHandleTxConflictsListError(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + nil, errCreateTxTest).Once() + + err = handleTxConflicts(context.Background(), req, 9, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "list create tx conflict candidates") +} + +// TestHandleTxConflictsMarkReplacedError verifies that the helper wraps +// direct-root replacement failures. +func TestHandleTxConflictsMarkReplacedError(t *testing.T) { + t.Parallel() + + req, err := newCreateTxRequest(CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Block: testBlock(77), + Status: TxStatusPublished, + }) + require.NoError(t, err) + + ops := &mockCreateTxOps{} + t.Cleanup(func() { + ops.AssertExpectations(t) + }) + + ops.On("listConflictTxns", mock.Anything, req).Return( + []int64{1}, []chainhash.Hash{{1}}, nil, + ).Once() + ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + []unminedTxRecord(nil), nil).Once() + ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + nil, + ).Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( + errCreateTxTest).Once() + + err = handleTxConflicts(context.Background(), req, 9, ops) + require.ErrorIs(t, err, errCreateTxTest) + require.ErrorContains(t, err, "mark direct conflicts replaced") +} + +// TestValidateUpdateTxState verifies the remaining shared UpdateTx state +// invariants not covered by the integration tests. +func TestValidateUpdateTxState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state UpdateTxState + isCoinbase bool + wantErr error + }{ + { + name: "invalid status", + state: UpdateTxState{Status: TxStatus(99)}, + wantErr: ErrInvalidParam, + }, + { + name: "non coinbase cannot orphan", + state: UpdateTxState{Status: TxStatusOrphaned}, + wantErr: ErrInvalidParam, + }, + { + name: "confirmed must be published", + state: UpdateTxState{ + Status: TxStatusFailed, + Block: testBlock(44), + }, + wantErr: ErrInvalidParam, + }, + { + name: "coinbase patch rejected", + state: UpdateTxState{Status: TxStatusPublished}, + isCoinbase: true, + wantErr: ErrInvalidParam, + }, + { + name: "published confirmed valid", + state: UpdateTxState{ + Status: TxStatusPublished, + Block: testBlock(45), + }, + wantErr: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateUpdateTxState(test.state, test.isCoinbase) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + return + } + + require.NoError(t, err) + }) + } +} From 4d96cfe5be25828b01287e3a96cbfc8b184bcd64 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 7 Apr 2026 22:43:21 +0800 Subject: [PATCH 528/691] wallet: add tx invalidation and corruption itests Add additive integration coverage for unmined invalidation flows and corruption-driven backend failures in the tx store. These cases verify that the public APIs surface the same invalidation, rollback, and query-error behavior the backend wrappers cover in unit tests on top of the current invalidation sentinel names. --- .../db/itest/tx_store_corruption_test.go | 327 ++++++++++++++++++ .../tx_store_invalidate_edge_cases_test.go | 164 +++++++++ 2 files changed, 491 insertions(+) create mode 100644 wallet/internal/db/itest/tx_store_invalidate_edge_cases_test.go diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go index 026cc773d6..5494f31d3c 100644 --- a/wallet/internal/db/itest/tx_store_corruption_test.go +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -3,6 +3,8 @@ package itest import ( + "database/sql" + "fmt" "testing" "time" @@ -12,6 +14,21 @@ import ( "github.com/stretchr/testify/require" ) +// dropTableForCorruption removes one backing table so the public store methods +// surface the backend query errors exercised by the corruption tests. +func dropTableForCorruption(t *testing.T, store interface{ DB() *sql.DB }, + table string) { + t.Helper() + + stmt := fmt.Sprintf("DROP TABLE %s", table) + if _, ok := any(store).(*db.PostgresStore); ok { + stmt += " CASCADE" + } + + _, err := store.DB().ExecContext(t.Context(), stmt) + require.NoError(t, err) +} + // TestGetAndListTxRejectCorruptedStatus verifies that tx reads fail loudly when // the stored status escapes the supported enum. func TestGetAndListTxRejectCorruptedStatus(t *testing.T) { @@ -133,6 +150,84 @@ func TestDeleteTxRejectsCorruptedStatus(t *testing.T) { require.ErrorContains(t, err, "invalid tx status") } +// TestCreateTxRejectsCorruptedExistingStatus verifies that CreateTx surfaces an +// invalid stored status while checking whether the tx hash already exists. +func TestCreateTxRejectsCorruptedExistingStatus(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-corrupted-existing-status") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2350, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000898, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + corruptTransactionStatus(t, store, walletID, tx.TxHash(), 99) + + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000899, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.ErrorContains(t, err, "invalid tx status") +} + +// TestInvalidateUnminedTxRejectsCorruptedStatus verifies that invalidation +// rejects a stored root whose wallet-visible status is corrupted. +func TestInvalidateUnminedTxRejectsCorruptedStatus(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalidate-corrupted-status") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 2400, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000901, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + corruptTransactionStatus(t, store, walletID, tx.TxHash(), 99) + + err = store.InvalidateUnminedTx( + t.Context(), + db.InvalidateUnminedTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + }, + ) + require.ErrorContains(t, err, "invalid tx status") +} + // TestListTxnsRejectsCorruptedUnminedHash verifies that unmined transaction // listings fail when a stored transaction hash cannot be decoded. func TestListTxnsRejectsCorruptedUnminedHash(t *testing.T) { @@ -296,3 +391,235 @@ func TestRollbackToBlockRejectsCorruptedCoinbaseRootHash(t *testing.T) { err = store.RollbackToBlock(t.Context(), coinbaseBlock.Height) require.ErrorContains(t, err, "rollback coinbase hash") } + +// TestCreateTxReturnsQueryErrorWhenTransactionsTableMissing verifies that the +// backend loadExisting path surfaces query errors from the transactions table. +func TestCreateTxReturnsQueryErrorWhenTransactionsTableMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-missing-transactions-table") + + dropTableForCorruption(t, store, "transactions") + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: newRegularTx([]wire.OutPoint{randomOutPoint()}, []*wire.TxOut{{Value: 5500, PkScript: []byte{0x51}}}), + Received: time.Unix(1710001433, 0), + Status: db.TxStatusPending, + }, + ) + require.ErrorContains(t, err, "get tx metadata") +} + +// TestCreateTxReturnsQueryErrorWhenUtxosTableMissing verifies that conflict +// discovery surfaces backend query errors from the UTXO table. +func TestCreateTxReturnsQueryErrorWhenUtxosTableMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-missing-utxos-table") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5600, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001434, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + conflictRoot := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 5500, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: conflictRoot, + Received: time.Unix(1710001435, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + confirmedBlock := CreateBlockFixture(t, store.Queries(), 292) + dropTableForCorruption(t, store, "utxos") + + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 5400, PkScript: []byte{0x52}}}, + ), + Received: time.Unix(1710001436, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }, + ) + require.ErrorContains(t, err, "lookup input conflict") +} + +// TestRollbackToBlockReturnsQueryErrorWhenBlocksTableMissing verifies that the +// backend rollback queries surface database errors when the block table is gone. +func TestRollbackToBlockReturnsQueryErrorWhenBlocksTableMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + dropTableForCorruption(t, store, "blocks") + + err := store.RollbackToBlock(t.Context(), 1) + require.Error(t, err) + + // SQLite still fails while rewinding wallet sync-state heights because + // wallet_sync_states keeps direct block references with ON DELETE RESTRICT. + // PostgreSQL drops those dependent rows with CASCADE when the blocks table is + // removed, so rollback gets far enough to fail on the block delete instead. + _, ok := any(store).(*db.PostgresStore) + if ok { + require.ErrorContains(t, err, "delete blocks at or above height") + return + } + + require.ErrorContains(t, err, "rewind wallet sync state heights") +} + +// TestLeaseAndReleaseReturnQueryErrorsWhenLeaseTablesMissing verifies that the +// backend lease queries surface direct database errors when the lease table has +// been removed. +func TestLeaseAndReleaseReturnQueryErrorsWhenLeaseTablesMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-missing-utxo-lease-table") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + confirmedBlock := CreateBlockFixture(t, store.Queries(), 293) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5700, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001437, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + outPoint := wire.OutPoint{Hash: tx.TxHash(), Index: 0} + leaseID := RandomHash() + + dropTableForCorruption(t, store, "utxo_leases") + + _, err = store.LeaseOutput( + t.Context(), + db.LeaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: outPoint, + Duration: time.Minute, + }, + ) + require.ErrorContains(t, err, "acquire lease row") + + err = store.ReleaseOutput( + t.Context(), + db.ReleaseOutputParams{ + WalletID: walletID, + ID: leaseID, + OutPoint: outPoint, + }, + ) + require.ErrorContains(t, err, "release lease row") +} + +// TestCreateTxRejectsCorruptedConflictRootHash verifies that confirmed conflict +// reconciliation fails loudly when one conflicting unmined root carries an +// invalid stored hash. +func TestCreateTxRejectsCorruptedConflictRootHash(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-create-corrupted-conflict-hash") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + parentTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5400, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: parentTx, + Received: time.Unix(1710001430, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + conflictRoot := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 5300, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: conflictRoot, + Received: time.Unix(1710001431, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + corruptTransactionHash( + t, store, walletID, conflictRoot.TxHash(), []byte{1, 2, 3}, + ) + + confirmedBlock := CreateBlockFixture(t, store.Queries(), 291) + winnerTx := newRegularTx( + []wire.OutPoint{{Hash: parentTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 5200, PkScript: []byte{0x52}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: winnerTx, + Received: time.Unix(1710001432, 0), + Block: &confirmedBlock, + Status: db.TxStatusPublished, + }, + ) + require.ErrorContains(t, err, "tx hash") +} diff --git a/wallet/internal/db/itest/tx_store_invalidate_edge_cases_test.go b/wallet/internal/db/itest/tx_store_invalidate_edge_cases_test.go new file mode 100644 index 0000000000..1c4c1ca7b5 --- /dev/null +++ b/wallet/internal/db/itest/tx_store_invalidate_edge_cases_test.go @@ -0,0 +1,164 @@ +//go:build itest + +package itest + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestInvalidateUnminedTxFailsBranch verifies that invalidating one unmined root +// fails the whole dependent branch and restores the wallet-owned parent output. +func TestInvalidateUnminedTxFailsBranch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalidate-unmined-branch") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + + rootTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5000, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: rootTx, + Received: time.Unix(1710003100, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + childTx := newRegularTx( + []wire.OutPoint{{Hash: rootTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4900, PkScript: []byte{0x51}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: childTx, + Received: time.Unix(1710003110, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + grandchildTx := newRegularTx( + []wire.OutPoint{{Hash: childTx.TxHash(), Index: 0}}, + []*wire.TxOut{{Value: 4800, PkScript: []byte{0x52}}}, + ) + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: grandchildTx, + Received: time.Unix(1710003120, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + err = store.InvalidateUnminedTx( + t.Context(), + db.InvalidateUnminedTxParams{ + WalletID: walletID, + Txid: rootTx.TxHash(), + }, + ) + require.NoError(t, err) + + rootInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: rootTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, rootInfo.Status) + require.Nil(t, rootInfo.Block) + + childInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: childTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, childInfo.Status) + + grandchildInfo, err := store.GetTx( + t.Context(), + db.GetTxQuery{ + WalletID: walletID, + Txid: grandchildTx.TxHash(), + }, + ) + require.NoError(t, err) + require.Equal(t, db.TxStatusFailed, grandchildInfo.Status) + + require.Empty(t, childSpendingTxIDs(t, store, walletID, rootTx.TxHash())) +} + +// TestInvalidateUnminedTxRejectsConfirmedAndMissing verifies the backend load +// paths for confirmed rows and missing tx hashes. +func TestInvalidateUnminedTxRejectsConfirmedAndMissing(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-invalidate-unmined-errors") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + block := CreateBlockFixture(t, store.Queries(), 320) + confirmedTx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 5200, PkScript: addr.ScriptPubKey}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: confirmedTx, + Received: time.Unix(1710003130, 0), + Block: &block, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + err = store.InvalidateUnminedTx( + t.Context(), + db.InvalidateUnminedTxParams{ + WalletID: walletID, + Txid: confirmedTx.TxHash(), + }, + ) + require.ErrorIs(t, err, db.ErrInvalidateTx) + + err = store.InvalidateUnminedTx( + t.Context(), + db.InvalidateUnminedTxParams{ + WalletID: walletID, + Txid: RandomHash(), + }, + ) + require.ErrorIs(t, err, db.ErrTxNotFound) +} From 9d276992e702c1c2e1ecb2323c8abf1b199167e0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:16:47 +0800 Subject: [PATCH 529/691] wallet: add tx store edge-case itests Add additive integration coverage for the remaining CreateTx and UpdateTx edge conditions that were still below the db-only coverage target. These tests keep the branch focused on public store behavior while pushing the split backend files above the current 90 percent goal. --- .../itest/tx_store_create_edge_cases_test.go | 48 ++++++++ .../db/itest/tx_store_edge_cases_test.go | 36 ++++++ .../db/tx_store_backend_error_test.go | 6 +- .../internal/db/tx_store_backend_rows_test.go | 103 +++++++++++++++--- wallet/internal/db/tx_store_common_test.go | 61 ++++++++--- wallet/internal/db/utxo_store_common_test.go | 32 ++++-- 6 files changed, 241 insertions(+), 45 deletions(-) diff --git a/wallet/internal/db/itest/tx_store_create_edge_cases_test.go b/wallet/internal/db/itest/tx_store_create_edge_cases_test.go index 368272d6ab..51b1ce2fc7 100644 --- a/wallet/internal/db/itest/tx_store_create_edge_cases_test.go +++ b/wallet/internal/db/itest/tx_store_create_edge_cases_test.go @@ -100,3 +100,51 @@ func TestCreateTxRejectsDuplicateConfirmedTransaction(t *testing.T) { err = store.CreateTx(t.Context(), params) require.ErrorIs(t, err, db.ErrTxAlreadyExists) } + +// TestCreateTxRejectsMissingConfirmingBlockForExistingUnminedRow verifies that +// re-confirming an existing unmined row still requires the confirming block to +// exist in block history. +func TestCreateTxRejectsMissingConfirmingBlockForExistingUnminedRow(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-missing-confirming-block") + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, "default") + + addr := newDerivedAddress( + t, store, walletID, db.KeyScopeBIP0084, "default", false, + ) + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 4600, PkScript: addr.ScriptPubKey}}, + ) + + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000860, 0), + Status: db.TxStatusPending, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.NoError(t, err) + + err = store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710000861, 0), + Block: &db.Block{ + Hash: RandomHash(), + Height: 999, + Timestamp: time.Unix(1710000862, 0), + }, + Status: db.TxStatusPublished, + Credits: map[uint32]btcutil.Address{0: nil}, + }, + ) + require.ErrorContains(t, err, "require confirming block") +} diff --git a/wallet/internal/db/itest/tx_store_edge_cases_test.go b/wallet/internal/db/itest/tx_store_edge_cases_test.go index 68a7a2864e..b74b0a5d50 100644 --- a/wallet/internal/db/itest/tx_store_edge_cases_test.go +++ b/wallet/internal/db/itest/tx_store_edge_cases_test.go @@ -3,6 +3,7 @@ package itest import ( + "strings" "testing" "time" @@ -200,3 +201,38 @@ func TestTxReadsReturnQueryErrorsWhenClosed(t *testing.T) { ) require.ErrorContains(t, err, "list txns by height") } + +// TestUpdateTxRejectsTooLongLabel verifies that backend label updates surface +// query errors when the requested label exceeds the stored column limit. +func TestUpdateTxRejectsTooLongLabel(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-update-label-too-long") + + tx := newRegularTx( + []wire.OutPoint{randomOutPoint()}, + []*wire.TxOut{{Value: 9100, PkScript: []byte{0x51}}}, + ) + err := store.CreateTx( + t.Context(), + db.CreateTxParams{ + WalletID: walletID, + Tx: tx, + Received: time.Unix(1710001600, 0), + Status: db.TxStatusPending, + }, + ) + require.NoError(t, err) + + label := strings.Repeat("x", 501) + err = store.UpdateTx( + t.Context(), + db.UpdateTxParams{ + WalletID: walletID, + Txid: tx.TxHash(), + Label: &label, + }, + ) + require.ErrorContains(t, err, "update tx label") +} diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go index 330be1e71b..596ae4e83c 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -317,14 +317,16 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }}, map[int64]struct{}{1: {}}) require.ErrorContains(t, err, "tx hash") - _, err = (&pgLeaseOutputOps{}).acquire(t.Context(), LeaseOutputParams{ + leaseOps := &pgLeaseOutputOps{} + + _, err = leaseOps.acquire(t.Context(), LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, }, time.Now(), time.Now().Add(time.Minute)) require.ErrorContains(t, err, "convert output index") - _, err = (&pgLeaseOutputOps{}).hasUtxo(t.Context(), LeaseOutputParams{ + _, err = leaseOps.hasUtxo(t.Context(), LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/tx_store_backend_rows_test.go index a5257c8c92..4908380cb3 100644 --- a/wallet/internal/db/tx_store_backend_rows_test.go +++ b/wallet/internal/db/tx_store_backend_rows_test.go @@ -78,7 +78,9 @@ func newSQLiteRow(t *testing.T, query string, args ...interface{}) *sql.Row { db, err := sql.Open("sqlite", ":memory:") require.NoError(t, err) - t.Cleanup(func() { _ = db.Close() }) + t.Cleanup(func() { + _ = db.Close() + }) return db.QueryRowContext(t.Context(), query, args...) } @@ -90,14 +92,15 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { req := testCreateTxRequest(t) ctx := context.Background() - - _, err := (&pgCreateTxOps{ + loadOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT * FROM missing_table"), }), }, - }).loadExisting(ctx, req) + } + + _, err := loadOps.loadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") block := &Block{ @@ -105,7 +108,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { Height: 7, Timestamp: time.Unix(77, 0), } - err = (&pgCreateTxOps{ + confirmOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow( @@ -116,20 +119,22 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { rows: 0, }), }, - }).confirmExisting(ctx, createTxRequest{ + } + err = confirmOps.confirmExisting(ctx, createTxRequest{ params: CreateTxParams{WalletID: 1, Block: block}, txHash: chainhash.Hash{9}, }, createTxExistingTarget{}) require.ErrorIs(t, err, ErrTxNotFound) - _, _, err = (&pgCreateTxOps{ + conflictOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), }, - }).listConflictTxns(ctx, req) + } + _, _, err = conflictOps.listConflictTxns(ctx, req) require.ErrorContains(t, err, "list unmined txns") } @@ -141,14 +146,15 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { req := testCreateTxRequest(t) ctx := context.Background() - - _, err := (&sqliteCreateTxOps{ + loadOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ row: newSQLiteRow(t, "SELECT * FROM missing_table"), }), }, - }).loadExisting(ctx, req) + } + + _, err := loadOps.loadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") block := &Block{ @@ -156,7 +162,7 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { Height: 8, Timestamp: time.Unix(88, 0), } - err = (&sqliteCreateTxOps{ + confirmOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ row: newSQLiteRow( @@ -167,31 +173,34 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { rows: 0, }), }, - }).confirmExisting(ctx, createTxRequest{ + } + err = confirmOps.confirmExisting(ctx, createTxRequest{ params: CreateTxParams{WalletID: 1, Block: block}, txHash: chainhash.Hash{9}, }, createTxExistingTarget{}) require.ErrorIs(t, err, ErrTxNotFound) - err = (&sqliteCreateTxOps{ + prepareOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ row: newSQLiteRow(t, "SELECT * FROM missing_table"), }), }, - }).prepareBlock(ctx, createTxRequest{ + } + err = prepareOps.prepareBlock(ctx, createTxRequest{ params: CreateTxParams{WalletID: 1, Block: block}, }) require.ErrorContains(t, err, "get block by height") - _, _, err = (&sqliteCreateTxOps{ + conflictOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ row: newSQLiteRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), }, - }).listConflictTxns(ctx, req) + } + _, _, err = conflictOps.listConflictTxns(ctx, req) require.ErrorContains(t, err, "list unmined txns") } @@ -213,3 +222,63 @@ func TestSqliteReleaseOutputOpsAdditionalBranches(t *testing.T) { _, err = ops.activeLockID(context.Background(), 1, 2, time.Now()) require.ErrorContains(t, err, "lookup active lease row") } + +// TestPgUpdateTxOpsAdditionalBranches covers the remaining postgres UpdateTx +// helper branches that are hard to reach through public integration tests +// alone. +func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + txHash := chainhash.Hash{9} + loadOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + })} + stateOps := &pgUpdateTxOps{ + qtx: sqlcpg.New(rowDBTX{rows: 0}), + blockHeight: sql.NullInt32{}, + status: int16(TxStatusPublished), + } + labelOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} + + _, err := loadOps.loadIsCoinbase(ctx, 1, txHash) + require.ErrorContains(t, err, "get tx metadata") + + err = stateOps.updateState(ctx, 1, txHash, UpdateTxState{ + Status: TxStatusPublished, + }) + require.ErrorIs(t, err, ErrTxNotFound) + + err = labelOps.updateLabel(ctx, 1, txHash, "note") + require.ErrorIs(t, err, ErrTxNotFound) +} + +// TestSqliteUpdateTxOpsAdditionalBranches covers the remaining sqlite UpdateTx +// helper branches that are hard to reach through public integration tests +// alone. +func TestSqliteUpdateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + txHash := chainhash.Hash{9} + loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT * FROM missing_table"), + })} + stateOps := &sqliteUpdateTxOps{ + qtx: sqlcsqlite.New(rowDBTX{rows: 0}), + blockHeight: sql.NullInt64{}, + status: int64(TxStatusPublished), + } + labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} + + _, err := loadOps.loadIsCoinbase(ctx, 1, txHash) + require.ErrorContains(t, err, "get tx metadata") + + err = stateOps.updateState(ctx, 1, txHash, UpdateTxState{ + Status: TxStatusPublished, + }) + require.ErrorIs(t, err, ErrTxNotFound) + + err = labelOps.updateLabel(ctx, 1, txHash, "note") + require.ErrorIs(t, err, ErrTxNotFound) +} diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index 64c4e9a854..b3ab861263 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -365,7 +365,9 @@ func TestCreateTxWithOpsInsert(t *testing.T) { ) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadExisting", mock.Anything, req).Return( nil, errCreateTxExistingNotFound).Once() @@ -413,7 +415,9 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { req := testCreateTxRequest(t) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadExisting", mock.Anything, req).Return( &createTxExistingTarget{id: 4}, nil).Once() @@ -444,7 +448,9 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { hasBlock: false, } ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadExisting", mock.Anything, req).Return(&existing, nil).Once() @@ -473,7 +479,9 @@ func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { rootIDs := []int64{5} rootHashes := []chainhash.Hash{{9}} ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadExisting", mock.Anything, req).Return( nil, errCreateTxExistingNotFound).Once() @@ -791,7 +799,9 @@ func TestCollectConflictDescendants(t *testing.T) { }}}, }} ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) rootIDs := []int64{11, 12} rootHashes := []chainhash.Hash{{1}, {2}} @@ -838,7 +848,9 @@ func TestHandleTxConflicts(t *testing.T) { }} ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("listConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{rootHash}, nil, @@ -888,7 +900,9 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { require.NoError(t, err) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("listConflictTxns", mock.Anything, req).Return( []int64{1, 2}, @@ -915,8 +929,8 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { Once() ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}).Return(nil). - Once() + ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}). + Return(nil).Once() ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1, 2}, int64(9)).Return(nil).Once() ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). @@ -943,7 +957,9 @@ func TestHandleTxConflictsNoDescendants(t *testing.T) { require.NoError(t, err) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("listConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, @@ -978,7 +994,9 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { require.NoError(t, err) ops := &mockCreateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("listConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, @@ -1034,7 +1052,9 @@ func TestUpdateTxWithOpsLabelAndState(t *testing.T) { ) ops := &mockUpdateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). Return(false, nil).Once() @@ -1078,10 +1098,12 @@ func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { Txid: chainhash.Hash{1}, } ops := &mockUpdateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) - ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}).Return( - false, nil).Once() + ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + Return(false, nil).Once() err := updateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) @@ -1142,10 +1164,13 @@ func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { State: &test.state, } ops := &mockUpdateTxOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) - ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). - Return(test.isCoinbase, nil).Once() + ops.On( + "loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}, + ).Return(test.isCoinbase, nil).Once() err := updateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 543262213c..88f7129506 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -201,7 +201,9 @@ func TestLeaseOutputWithOps(t *testing.T) { } acquireExpiration := time.Unix(333, 0).In(time.FixedZone("X", 3600)) ops := &mockLeaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) nowMatcher := mock.AnythingOfType("time.Time") ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( @@ -229,7 +231,9 @@ func TestLeaseOutputWithOpsRejectsNonPositiveDuration(t *testing.T) { Duration: 0, } ops := &mockLeaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) _, err := leaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) @@ -249,7 +253,9 @@ func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { Duration: time.Minute, } ops := &mockLeaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) nowMatcher := mock.AnythingOfType("time.Time") ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( @@ -273,7 +279,9 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { Duration: time.Minute, } ops := &mockLeaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) nowMatcher := mock.AnythingOfType("time.Time") ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( @@ -297,7 +305,9 @@ func TestReleaseOutputWithOps(t *testing.T) { ID: [32]byte{9}, } ops := &mockReleaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() @@ -322,7 +332,9 @@ func TestReleaseOutputWithOpsMissingUtxo(t *testing.T) { ID: [32]byte{9}, } ops := &mockReleaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) ops.On("lookupUtxoID", mock.Anything, params).Return( int64(0), errReleaseOutputUtxoNotFound).Once() @@ -342,7 +354,9 @@ func TestReleaseOutputWithOpsWrongLock(t *testing.T) { ID: [32]byte{9}, } ops := &mockReleaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) releaseTimeMatcher := mock.AnythingOfType("time.Time") ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() @@ -369,7 +383,9 @@ func TestReleaseOutputWithOpsMissingActiveLease(t *testing.T) { ID: [32]byte{9}, } ops := &mockReleaseOutputOps{} - t.Cleanup(func() { ops.AssertExpectations(t) }) + t.Cleanup(func() { + ops.AssertExpectations(t) + }) releaseTimeMatcher := mock.AnythingOfType("time.Time") ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() From 92d399273de5d690f62e76450f07c9bc97ce3227 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 08:05:42 +0800 Subject: [PATCH 530/691] db: export shared backend helpers Export the shared helper types, functions, and adapter contracts that both SQL backends already use so handwritten backend files can move into backend-specific packages without introducing import cycles. This keeps the current behavior intact while preparing the later sqlite and pg file-split commits to stay mostly mechanical. This also refreshes the shared db tests that moved with the helper exports so the branch still matches the updated adr-0006-tests base. --- wallet/internal/db/accounts_common.go | 108 ++--- wallet/internal/db/accounts_pg.go | 44 +- wallet/internal/db/accounts_sqlite.go | 44 +- wallet/internal/db/address_types_common.go | 12 +- wallet/internal/db/address_types_pg.go | 6 +- wallet/internal/db/address_types_sqlite.go | 6 +- wallet/internal/db/addresses_common.go | 226 ++++----- wallet/internal/db/addresses_pg.go | 76 +-- wallet/internal/db/addresses_sqlite.go | 74 +-- wallet/internal/db/block_common.go | 4 +- wallet/internal/db/block_pg.go | 8 +- wallet/internal/db/block_sqlite.go | 4 +- .../internal/db/itest/account_store_test.go | 8 +- .../internal/db/itest/address_store_test.go | 24 +- wallet/internal/db/itest/fixtures_pg_test.go | 2 +- .../internal/db/itest/fixtures_sqlite_test.go | 2 +- wallet/internal/db/pg.go | 2 +- .../internal/db/postgres_txstore_createtx.go | 138 +++--- .../internal/db/postgres_txstore_deletetx.go | 34 +- wallet/internal/db/postgres_txstore_gettx.go | 2 +- .../db/postgres_txstore_invalidateunmined.go | 46 +- .../internal/db/postgres_txstore_listtxns.go | 6 +- .../internal/db/postgres_txstore_rollback.go | 46 +- .../internal/db/postgres_txstore_updatetx.go | 20 +- .../internal/db/postgres_utxostore_balance.go | 8 +- .../internal/db/postgres_utxostore_getutxo.go | 8 +- .../db/postgres_utxostore_leaseoutput.go | 22 +- .../postgres_utxostore_listleasedoutputs.go | 4 +- .../db/postgres_utxostore_listutxos.go | 6 +- .../db/postgres_utxostore_releaseoutput.go | 28 +- wallet/internal/db/safecasting.go | 42 +- wallet/internal/db/safecasting_test.go | 26 +- wallet/internal/db/sqlite.go | 2 +- wallet/internal/db/sqlite_txstore_createtx.go | 128 ++--- wallet/internal/db/sqlite_txstore_deletetx.go | 34 +- wallet/internal/db/sqlite_txstore_gettx.go | 2 +- .../db/sqlite_txstore_invalidateunmined.go | 46 +- wallet/internal/db/sqlite_txstore_listtxns.go | 2 +- wallet/internal/db/sqlite_txstore_rollback.go | 38 +- wallet/internal/db/sqlite_txstore_updatetx.go | 20 +- .../internal/db/sqlite_utxostore_balance.go | 8 +- .../internal/db/sqlite_utxostore_getutxo.go | 6 +- .../db/sqlite_utxostore_leaseoutput.go | 18 +- .../db/sqlite_utxostore_listleasedoutputs.go | 4 +- .../internal/db/sqlite_utxostore_listutxos.go | 6 +- .../db/sqlite_utxostore_releaseoutput.go | 26 +- wallet/internal/db/tx.go | 4 +- .../db/tx_store_backend_error_test.go | 100 ++-- .../internal/db/tx_store_backend_rows_test.go | 66 +-- wallet/internal/db/tx_store_common.go | 429 +++++++++-------- wallet/internal/db/tx_store_common_test.go | 447 +++++++++--------- .../db/tx_store_invalidateunmined_common.go | 86 ++-- .../tx_store_invalidateunmined_common_test.go | 160 ++++--- wallet/internal/db/utxo_store_common.go | 88 ++-- wallet/internal/db/utxo_store_common_test.go | 179 +++---- wallet/internal/db/wallet_pg.go | 8 +- wallet/internal/db/wallet_sqlite.go | 6 +- wallet/internal/db/wallets_common.go | 4 +- 58 files changed, 1487 insertions(+), 1516 deletions(-) diff --git a/wallet/internal/db/accounts_common.go b/wallet/internal/db/accounts_common.go index 24324e5786..2bb6317261 100644 --- a/wallet/internal/db/accounts_common.go +++ b/wallet/internal/db/accounts_common.go @@ -9,10 +9,10 @@ import ( ) var ( - // errNilDBAccountNumber is returned when the database returns a nil account + // ErrNilDBAccountNumber is returned when the database returns a nil account // number. In practice, this should never happen, but it's possible if the // database is modified incorrectly or the query is incorrect. - errNilDBAccountNumber = errors.New("database returned nil account number") + ErrNilDBAccountNumber = errors.New("database returned nil account number") // errInvalidAccountOrigin is returned when an account origin ID from the // database does not correspond to a known AccountOrigin value. In practice, @@ -48,9 +48,9 @@ func (params *CreateImportedAccountParams) isWatchOnly() bool { return len(params.EncryptedPrivateKey) == 0 } -// accountPropsRow represents the raw database fields needed to construct +// AccountPropsRow represents the raw database fields needed to construct // AccountProperties. -type accountPropsRow[AddrTypeId, AccOriginId any] struct { +type AccountPropsRow[AddrTypeId, AccOriginId any] struct { AccountNumber sql.NullInt64 AccountName string OriginID AccOriginId @@ -74,17 +74,17 @@ type accountPropsRow[AddrTypeId, AccOriginId any] struct { func getKeyCounts(external, internal, imported int64) (uint32, uint32, uint32, error) { - externalKeyCount, err := int64ToUint32(external) + externalKeyCount, err := Int64ToUint32(external) if err != nil { return 0, 0, 0, fmt.Errorf("external key count: %w", err) } - internalKeyCount, err := int64ToUint32(internal) + internalKeyCount, err := Int64ToUint32(internal) if err != nil { return 0, 0, 0, fmt.Errorf("internal key count: %w", err) } - importedKeyCount, err := int64ToUint32(imported) + importedKeyCount, err := Int64ToUint32(imported) if err != nil { return 0, 0, 0, fmt.Errorf("imported key count: %w", err) } @@ -95,7 +95,7 @@ func getKeyCounts(external, internal, imported int64) (uint32, uint32, // getAddrTypes extracts the internal and external address types from the row // and handles errors. func getAddrTypes[AddrTypeId, AccOriginId any]( - row accountPropsRow[AddrTypeId, AccOriginId]) (AddressType, AddressType, + row AccountPropsRow[AddrTypeId, AccOriginId]) (AddressType, AddressType, error) { internalType, err := row.IDToAddrType(row.InternalTypeID) @@ -111,18 +111,18 @@ func getAddrTypes[AddrTypeId, AccOriginId any]( return internalType, externalType, nil } -// accountPropsRowToProps converts a database row containing full account +// AccountPropsRowToProps converts a database row containing full account // properties into an AccountProperties struct. The idToAddrType function is // used to convert the internal and external address type IDs to AddressType // values. -func accountPropsRowToProps[AddrTypeId, AccOriginId any]( - row accountPropsRow[AddrTypeId, AccOriginId]) (*AccountProperties, error) { +func AccountPropsRowToProps[AddrTypeId, AccOriginId any]( + row AccountPropsRow[AddrTypeId, AccOriginId]) (*AccountProperties, error) { var accountNum uint32 if row.AccountNumber.Valid { var err error - accountNum, err = int64ToUint32(row.AccountNumber.Int64) + accountNum, err = Int64ToUint32(row.AccountNumber.Int64) if err != nil { return nil, fmt.Errorf("account number: %w", err) } @@ -133,12 +133,12 @@ func accountPropsRowToProps[AddrTypeId, AccOriginId any]( return nil, fmt.Errorf("origin: %w", err) } - purposeNum, err := int64ToUint32(row.Purpose) + purposeNum, err := Int64ToUint32(row.Purpose) if err != nil { return nil, fmt.Errorf("purpose: %w", err) } - coinTypeNum, err := int64ToUint32(row.CoinType) + coinTypeNum, err := Int64ToUint32(row.CoinType) if err != nil { return nil, fmt.Errorf("coin type: %w", err) } @@ -150,7 +150,7 @@ func accountPropsRowToProps[AddrTypeId, AccOriginId any]( var fingerprint uint32 if row.MasterFingerprint.Valid { - fingerprint, err = int64ToUint32(row.MasterFingerprint.Int64) + fingerprint, err = Int64ToUint32(row.MasterFingerprint.Int64) if err != nil { return nil, fmt.Errorf("master fingerprint: %w", err) } @@ -203,9 +203,9 @@ func accountInfosFromRows[T any](rows []T, return accounts, nil } -// listAccounts is a generic helper that retrieves accounts using the provided +// ListAccounts is a generic helper that retrieves accounts using the provided // list function and converts the results to AccountInfo structs. -func listAccounts[T any, Args any](ctx context.Context, +func ListAccounts[T any, Args any](ctx context.Context, lister func(context.Context, Args) ([]T, error), args Args, toInfo func(T) (*AccountInfo, error)) ([]AccountInfo, error) { @@ -229,12 +229,12 @@ func getAddrSchemaForScope(scope KeyScope) (ScopeAddrSchema, error) { return addrSchema, nil } -// buildAccountInfo creates an AccountInfo with the provided values and zeroed +// BuildAccountInfo creates an AccountInfo with the provided values and zeroed // balances while we do not yet support balance tracking. // // TODO(stingelin): Add balance tracking support after transaction management is // implemented. -func buildAccountInfo(accountNum uint32, accountName string, +func BuildAccountInfo(accountNum uint32, accountName string, origin AccountOrigin, externalKeyCount, internalKeyCount, importedKeyCount uint32, isWatchOnly bool, createdAt time.Time, scope KeyScope) *AccountInfo { @@ -254,9 +254,9 @@ func buildAccountInfo(accountNum uint32, accountName string, } } -// idToAccountOrigin safely converts an integer to AccountOrigin. It returns an +// IDToAccountOrigin safely converts an integer to AccountOrigin. It returns an // error if the value does not correspond to a known AccountOrigin value. -func idToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { +func IDToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { if v < 0 || v > T(ImportedAccount) { return 0, fmt.Errorf("%w: %d", errInvalidAccountOrigin, v) } @@ -264,9 +264,9 @@ func idToAccountOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { return AccountOrigin(v), nil } -// accountInfoRow represents the raw database fields needed to construct +// AccountInfoRow represents the raw database fields needed to construct // AccountInfo. -type accountInfoRow[AccOriginId any] struct { +type AccountInfoRow[AccOriginId any] struct { AccountNumber sql.NullInt64 AccountName string OriginID AccOriginId @@ -280,16 +280,16 @@ type accountInfoRow[AccOriginId any] struct { IDToOriginType func(AccOriginId) (AccountOrigin, error) } -// accountRowToInfo converts raw database field values into an AccountInfo +// AccountRowToInfo converts raw database field values into an AccountInfo // struct. It handles type conversion and validation for each field. -func accountRowToInfo[AccOriginId any]( - row accountInfoRow[AccOriginId]) (*AccountInfo, error) { +func AccountRowToInfo[AccOriginId any]( + row AccountInfoRow[AccOriginId]) (*AccountInfo, error) { var accountNum uint32 if row.AccountNumber.Valid { var err error - accountNum, err = int64ToUint32(row.AccountNumber.Int64) + accountNum, err = Int64ToUint32(row.AccountNumber.Int64) if err != nil { return nil, fmt.Errorf("account number: %w", err) } @@ -300,12 +300,12 @@ func accountRowToInfo[AccOriginId any]( return nil, fmt.Errorf("origin: %w", err) } - purposeNum, err := int64ToUint32(row.Purpose) + purposeNum, err := Int64ToUint32(row.Purpose) if err != nil { return nil, fmt.Errorf("purpose: %w", err) } - coinTypeNum, err := int64ToUint32(row.CoinType) + coinTypeNum, err := Int64ToUint32(row.CoinType) if err != nil { return nil, fmt.Errorf("coin type: %w", err) } @@ -317,16 +317,16 @@ func accountRowToInfo[AccOriginId any]( return nil, err } - return buildAccountInfo( + return BuildAccountInfo( accountNum, row.AccountName, origin, externalKeyCount, internalKeyCount, importedKeyCount, row.IsWatchOnly, row.CreatedAt, KeyScope{Purpose: purposeNum, Coin: coinTypeNum}, ), nil } -// ensureKeyScope retrieves an existing key scope or creates it if missing. It +// EnsureKeyScope retrieves an existing key scope or creates it if missing. It // returns the scope ID once available. -func ensureKeyScope[Row any, GetArgs any, CreateArgs any]( +func EnsureKeyScope[Row any, GetArgs any, CreateArgs any]( ctx context.Context, getter func(context.Context, GetArgs) (Row, error), getArgs GetArgs, creator func(context.Context, CreateArgs) (int64, error), createArgs func(ScopeAddrSchema) CreateArgs, rowToID func(Row) int64, @@ -373,13 +373,13 @@ func ensureKeyScope[Row any, GetArgs any, CreateArgs any]( return rowToID(scopeInfo), nil } -// getAccountFunc defines a function signature for retrieving a single account. -type getAccountFunc func(context.Context, GetAccountQuery) (*AccountInfo, error) +// GetAccountFunc defines a function signature for retrieving a single account. +type GetAccountFunc func(context.Context, GetAccountQuery) (*AccountInfo, error) -// getAccountByQuery dispatches to the appropriate query based on the provided +// GetAccountByQuery dispatches to the appropriate query based on the provided // account identifier. -func getAccountByQuery(ctx context.Context, query GetAccountQuery, - getByNumber getAccountFunc, getByName getAccountFunc) (*AccountInfo, +func GetAccountByQuery(ctx context.Context, query GetAccountQuery, + getByNumber GetAccountFunc, getByName GetAccountFunc) (*AccountInfo, error) { switch { @@ -394,16 +394,16 @@ func getAccountByQuery(ctx context.Context, query GetAccountQuery, } } -// listAccountsFunc defines a function signature for listing accounts. -type listAccountsFunc func(context.Context, ListAccountsQuery) ([]AccountInfo, +// ListAccountsFunc defines a function signature for listing accounts. +type ListAccountsFunc func(context.Context, ListAccountsQuery) ([]AccountInfo, error) -// listAccountsByQuery dispatches to the appropriate list query based on the +// ListAccountsByQuery dispatches to the appropriate list query based on the // provided filters. It returns an error if both scope and name filters are // provided, as they are mutually exclusive. -func listAccountsByQuery(ctx context.Context, query ListAccountsQuery, - listByScope listAccountsFunc, listByName listAccountsFunc, - listAll listAccountsFunc) ([]AccountInfo, error) { +func ListAccountsByQuery(ctx context.Context, query ListAccountsQuery, + listByScope ListAccountsFunc, listByName ListAccountsFunc, + listAll ListAccountsFunc) ([]AccountInfo, error) { switch { case query.Scope != nil && query.Name != nil: @@ -420,13 +420,13 @@ func listAccountsByQuery(ctx context.Context, query ListAccountsQuery, } } -// renameAccountFunc defines a function signature for renaming an account. -type renameAccountFunc func(context.Context, RenameAccountParams) error +// RenameAccountFunc defines a function signature for renaming an account. +type RenameAccountFunc func(context.Context, RenameAccountParams) error -// renameAccountByQuery dispatches to the appropriate rename query based on the +// RenameAccountByQuery dispatches to the appropriate rename query based on the // provided account identifier (either account number or old name). -func renameAccountByQuery(ctx context.Context, params RenameAccountParams, - renameByNumber renameAccountFunc, renameByName renameAccountFunc) error { +func RenameAccountByQuery(ctx context.Context, params RenameAccountParams, + renameByNumber RenameAccountFunc, renameByName RenameAccountFunc) error { if params.NewName == "" { return ErrMissingAccountName @@ -444,10 +444,10 @@ func renameAccountByQuery(ctx context.Context, params RenameAccountParams, } } -// getAccount is a generic helper that retrieves an account using the provided +// GetAccount is a generic helper that retrieves an account using the provided // query function. It handles error mapping and delegates conversion to the // toInfo function. -func getAccount[T any, Args any](ctx context.Context, +func GetAccount[T any, Args any](ctx context.Context, getter func(context.Context, Args) (T, error), args Args, query GetAccountQuery, toInfo func(T) (*AccountInfo, error)) (*AccountInfo, error) { @@ -471,10 +471,10 @@ func getAccount[T any, Args any](ctx context.Context, ErrAccountNotFound) } -// renameAccount is a generic helper that updates an account name using the +// RenameAccount is a generic helper that updates an account name using the // provided update function. It checks rows affected and returns an error if // the account was not found. -func renameAccount[Args any](ctx context.Context, +func RenameAccount[Args any](ctx context.Context, update func(context.Context, Args) (int64, error), args Args, params RenameAccountParams) error { @@ -496,11 +496,11 @@ func renameAccount[Args any](ctx context.Context, params.Scope.Purpose, params.Scope.Coin, ErrAccountNotFound) } -// createImportedAccount is a generic helper that creates an imported account. +// CreateImportedAccount is a generic helper that creates an imported account. // It handles ensuring the key scope exists, creating the account record, // optionally creating the account secret for non-watch-only accounts, and // fetching the full account properties from the database. -func createImportedAccount[CreateArgs any, CreateRow any, SecretArgs any]( +func CreateImportedAccount[CreateArgs any, CreateRow any, SecretArgs any]( ctx context.Context, params CreateImportedAccountParams, ensureScope func() (int64, error), createAccount func(context.Context, CreateArgs) (CreateRow, error), diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index e42f494cb7..72c2f54860 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -18,7 +18,7 @@ func (s *PostgresStore) GetAccount(ctx context.Context, getQueries := pgAccountGetQueries{q: s.queries} - return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally @@ -28,7 +28,7 @@ func (s *PostgresStore) ListAccounts(ctx context.Context, listQueries := pgAccountListQueries{q: s.queries} - return listAccountsByQuery( + return ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, ) } @@ -40,7 +40,7 @@ func (s *PostgresStore) RenameAccount(ctx context.Context, renameQueries := pgAccountRenameQueries{q: s.queries} - return renameAccountByQuery( + return RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, ) } @@ -91,15 +91,15 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, if !row.AccountNumber.Valid { // This should never happen unless the query is modified // incorrectly. - return errNilDBAccountNumber + return ErrNilDBAccountNumber } - accNumber, err := int64ToUint32(row.AccountNumber.Int64) + accNumber, err := Int64ToUint32(row.AccountNumber.Int64) if err != nil { return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) } - info = buildAccountInfo( + info = BuildAccountInfo( accNumber, params.Name, DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -125,7 +125,7 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { var err error - props, err = createImportedAccount( + props, err = CreateImportedAccount( ctx, params, func() (int64, error) { return pgEnsureKeyScope(ctx, qtx, params.WalletID, params.Scope) }, qtx.CreateImportedAccount, @@ -193,7 +193,7 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, return nil, fmt.Errorf("get account props: %w", err) } - return accountPropsRowToProps(accountPropsRow[int16, int16]{ + return AccountPropsRowToProps(AccountPropsRow[int16, int16]{ AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, @@ -208,8 +208,8 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, CoinType: row.CoinType, InternalTypeID: row.InternalTypeID, ExternalTypeID: row.ExternalTypeID, - IDToAddrType: idToAddressType[int16], - IDToOriginType: idToAccountOrigin[int16], + IDToAddrType: IDToAddressType[int16], + IDToOriginType: IDToAccountOrigin[int16], }) } @@ -218,7 +218,7 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, scope KeyScope) (int64, error) { - return ensureKeyScope( + return EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, sqlcpg.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), @@ -266,7 +266,7 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAccountByScopeAndNameRow(row) - return accountRowToInfo(accountInfoRow[int16]{ + return AccountRowToInfo(AccountInfoRow[int16]{ AccountNumber: base.AccountNumber, AccountName: base.AccountName, OriginID: base.OriginID, @@ -277,7 +277,7 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { CreatedAt: base.CreatedAt, Purpose: base.Purpose, CoinType: base.CoinType, - IDToOriginType: idToAccountOrigin[int16], + IDToOriginType: IDToAccountOrigin[int16], }) } @@ -290,7 +290,7 @@ type pgAccountListQueries struct { func (p pgAccountListQueries) byScope(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, p.q.ListAccountsByWalletScope, sqlcpg.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), @@ -304,7 +304,7 @@ func (p pgAccountListQueries) byScope(ctx context.Context, func (p pgAccountListQueries) byName(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, p.q.ListAccountsByWalletAndName, sqlcpg.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), @@ -317,7 +317,7 @@ func (p pgAccountListQueries) byName(ctx context.Context, func (p pgAccountListQueries) all(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, p.q.ListAccountsByWallet, int64(query.WalletID), pgAccountRowToInfo, ) @@ -332,13 +332,13 @@ type pgAccountGetQueries struct { func (p pgAccountGetQueries) byNumber(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - return getAccount( + return GetAccount( ctx, p.q.GetAccountByWalletScopeAndNumber, sqlcpg.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - AccountNumber: nullableUint32ToSQLInt64(query.AccountNumber), + AccountNumber: NullableUint32ToSQLInt64(query.AccountNumber), }, query, pgAccountRowToInfo, ) } @@ -347,7 +347,7 @@ func (p pgAccountGetQueries) byNumber(ctx context.Context, func (p pgAccountGetQueries) byName(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - return getAccount( + return GetAccount( ctx, p.q.GetAccountByWalletScopeAndName, sqlcpg.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), @@ -368,14 +368,14 @@ type pgAccountRenameQueries struct { func (p pgAccountRenameQueries) byNumber(ctx context.Context, params RenameAccountParams) error { - return renameAccount( + return RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), CoinType: int64(params.Scope.Coin), - AccountNumber: nullableUint32ToSQLInt64(params.AccountNumber), + AccountNumber: NullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } @@ -385,7 +385,7 @@ func (p pgAccountRenameQueries) byNumber(ctx context.Context, func (p pgAccountRenameQueries) byName(ctx context.Context, params RenameAccountParams) error { - return renameAccount( + return RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndName, sqlcpg.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/accounts_sqlite.go index d73d0f18cc..d61a46111d 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/accounts_sqlite.go @@ -18,7 +18,7 @@ func (s *SqliteStore) GetAccount(ctx context.Context, getQueries := sqliteAccountGetQueries{q: s.queries} - return getAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally @@ -28,7 +28,7 @@ func (s *SqliteStore) ListAccounts(ctx context.Context, listQueries := sqliteAccountListQueries{q: s.queries} - return listAccountsByQuery( + return ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, ) } @@ -40,7 +40,7 @@ func (s *SqliteStore) RenameAccount(ctx context.Context, renameQueries := sqliteAccountRenameQueries{q: s.queries} - return renameAccountByQuery( + return RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, ) } @@ -81,15 +81,15 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, if !row.AccountNumber.Valid { // This should never happen unless the query is modified // incorrectly. - return errNilDBAccountNumber + return ErrNilDBAccountNumber } - accNumber, err := int64ToUint32(row.AccountNumber.Int64) + accNumber, err := Int64ToUint32(row.AccountNumber.Int64) if err != nil { return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) } - info = buildAccountInfo( + info = BuildAccountInfo( accNumber, params.Name, DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -108,7 +108,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, scope KeyScope) (int64, error) { - return ensureKeyScope( + return EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), @@ -145,7 +145,7 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { var err error - props, err = createImportedAccount( + props, err = CreateImportedAccount( ctx, params, func() (int64, error) { return sqliteEnsureKeyScope( @@ -219,7 +219,7 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, return nil, fmt.Errorf("get account props: %w", err) } - return accountPropsRowToProps(accountPropsRow[int64, int64]{ + return AccountPropsRowToProps(AccountPropsRow[int64, int64]{ AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, @@ -234,8 +234,8 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, CoinType: row.CoinType, InternalTypeID: row.InternalTypeID, ExternalTypeID: row.ExternalTypeID, - IDToAddrType: idToAddressType[int64], - IDToOriginType: idToAccountOrigin[int64], + IDToAddrType: IDToAddressType[int64], + IDToOriginType: IDToAccountOrigin[int64], }) } @@ -262,7 +262,7 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, // identical fields. If sqlc types diverge, compilation will fail. base := sqlcsqlite.GetAccountByScopeAndNameRow(row) - return accountRowToInfo(accountInfoRow[int64]{ + return AccountRowToInfo(AccountInfoRow[int64]{ AccountNumber: base.AccountNumber, AccountName: base.AccountName, OriginID: base.OriginID, @@ -273,7 +273,7 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, CreatedAt: base.CreatedAt, Purpose: base.Purpose, CoinType: base.CoinType, - IDToOriginType: idToAccountOrigin[int64], + IDToOriginType: IDToAccountOrigin[int64], }) } @@ -286,7 +286,7 @@ type sqliteAccountListQueries struct { func (s sqliteAccountListQueries) byScope(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, s.q.ListAccountsByWalletScope, sqlcsqlite.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), @@ -300,7 +300,7 @@ func (s sqliteAccountListQueries) byScope(ctx context.Context, func (s sqliteAccountListQueries) byName(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, s.q.ListAccountsByWalletAndName, sqlcsqlite.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), @@ -313,7 +313,7 @@ func (s sqliteAccountListQueries) byName(ctx context.Context, func (s sqliteAccountListQueries) all(ctx context.Context, query ListAccountsQuery) ([]AccountInfo, error) { - return listAccounts( + return ListAccounts( ctx, s.q.ListAccountsByWallet, int64(query.WalletID), sqliteAccountRowToInfo, ) @@ -328,13 +328,13 @@ type sqliteAccountGetQueries struct { func (s sqliteAccountGetQueries) byNumber(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - return getAccount( + return GetAccount( ctx, s.q.GetAccountByWalletScopeAndNumber, sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - AccountNumber: nullableUint32ToSQLInt64(query.AccountNumber), + AccountNumber: NullableUint32ToSQLInt64(query.AccountNumber), }, query, sqliteAccountRowToInfo, ) } @@ -343,7 +343,7 @@ func (s sqliteAccountGetQueries) byNumber(ctx context.Context, func (s sqliteAccountGetQueries) byName(ctx context.Context, query GetAccountQuery) (*AccountInfo, error) { - return getAccount(ctx, s.q.GetAccountByWalletScopeAndName, + return GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), @@ -363,14 +363,14 @@ type sqliteAccountRenameQueries struct { func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, params RenameAccountParams) error { - return renameAccount( + return RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), CoinType: int64(params.Scope.Coin), - AccountNumber: nullableUint32ToSQLInt64(params.AccountNumber), + AccountNumber: NullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } @@ -380,7 +380,7 @@ func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, func (s sqliteAccountRenameQueries) byName(ctx context.Context, params RenameAccountParams) error { - return renameAccount( + return RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndName, sqlcsqlite.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, diff --git a/wallet/internal/db/address_types_common.go b/wallet/internal/db/address_types_common.go index 044f245203..9914c66f42 100644 --- a/wallet/internal/db/address_types_common.go +++ b/wallet/internal/db/address_types_common.go @@ -13,9 +13,9 @@ import ( // incorrect. var errInvalidAddressType = errors.New("invalid address type") -// idToAddressType safely converts an integer to AddressType. It returns an +// IDToAddressType safely converts an integer to AddressType. It returns an // error if the value does not correspond to a known AddressType value. -func idToAddressType[T ~int16 | ~int64](v T) (AddressType, error) { +func IDToAddressType[T ~int16 | ~int64](v T) (AddressType, error) { if v < 0 || v > T(Anchor) { return 0, fmt.Errorf("%w: %d", errInvalidAddressType, v) } @@ -23,9 +23,9 @@ func idToAddressType[T ~int16 | ~int64](v T) (AddressType, error) { return AddressType(v), nil } -// listAddressTypes is a generic helper that retrieves all address types from +// ListAddressTypes is a generic helper that retrieves all address types from // the database and converts them to AddressTypeInfo structs. -func listAddressTypes[Row any](ctx context.Context, +func ListAddressTypes[Row any](ctx context.Context, lister func(context.Context) ([]Row, error), toInfo func(Row) (AddressTypeInfo, error)) ([]AddressTypeInfo, error) { @@ -48,10 +48,10 @@ func listAddressTypes[Row any](ctx context.Context, return types, nil } -// getAddressTypeByID is a generic helper that retrieves a single address type +// GetAddressTypeByID is a generic helper that retrieves a single address type // by its ID and converts it to an AddressTypeInfo struct. It returns // ErrAddressTypeNotFound if no matching type is found. -func getAddressTypeByID[Row any, ID any](ctx context.Context, +func GetAddressTypeByID[Row any, ID any](ctx context.Context, getter func(context.Context, ID) (Row, error), queryID ID, id AddressType, toInfo func(Row) (AddressTypeInfo, error)) (AddressTypeInfo, error) { diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/address_types_pg.go index 52ee3d6111..f033ef9241 100644 --- a/wallet/internal/db/address_types_pg.go +++ b/wallet/internal/db/address_types_pg.go @@ -9,7 +9,7 @@ import ( // pgAddressTypeRowToInfo converts a PostgreSQL address type row to an // AddressTypeInfo struct. func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { - addrType, err := idToAddressType(row.ID) + addrType, err := IDToAddressType(row.ID) if err != nil { return AddressTypeInfo{}, err } @@ -25,7 +25,7 @@ func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { - return listAddressTypes( + return ListAddressTypes( ctx, s.queries.ListAddressTypes, pgAddressTypeRowToInfo, ) } @@ -35,7 +35,7 @@ func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( func (s *PostgresStore) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { - return getAddressTypeByID( + return GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int16(id), id, pgAddressTypeRowToInfo, ) diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/address_types_sqlite.go index 7120c86cde..28aaaf0433 100644 --- a/wallet/internal/db/address_types_sqlite.go +++ b/wallet/internal/db/address_types_sqlite.go @@ -11,7 +11,7 @@ import ( func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, error) { - addrType, err := idToAddressType(row.ID) + addrType, err := IDToAddressType(row.ID) if err != nil { return AddressTypeInfo{}, err } @@ -27,7 +27,7 @@ func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( []AddressTypeInfo, error) { - return listAddressTypes( + return ListAddressTypes( ctx, s.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, ) } @@ -37,7 +37,7 @@ func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( func (s *SqliteStore) GetAddressType(ctx context.Context, id AddressType) (AddressTypeInfo, error) { - return getAddressTypeByID( + return GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int64(id), id, sqliteAddressTypeRowToInfo, ) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index e3e059d531..db022a33a1 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -27,41 +27,41 @@ var ( errInvalidDerivationPath = errors.New("invalid derivation path") ) -// accountLookupKey contains the fields needed to look up an account. -type accountLookupKey struct { - walletID int64 - purpose int64 - coinType int64 - accountName string +// AccountLookupKey contains the fields needed to look up an account. +type AccountLookupKey struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountName string } -// accountKeyFromParams extracts account lookup fields from params. -func accountKeyFromParams(params NewDerivedAddressParams) accountLookupKey { - return accountLookupKey{ - walletID: int64(params.WalletID), - purpose: int64(params.Scope.Purpose), - coinType: int64(params.Scope.Coin), - accountName: params.AccountName, +// AccountKeyFromParams extracts account lookup fields from params. +func AccountKeyFromParams(params NewDerivedAddressParams) AccountLookupKey { + return AccountLookupKey{ + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountName: params.AccountName, } } -// accountKeyFromImportedParams extracts account lookup fields from imported +// AccountKeyFromImportedParams extracts account lookup fields from imported // params using DefaultImportedAccountName. -func accountKeyFromImportedParams( - params NewImportedAddressParams) accountLookupKey { +func AccountKeyFromImportedParams( + params NewImportedAddressParams) AccountLookupKey { - return accountLookupKey{ - walletID: int64(params.WalletID), - purpose: int64(params.Scope.Purpose), - coinType: int64(params.Scope.Coin), - accountName: DefaultImportedAccountName, + return AccountLookupKey{ + WalletID: int64(params.WalletID), + Purpose: int64(params.Scope.Purpose), + CoinType: int64(params.Scope.Coin), + AccountName: DefaultImportedAccountName, } } -// getAddressSecret is a generic helper that retrieves address secret +// GetAddressSecret is a generic helper that retrieves address secret // information using the provided getter function and converts it to an // AddressSecret with error handling. -func getAddressSecret[Row any](ctx context.Context, +func GetAddressSecret[Row any](ctx context.Context, getter func(context.Context, int64) (Row, error), addressID uint32, toSecret func(Row) (*AddressSecret, error)) (*AddressSecret, error) { @@ -101,9 +101,9 @@ func (p NewImportedAddressParams) isWatchOnly() bool { return false } -// idToOrigin safely converts an integer to AccountOrigin. It returns an error +// IDToOrigin safely converts an integer to AccountOrigin. It returns an error // if the value is outside [DerivedAccount, ImportedAccount]. -func idToOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { +func IDToOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { if v < 0 || v > T(ImportedAccount) { return 0, fmt.Errorf("address origin: %d: %w", v, errInvalidOriginID) } @@ -111,10 +111,10 @@ func idToOrigin[T ~int16 | ~int64](v T) (AccountOrigin, error) { return AccountOrigin(uint8(v)), nil } -// addressInfoRow captures common fields from all address row types across +// AddressInfoRow captures common fields from all address row types across // PostgreSQL and SQLite backends. Uses generic type parameters to handle // different ID types (int16 for PostgreSQL, int64 for SQLite). -type addressInfoRow[TypeID, OriginIDType any] struct { +type AddressInfoRow[TypeID, OriginIDType any] struct { // ID is the database unique identifier for the address. ID int64 @@ -158,9 +158,9 @@ type addressInfoRow[TypeID, OriginIDType any] struct { IDToOrigin func(OriginIDType) (AccountOrigin, error) } -// addressSecretRow captures fields shared by address secret row types across +// AddressSecretRow captures fields shared by address secret row types across // backends. -type addressSecretRow struct { +type AddressSecretRow struct { // AddressID is the database unique identifier for the address. AddressID int64 @@ -171,9 +171,9 @@ type addressSecretRow struct { EncryptedScript []byte } -// addressSecretRowToSecret converts raw secret row fields into an AddressSecret +// AddressSecretRowToSecret converts raw secret row fields into an AddressSecret // with validation and ID conversion. -func addressSecretRowToSecret(row addressSecretRow) (*AddressSecret, error) { +func AddressSecretRowToSecret(row AddressSecretRow) (*AddressSecret, error) { hasKey := len(row.EncryptedPrivKey) > 0 hasScript := len(row.EncryptedScript) > 0 @@ -182,7 +182,7 @@ func addressSecretRowToSecret(row addressSecretRow) (*AddressSecret, error) { ErrSecretNotFound) } - addrID, err := int64ToUint32(row.AddressID) + addrID, err := Int64ToUint32(row.AddressID) if err != nil { return nil, fmt.Errorf("address ID: %w", err) } @@ -197,12 +197,12 @@ func addressSecretRowToSecret(row addressSecretRow) (*AddressSecret, error) { // convertAddressIDs converts database IDs to their respective uint32 values // with error handling. func convertAddressIDs(id, accountID int64) (uint32, uint32, error) { - addrID, err := int64ToUint32(id) + addrID, err := Int64ToUint32(id) if err != nil { return 0, 0, fmt.Errorf("address ID: %w", err) } - acctID, err := int64ToUint32(accountID) + acctID, err := Int64ToUint32(accountID) if err != nil { return 0, 0, fmt.Errorf("account ID: %w", err) } @@ -254,7 +254,7 @@ func newImportedAddressTx[QTX any, Row any, CreateArgs any, InsertArgs any]( // convertAddressMetadata converts address type and origin IDs with error // handling. func convertAddressMetadata[TypeID, OriginIDType any]( - row addressInfoRow[TypeID, OriginIDType]) (AddressType, AccountOrigin, + row AddressInfoRow[TypeID, OriginIDType]) (AddressType, AccountOrigin, error) { addrType, err := row.IDToAddrType(row.TypeID) @@ -288,12 +288,12 @@ func convertAddressPath(origin AccountOrigin, branch, return 0, 0, errInvalidDerivationPath } - addrBranch, err := int64ToUint32(branch.Int64) + addrBranch, err := Int64ToUint32(branch.Int64) if err != nil { return 0, 0, fmt.Errorf("address branch: %w", err) } - addrIndex, err := int64ToUint32(index.Int64) + addrIndex, err := Int64ToUint32(index.Int64) if err != nil { return 0, 0, fmt.Errorf("address index: %w", err) } @@ -304,7 +304,7 @@ func convertAddressPath(origin AccountOrigin, branch, // addressRowToInfo converts raw database field values into an AddressInfo // struct. It handles type conversion and validation for each field. func addressRowToInfo[TypeID, OriginIDType any]( - row addressInfoRow[TypeID, OriginIDType]) (*AddressInfo, error) { + row AddressInfoRow[TypeID, OriginIDType]) (*AddressInfo, error) { id, accountID, err := convertAddressIDs(row.ID, row.AccountID) if err != nil { @@ -340,10 +340,10 @@ func addressRowToInfo[TypeID, OriginIDType any]( }, nil } -// getAddress is a generic helper that retrieves a single address using the +// GetAddress is a generic helper that retrieves a single address using the // provided getter function. It handles sql.ErrNoRows mapping and delegates // conversion to the toInfo function. -func getAddress[T any, Args any](ctx context.Context, +func GetAddress[T any, Args any](ctx context.Context, getter func(context.Context, Args) (T, error), args Args, toInfo func(T) (*AddressInfo, error)) (*AddressInfo, error) { @@ -359,9 +359,9 @@ func getAddress[T any, Args any](ctx context.Context, return nil, ErrAddressNotFound } -// nextListAddressesQuery returns a query with its pagination cursor advanced to +// NextListAddressesQuery returns a query with its pagination cursor advanced to // the provided value. -func nextListAddressesQuery(q ListAddressesQuery, +func NextListAddressesQuery(q ListAddressesQuery, cursor uint32) ListAddressesQuery { q.Page = q.Page.WithAfter(cursor) @@ -369,78 +369,78 @@ func nextListAddressesQuery(q ListAddressesQuery, return q } -// derivedAddressAdapters groups the functions needed to create a +// DerivedAddressAdapters groups the functions needed to create a // derived address across different database backends. -type derivedAddressAdapters[QTX any, AccountRow any, AccountParams any, +type DerivedAddressAdapters[QTX any, AccountRow any, AccountParams any, AddrRow any] struct { - // getAccount retrieves an account by the provided parameters. - getAccount func(context.Context, AccountParams) (AccountRow, error) + // GetAccount retrieves an account by the provided parameters. + GetAccount func(context.Context, AccountParams) (AccountRow, error) - // accountParams converts params to account lookup parameters. - accountParams func(NewDerivedAddressParams) AccountParams + // AccountParams converts params to account lookup parameters. + AccountParams func(NewDerivedAddressParams) AccountParams - // getAccountID extracts the account ID from an account row. - getAccountID func(AccountRow) int64 + // GetAccountID extracts the account ID from an account row. + GetAccountID func(AccountRow) int64 - // getExtIndex returns a function to get the external index. - getExtIndex func(QTX) func(context.Context, int64) (int64, error) + // GetExtIndex returns a function to get the external index. + GetExtIndex func(QTX) func(context.Context, int64) (int64, error) - // getIntIndex returns a function to get the internal index. - getIntIndex func(QTX) func(context.Context, int64) (int64, error) + // GetIntIndex returns a function to get the internal index. + GetIntIndex func(QTX) func(context.Context, int64) (int64, error) - // createAddr returns a function to create an address row. - createAddr func(QTX) func(context.Context, int64, AddressType, uint32, + // CreateAddr returns a function to create an address row. + CreateAddr func(QTX) func(context.Context, int64, AddressType, uint32, uint32, []byte) (AddrRow, error) - // rowID extracts the ID from an address row. - rowID func(AddrRow) int64 + // RowID extracts the ID from an address row. + RowID func(AddrRow) int64 - // rowCreatedAt extracts the creation time from an address row. - rowCreatedAt func(AddrRow) time.Time + // RowCreatedAt extracts the creation time from an address row. + RowCreatedAt func(AddrRow) time.Time } -// importedAddressAdapters groups the functions needed to create an +// ImportedAddressAdapters groups the functions needed to create an // imported address across different database backends. -type importedAddressAdapters[QTX any, AccountRow any, +type ImportedAddressAdapters[QTX any, AccountRow any, AccountParams any, CreateArgs any, AddrRow any, SecretParams any] struct { - // getAccount retrieves an account by the provided parameters. - getAccount func(context.Context, AccountParams) (AccountRow, error) + // GetAccount retrieves an account by the provided parameters. + GetAccount func(context.Context, AccountParams) (AccountRow, error) - // accountParams converts params to account lookup parameters. - accountParams func(NewImportedAddressParams) AccountParams + // AccountParams converts params to account lookup parameters. + AccountParams func(NewImportedAddressParams) AccountParams - // getAccountID extracts the account ID from an account row. - getAccountID func(AccountRow) int64 + // GetAccountID extracts the account ID from an account row. + GetAccountID func(AccountRow) int64 - // createAddr returns a function to create an address row. - createAddr func(QTX) func(context.Context, CreateArgs) (AddrRow, error) + // CreateAddr returns a function to create an address row. + CreateAddr func(QTX) func(context.Context, CreateArgs) (AddrRow, error) - // createParams converts accountID and params to address creation + // CreateParams converts accountID and params to address creation // parameters. - createParams func(int64, NewImportedAddressParams) CreateArgs + CreateParams func(int64, NewImportedAddressParams) CreateArgs - // insertSecret returns a function to insert address secret. - insertSecret func(QTX) func(context.Context, SecretParams) error + // InsertSecret returns a function to insert address secret. + InsertSecret func(QTX) func(context.Context, SecretParams) error - // secretParams converts address ID and params to secret parameters. - secretParams func(int64, NewImportedAddressParams) SecretParams + // SecretParams converts address ID and params to secret parameters. + SecretParams func(int64, NewImportedAddressParams) SecretParams - // rowID extracts the ID from an address row. - rowID func(AddrRow) int64 + // RowID extracts the ID from an address row. + RowID func(AddrRow) int64 - // rowCreatedAt extracts the creation time from an address row. - rowCreatedAt func(AddrRow) time.Time + // RowCreatedAt extracts the creation time from an address row. + RowCreatedAt func(AddrRow) time.Time } -// getAddressFunc defines a function signature for retrieving a single address. -type getAddressFunc func(context.Context, GetAddressQuery) (*AddressInfo, error) +// GetAddressFunc defines a function signature for retrieving a single address. +type GetAddressFunc func(context.Context, GetAddressQuery) (*AddressInfo, error) -// getAddressByQuery validates the query and executes the script-based lookup. -func getAddressByQuery(ctx context.Context, query GetAddressQuery, - getter getAddressFunc) (*AddressInfo, error) { +// GetAddressByQuery validates the query and executes the script-based lookup. +func GetAddressByQuery(ctx context.Context, query GetAddressQuery, + getter GetAddressFunc) (*AddressInfo, error) { if len(query.ScriptPubKey) == 0 { return nil, ErrInvalidAddressQuery @@ -534,13 +534,13 @@ func derivedAddressInput(ctx context.Context, return 0, 0, 0, nil, ErrMaxAddressIndexReached } - index, err := int64ToUint32(indexValue) + index, err := Int64ToUint32(indexValue) if err != nil { return 0, 0, 0, nil, fmt.Errorf( "address index: %w", err) } - acctID, err := int64ToUint32(accountID) + acctID, err := Int64ToUint32(accountID) if err != nil { return 0, 0, 0, nil, fmt.Errorf( "account ID: %w", err) @@ -555,27 +555,27 @@ func derivedAddressInput(ctx context.Context, return addrType, branch, index, derivedData.ScriptPubKey, nil } -// newDerivedAddressWithTx combines transaction execution, account lookup, +// NewDerivedAddressWithTx combines transaction execution, account lookup, // and derived address creation in one helper. -func newDerivedAddressWithTx[QTX any, AccountRow any, +func NewDerivedAddressWithTx[QTX any, AccountRow any, AccountParams any, AddrRow any](ctx context.Context, params NewDerivedAddressParams, executeTx func(context.Context, func(QTX) error) error, - adapters derivedAddressAdapters[QTX, AccountRow, AccountParams, AddrRow], + adapters DerivedAddressAdapters[QTX, AccountRow, AccountParams, AddrRow], deriveFn AddressDerivationFunc) (*AddressInfo, error) { var result *AddressInfo err := executeTx(ctx, func(qtx QTX) error { - row, err := adapters.getAccount(ctx, adapters.accountParams(params)) + row, err := adapters.GetAccount(ctx, adapters.AccountParams(params)) if err == nil { info, errAddr := createDerivedAddress( - ctx, params, adapters.getAccountID(row), - adapters.getExtIndex(qtx), - adapters.getIntIndex(qtx), - adapters.createAddr(qtx), - adapters.rowID, - adapters.rowCreatedAt, deriveFn) + ctx, params, adapters.GetAccountID(row), + adapters.GetExtIndex(qtx), + adapters.GetIntIndex(qtx), + adapters.CreateAddr(qtx), + adapters.RowID, + adapters.RowCreatedAt, deriveFn) if errAddr != nil { return errAddr } @@ -586,10 +586,11 @@ func newDerivedAddressWithTx[QTX any, AccountRow any, } if errors.Is(err, sql.ErrNoRows) { - key := accountKeyFromParams(params) + key := AccountKeyFromParams(params) return fmt.Errorf("account %q in scope %d/%d: %w", - key.accountName, key.purpose, key.coinType, ErrAccountNotFound) + key.AccountName, key.Purpose, key.CoinType, + ErrAccountNotFound) } return fmt.Errorf("get account: %w", err) @@ -601,13 +602,13 @@ func newDerivedAddressWithTx[QTX any, AccountRow any, return result, nil } -// newImportedAddressWithTx combines transaction execution, account lookup, +// NewImportedAddressWithTx combines transaction execution, account lookup, // and imported address creation in one helper. -func newImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, +func NewImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, CreateArgs any, AddrRow any, SecretParams any]( ctx context.Context, params NewImportedAddressParams, executeTx func(context.Context, func(QTX) error) error, - adapters importedAddressAdapters[QTX, AccountRow, AccountParams, CreateArgs, + adapters ImportedAddressAdapters[QTX, AccountRow, AccountParams, CreateArgs, AddrRow, SecretParams]) (*AddressInfo, error) { validationErr := params.validate() @@ -618,17 +619,17 @@ func newImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, var result *AddressInfo err := executeTx(ctx, func(qtx QTX) error { - row, err := adapters.getAccount(ctx, adapters.accountParams(params)) + row, err := adapters.GetAccount(ctx, adapters.AccountParams(params)) if err == nil { - acctID := adapters.getAccountID(row) + acctID := adapters.GetAccountID(row) info, errAddr := newImportedAddressTx( - ctx, adapters.createAddr(qtx), - adapters.createParams(acctID, params), - adapters.insertSecret, qtx, - adapters.secretParams, params, acctID, - adapters.rowID, - adapters.rowCreatedAt) + ctx, adapters.CreateAddr(qtx), + adapters.CreateParams(acctID, params), + adapters.InsertSecret, qtx, + adapters.SecretParams, params, acctID, + adapters.RowID, + adapters.RowCreatedAt) if errAddr != nil { return errAddr } @@ -639,10 +640,11 @@ func newImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, } if errors.Is(err, sql.ErrNoRows) { - key := accountKeyFromImportedParams(params) + key := AccountKeyFromImportedParams(params) return fmt.Errorf("account %q in scope %d/%d: %w", - key.accountName, key.purpose, key.coinType, ErrAccountNotFound) + key.AccountName, key.Purpose, key.CoinType, + ErrAccountNotFound) } return fmt.Errorf("get account: %w", err) diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 7159261ac5..0d01de2b03 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -21,7 +21,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, error) { - return getAddress( + return GetAddress( ctx, s.queries.GetAddressByScriptPubKey, sqlcpg.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, @@ -30,7 +30,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, ) } - return getAddressByQuery(ctx, query, getByScript) + return GetAddressByQuery(ctx, query, getByScript) } // ListAddresses returns a page of addresses matching the given query. @@ -57,7 +57,7 @@ func (s *PostgresStore) IterAddresses(ctx context.Context, query ListAddressesQuery) iter.Seq2[AddressInfo, error] { return page.Iter( - ctx, query, s.ListAddresses, nextListAddressesQuery, + ctx, query, s.ListAddresses, NextListAddressesQuery, ) } @@ -65,7 +65,7 @@ func (s *PostgresStore) IterAddresses(ctx context.Context, func (s *PostgresStore) GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, error) { - return getAddressSecret( + return GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, ) } @@ -76,63 +76,63 @@ func (s *PostgresStore) NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, deriveFn AddressDerivationFunc) (*AddressInfo, error) { - adapters := derivedAddressAdapters[ + adapters := DerivedAddressAdapters[ *sqlcpg.Queries, sqlcpg.GetAccountByWalletScopeAndNameRow, - accountLookupKey, + AccountLookupKey, sqlcpg.CreateDerivedAddressRow]{ - getAccount: pgGetAccountFromKey(s.queries), - accountParams: accountKeyFromParams, - getAccountID: newDerivedAddressGetAccountIDPg, - getExtIndex: newDerivedAddressGetExtIndexPg, - getIntIndex: newDerivedAddressGetIntIndexPg, - createAddr: newDerivedAddressCreateAddrPg, - rowID: newDerivedAddressRowIDPg, - rowCreatedAt: newDerivedAddressRowCreatedAtPg, + GetAccount: pgGetAccountFromKey(s.queries), + AccountParams: AccountKeyFromParams, + GetAccountID: newDerivedAddressGetAccountIDPg, + GetExtIndex: newDerivedAddressGetExtIndexPg, + GetIntIndex: newDerivedAddressGetIntIndexPg, + CreateAddr: newDerivedAddressCreateAddrPg, + RowID: newDerivedAddressRowIDPg, + RowCreatedAt: newDerivedAddressRowCreatedAtPg, } - return newDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. func (s *PostgresStore) NewImportedAddress(ctx context.Context, params NewImportedAddressParams) (*AddressInfo, error) { - adapters := importedAddressAdapters[ + adapters := ImportedAddressAdapters[ *sqlcpg.Queries, sqlcpg.GetAccountByWalletScopeAndNameRow, - accountLookupKey, + AccountLookupKey, sqlcpg.CreateImportedAddressParams, sqlcpg.CreateImportedAddressRow, sqlcpg.InsertAddressSecretParams]{ - getAccount: pgGetAccountFromKey(s.queries), - accountParams: accountKeyFromImportedParams, - getAccountID: newImportedAddressGetAccountIDPg, - createAddr: pgCreateImportedAddress, - createParams: createImportedAddressParamsPg, - insertSecret: pgInsertAddressSecret, - secretParams: insertAddressSecretParamsPg, - rowID: importedAddressRowIDPg, - rowCreatedAt: importedAddressRowCreatedAtPg, + GetAccount: pgGetAccountFromKey(s.queries), + AccountParams: AccountKeyFromImportedParams, + GetAccountID: newImportedAddressGetAccountIDPg, + CreateAddr: pgCreateImportedAddress, + CreateParams: createImportedAddressParamsPg, + InsertSecret: pgInsertAddressSecret, + SecretParams: insertAddressSecretParamsPg, + RowID: importedAddressRowIDPg, + RowCreatedAt: importedAddressRowCreatedAtPg, } - return newImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // pgGetAccountFromKey returns a helper to look up accounts by key. func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, - accountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { + AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key accountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, + key AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( ctx, sqlcpg.GetAccountByWalletScopeAndNameParams{ - WalletID: key.walletID, - Purpose: key.purpose, - CoinType: key.coinType, - AccountName: key.accountName, + WalletID: key.WalletID, + Purpose: key.Purpose, + CoinType: key.CoinType, + AccountName: key.AccountName, }, ) } @@ -168,7 +168,7 @@ func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, branch uint32, index uint32, scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { - branchNum, err := uint32ToInt16(branch) + branchNum, err := Uint32ToInt16(branch) if err != nil { return sqlcpg.CreateDerivedAddressRow{}, fmt.Errorf( "address branch: %w", err, @@ -268,7 +268,7 @@ func importedAddressRowCreatedAtPg( func pgAddressSecretRowToSecret( row sqlcpg.GetAddressSecretRow) (*AddressSecret, error) { - return addressSecretRowToSecret(addressSecretRow{ + return AddressSecretRowToSecret(AddressSecretRow{ AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, @@ -290,7 +290,7 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAddressByScriptPubKeyRow(row) - info, err := addressRowToInfo(addressInfoRow[int16, int16]{ + info, err := addressRowToInfo(AddressInfoRow[int16, int16]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, @@ -305,8 +305,8 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, - IDToAddrType: idToAddressType[int16], - IDToOrigin: idToOrigin[int16], + IDToAddrType: IDToAddressType[int16], + IDToOrigin: IDToOrigin[int16], }) if err != nil { return nil, err diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/addresses_sqlite.go index 0e1bbc500e..5645297197 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/addresses_sqlite.go @@ -21,7 +21,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, error) { - return getAddress( + return GetAddress( ctx, s.queries.GetAddressByScriptPubKey, sqlcsqlite.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), @@ -30,7 +30,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, ) } - return getAddressByQuery(ctx, query, getByScript) + return GetAddressByQuery(ctx, query, getByScript) } // ListAddresses returns a page of addresses matching the given query. @@ -57,7 +57,7 @@ func (s *SqliteStore) IterAddresses(ctx context.Context, query ListAddressesQuery) iter.Seq2[AddressInfo, error] { return page.Iter( - ctx, query, s.ListAddresses, nextListAddressesQuery, + ctx, query, s.ListAddresses, NextListAddressesQuery, ) } @@ -65,7 +65,7 @@ func (s *SqliteStore) IterAddresses(ctx context.Context, func (s *SqliteStore) GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, error) { - return getAddressSecret( + return GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, sqliteAddressSecretRowToSecret, ) @@ -77,63 +77,63 @@ func (s *SqliteStore) NewDerivedAddress(ctx context.Context, params NewDerivedAddressParams, deriveFn AddressDerivationFunc) (*AddressInfo, error) { - adapters := derivedAddressAdapters[ + adapters := DerivedAddressAdapters[ *sqlcsqlite.Queries, sqlcsqlite.GetAccountByWalletScopeAndNameRow, - accountLookupKey, + AccountLookupKey, sqlcsqlite.CreateDerivedAddressRow]{ - getAccount: sqliteGetAccountFromKey(s.queries), - accountParams: accountKeyFromParams, - getAccountID: newDerivedAddressGetAccountIDSQLite, - getExtIndex: newDerivedAddressGetExtIndexSQLite, - getIntIndex: newDerivedAddressGetIntIndexSQLite, - createAddr: newDerivedAddressCreateAddrSQLite, - rowID: newDerivedAddressRowIDSQLite, - rowCreatedAt: newDerivedAddressRowCreatedAtSQLite, + GetAccount: sqliteGetAccountFromKey(s.queries), + AccountParams: AccountKeyFromParams, + GetAccountID: newDerivedAddressGetAccountIDSQLite, + GetExtIndex: newDerivedAddressGetExtIndexSQLite, + GetIntIndex: newDerivedAddressGetIntIndexSQLite, + CreateAddr: newDerivedAddressCreateAddrSQLite, + RowID: newDerivedAddressRowIDSQLite, + RowCreatedAt: newDerivedAddressRowCreatedAtSQLite, } - return newDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. func (s *SqliteStore) NewImportedAddress(ctx context.Context, params NewImportedAddressParams) (*AddressInfo, error) { - adapters := importedAddressAdapters[ + adapters := ImportedAddressAdapters[ *sqlcsqlite.Queries, sqlcsqlite.GetAccountByWalletScopeAndNameRow, - accountLookupKey, + AccountLookupKey, sqlcsqlite.CreateImportedAddressParams, sqlcsqlite.CreateImportedAddressRow, sqlcsqlite.InsertAddressSecretParams]{ - getAccount: sqliteGetAccountFromKey(s.queries), - accountParams: accountKeyFromImportedParams, - getAccountID: newImportedAddressGetAccountIDSQLite, - createAddr: sqliteCreateImportedAddress, - createParams: createImportedAddressParamsSQLite, - insertSecret: sqliteInsertAddressSecret, - secretParams: insertAddressSecretParamsSQLite, - rowID: importedAddressRowIDSQLite, - rowCreatedAt: importedAddressRowCreatedAtSQLite, + GetAccount: sqliteGetAccountFromKey(s.queries), + AccountParams: AccountKeyFromImportedParams, + GetAccountID: newImportedAddressGetAccountIDSQLite, + CreateAddr: sqliteCreateImportedAddress, + CreateParams: createImportedAddressParamsSQLite, + InsertSecret: sqliteInsertAddressSecret, + SecretParams: insertAddressSecretParamsSQLite, + RowID: importedAddressRowIDSQLite, + RowCreatedAt: importedAddressRowCreatedAtSQLite, } - return newImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // sqliteGetAccountFromKey returns a helper to look up accounts by key. func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, - accountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { + AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key accountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, + key AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( ctx, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ - WalletID: key.walletID, - Purpose: key.purpose, - CoinType: key.coinType, - AccountName: key.accountName, + WalletID: key.WalletID, + Purpose: key.Purpose, + CoinType: key.CoinType, + AccountName: key.AccountName, }, ) } @@ -264,7 +264,7 @@ func insertAddressSecretParamsSQLite(addressID int64, func sqliteAddressSecretRowToSecret( row sqlcsqlite.GetAddressSecretRow) (*AddressSecret, error) { - return addressSecretRowToSecret(addressSecretRow{ + return AddressSecretRowToSecret(AddressSecretRow{ AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, @@ -287,7 +287,7 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, // identical fields. If sqlc types diverge, compilation will fail. base := sqlcsqlite.GetAddressByScriptPubKeyRow(row) - info, err := addressRowToInfo(addressInfoRow[int64, int64]{ + info, err := addressRowToInfo(AddressInfoRow[int64, int64]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, @@ -299,8 +299,8 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, - IDToAddrType: idToAddressType[int64], - IDToOrigin: idToOrigin[int64], + IDToAddrType: IDToAddressType[int64], + IDToOrigin: IDToOrigin[int64], }) if err != nil { return nil, err diff --git a/wallet/internal/db/block_common.go b/wallet/internal/db/block_common.go index 507315bc88..5b1e43b722 100644 --- a/wallet/internal/db/block_common.go +++ b/wallet/internal/db/block_common.go @@ -7,9 +7,9 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" ) -// buildBlock constructs a Block from the provided components that are common +// BuildBlock constructs a Block from the provided components that are common // across different database backends. -func buildBlock(hash []byte, height uint32, timestamp int64) (*Block, error) { +func BuildBlock(hash []byte, height uint32, timestamp int64) (*Block, error) { h, err := chainhash.NewHash(hash) if err != nil { return nil, fmt.Errorf("block hash: %w", err) diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/block_pg.go index 7fee38f67c..b25aaf0172 100644 --- a/wallet/internal/db/block_pg.go +++ b/wallet/internal/db/block_pg.go @@ -15,19 +15,19 @@ import ( func buildPgBlock(height sql.NullInt32, hash []byte, timestamp sql.NullInt64) (*Block, error) { - height32, err := nullInt32ToUint32(height) + height32, err := NullInt32ToUint32(height) if err != nil { return nil, fmt.Errorf("block height: %w", err) } - return buildBlock(hash, height32, timestamp.Int64) + return BuildBlock(hash, height32, timestamp.Int64) } // ensureBlockExistsPg ensures that a block exists in the database. func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, block *Block) error { - height, err := uint32ToInt32(block.Height) + height, err := Uint32ToInt32(block.Height) if err != nil { return fmt.Errorf("convert block height: %w", err) } @@ -51,7 +51,7 @@ func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, block *Block) (int32, error) { - height, err := uint32ToInt32(block.Height) + height, err := Uint32ToInt32(block.Height) if err != nil { return 0, fmt.Errorf("convert block height: %w", err) } diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/block_sqlite.go index ffa5413dd7..39bbf9916c 100644 --- a/wallet/internal/db/block_sqlite.go +++ b/wallet/internal/db/block_sqlite.go @@ -15,12 +15,12 @@ import ( func buildSqliteBlock(height sql.NullInt64, hash []byte, timestamp sql.NullInt64) (*Block, error) { - height32, err := int64ToUint32(height.Int64) + height32, err := Int64ToUint32(height.Int64) if err != nil { return nil, fmt.Errorf("block height: %w", err) } - return buildBlock(hash, height32, timestamp.Int64) + return BuildBlock(hash, height32, timestamp.Int64) } // ensureBlockExistsSqlite ensures that a block exists in the database. If it diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 7c9078c130..63ee0e195f 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -486,9 +486,9 @@ func TestListAccountsOrdering(t *testing.T) { scope := db.KeyScopeBIP0084 // Create accounts in mixed order: imported, derived, imported, derived. - createImportedAccount(t, store, walletID, scope, "imported-first") + CreateImportedAccount(t, store, walletID, scope, "imported-first") createDerivedAccount(t, store, walletID, scope, "derived-0") - createImportedAccount(t, store, walletID, scope, "imported-second") + CreateImportedAccount(t, store, walletID, scope, "imported-second") createDerivedAccount(t, store, walletID, scope, "derived-1") query := db.ListAccountsQuery{ @@ -793,10 +793,10 @@ func createDerivedAccount(t *testing.T, store db.AccountStore, walletID uint32, require.NoError(t, err) } -// createImportedAccount creates a new imported account with the given name, +// CreateImportedAccount creates a new imported account with the given name, // scope, and wallet ID using the provided account store. A random public key // is generated for the account. -func createImportedAccount(t *testing.T, store db.AccountStore, walletID uint32, +func CreateImportedAccount(t *testing.T, store db.AccountStore, walletID uint32, scope db.KeyScope, name string) { t.Helper() diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 81a416595a..7bb2747025 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -133,9 +133,9 @@ func TestNewImportedAddress(t *testing.T) { queries := store.Queries() walletID := newWallet(t, store, "wallet-imported-addresses") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0086, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0086, "imported") privKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -252,7 +252,7 @@ func TestNewImportedAddress(t *testing.T) { t, queries, params.ScriptPubKey, walletID, ) - secret, err := getAddressSecret(t, queries, addressID) + secret, err := GetAddressSecret(t, queries, addressID) require.NoError(t, err) if tc.providePrivateKey { require.Equal( @@ -276,8 +276,8 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { store := NewTestStore(t) queries := store.Queries() walletID := newWallet(t, store, "wallet-encrypted-script") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") - createImportedAccount( + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + CreateImportedAccount( t, store, walletID, db.KeyScopeBIP0049Plus, "imported", ) @@ -365,7 +365,7 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { t, queries, params.ScriptPubKey, walletID, ) - secret, err := getAddressSecret(t, queries, addressID) + secret, err := GetAddressSecret(t, queries, addressID) require.NoError(t, err) require.Equal(t, tc.encryptedScript, secret.EncryptedScript) @@ -387,7 +387,7 @@ func TestImportedAddressCounterInsertDelete(t *testing.T) { store := NewTestStore(t) dbConn := store.DB() walletID := newWallet(t, store, "wallet-imported-counter") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") const importedAddrCount = 5 addressIDs := make([]uint32, 0, importedAddrCount) @@ -435,7 +435,7 @@ func TestImportedAddressCounterConcurrentInsert(t *testing.T) { store := NewTestStore(t) dbConn := store.DB() walletID := newWallet(t, store, "wallet-imported-counter-concurrent") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") const workers = 20 ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -517,7 +517,7 @@ func TestNewImportedAddressDuplicate(t *testing.T) { store := NewTestStore(t) walletID := newWallet(t, store, "wallet-duplicate-import") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") // Set up encryption parameters (same for both imports). scriptPubKey := RandomBytes(32) @@ -550,7 +550,7 @@ func TestGetAddressSecret(t *testing.T) { store := NewTestStore(t) walletID := newWallet(t, store, "wallet-secrets") - createImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") testCases := []struct { name string @@ -628,7 +628,7 @@ func TestGetAddress(t *testing.T) { accountStore db.AccountStore, walletID uint32) db.GetAddressQuery { - createImportedAccount( + CreateImportedAccount( t, accountStore, walletID, db.KeyScopeBIP0084, "imported", ) diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index d496f98c55..b7d0a902f2 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -146,7 +146,7 @@ func getAddressID(t *testing.T, queries *sqlcpg.Queries, scriptPubKey []byte, return addr.ID } -func getAddressSecret(t *testing.T, queries *sqlcpg.Queries, +func GetAddressSecret(t *testing.T, queries *sqlcpg.Queries, addressID int64) (sqlcpg.GetAddressSecretRow, error) { t.Helper() diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 8e3a9d568f..de5a4783bd 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -146,7 +146,7 @@ func getAddressID(t *testing.T, queries *sqlcsqlite.Queries, return addr.ID } -func getAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, +func GetAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, addressID int64) (sqlcsqlite.GetAddressSecretRow, error) { t.Helper() diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg.go index 60ac84fd3e..2441ba1265 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg.go @@ -81,7 +81,7 @@ func (s *PostgresStore) Close() error { func (s *PostgresStore) ExecuteTx(ctx context.Context, fn func(*sqlcpg.Queries) error) error { - return execInTx(ctx, s.db, func(tx *sql.Tx) error { + return ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) return fn(qtx) }) diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/postgres_txstore_createtx.go index dd81f7688c..5e388c9ca1 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/postgres_txstore_createtx.go @@ -17,19 +17,19 @@ import ( // The full write runs inside ExecuteTx so the transaction row, created UTXOs, // spent-parent markers, and any required invalidation are either committed // together or not at all. Received timestamps are normalized to UTC before -// insert. When the wallet already stores the same unmined transaction hash, +// Insert. When the wallet already stores the same unmined transaction hash, // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. func (s *PostgresStore) CreateTx(ctx context.Context, params CreateTxParams) error { - req, err := newCreateTxRequest(params) + req, err := NewCreateTxRequest(params) if err != nil { return err } return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return createTxWithOps(ctx, req, &pgCreateTxOps{ + return CreateTxWithOps(ctx, req, &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: qtx, }, @@ -44,46 +44,46 @@ type pgCreateTxOps struct { blockHeight sql.NullInt32 } -var _ createTxOps = (*pgCreateTxOps)(nil) +var _ CreateTxOps = (*pgCreateTxOps)(nil) -// loadExisting loads any existing wallet-scoped row for the requested tx hash. -func (o *pgCreateTxOps) loadExisting(ctx context.Context, - req createTxRequest) (*createTxExistingTarget, error) { +// LoadExisting loads any existing wallet-scoped row for the requested tx hash. +func (o *pgCreateTxOps) LoadExisting(ctx context.Context, + req CreateTxRequest) (*CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcpg.GetTransactionMetaByHashParams{ - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, errCreateTxExistingNotFound + return nil, ErrCreateTxExistingNotFound } return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(int64(meta.TxStatus)) + status, err := ParseTxStatus(int64(meta.TxStatus)) if err != nil { return nil, err } - return &createTxExistingTarget{ - id: meta.ID, - status: status, - hasBlock: meta.BlockHeight.Valid, - isCoinbase: meta.IsCoinbase, + return &CreateTxExistingTarget{ + ID: meta.ID, + Status: status, + HasBlock: meta.BlockHeight.Valid, + IsCoinbase: meta.IsCoinbase, }, nil } -// confirmExisting promotes one existing unmined row to its confirmed state. -func (o *pgCreateTxOps) confirmExisting(ctx context.Context, - req createTxRequest, - _ createTxExistingTarget) error { +// ConfirmExisting promotes one existing unmined row to its confirmed state. +func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, + req CreateTxRequest, + _ CreateTxExistingTarget) error { - blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.params.Block) + blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -92,8 +92,8 @@ func (o *pgCreateTxOps) confirmExisting(ctx context.Context, ctx, sqlcpg.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{Int32: blockHeight, Valid: true}, Status: int16(TxStatusPublished), - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], }, ) if err != nil { @@ -101,24 +101,24 @@ func (o *pgCreateTxOps) confirmExisting(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", req.txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxNotFound) } return nil } -// prepareBlock validates the optional confirming block and caches the postgres -// block-height value that the later insert query will store. -func (o *pgCreateTxOps) prepareBlock(ctx context.Context, - req createTxRequest) error { +// PrepareBlock validates the optional confirming block and caches the postgres +// block-height value that the later Insert query will store. +func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, + req CreateTxRequest) error { o.blockHeight = sql.NullInt32{} - if req.params.Block == nil { + if req.Params.Block == nil { return nil } - height, err := requireBlockMatchesPg(ctx, o.qtx, req.params.Block) + height, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) if err != nil { return err } @@ -128,10 +128,10 @@ func (o *pgCreateTxOps) prepareBlock(ctx context.Context, return nil } -// listConflictTxns returns the direct conflict root IDs plus the matching tx +// ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. -func (o *pgCreateTxOps) listConflictTxns(ctx context.Context, - req createTxRequest) ([]int64, []chainhash.Hash, error) { +func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, + req CreateTxRequest) ([]int64, []chainhash.Hash, error) { rootIDs, err := collectPgConflictRootIDs(ctx, o.qtx, req) if err != nil { @@ -142,7 +142,7 @@ func (o *pgCreateTxOps) listConflictTxns(ctx context.Context, return nil, nil, nil } - rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.params.WalletID)) + rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.Params.WalletID)) if err != nil { return nil, nil, fmt.Errorf("list unmined txns: %w", err) } @@ -153,15 +153,15 @@ func (o *pgCreateTxOps) listConflictTxns(ctx context.Context, // collectPgConflictRootIDs returns the active unmined spender row IDs // that currently own any wallet-controlled input spent by the incoming tx. func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, - req createTxRequest) (map[int64]struct{}, error) { + req CreateTxRequest) (map[int64]struct{}, error) { - if blockchain.IsCoinBaseTx(req.params.Tx) { + if blockchain.IsCoinBaseTx(req.Params.Tx) { return map[int64]struct{}{}, nil } - rootIDs := make(map[int64]struct{}, len(req.params.Tx.TxIn)) - for inputIndex, txIn := range req.params.Tx.TxIn { - outputIndex, err := uint32ToInt32(txIn.PreviousOutPoint.Index) + rootIDs := make(map[int64]struct{}, len(req.Params.Tx.TxIn)) + for inputIndex, txIn := range req.Params.Tx.TxIn { + outputIndex, err := Uint32ToInt32(txIn.PreviousOutPoint.Index) if err != nil { return nil, fmt.Errorf("convert input outpoint index %d: %w", inputIndex, err) @@ -169,7 +169,7 @@ func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, spentByTxID, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlcpg.GetUtxoSpendByOutpointParams{ - WalletID: int64(req.params.WalletID), + WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: outputIndex, }, @@ -219,44 +219,44 @@ func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, return rootIDs, rootHashes, nil } -// insert stores one new postgres transaction row for CreateTx. -func (o *pgCreateTxOps) insert(ctx context.Context, - req createTxRequest) (int64, error) { +// Insert stores one new postgres transaction row for CreateTx. +func (o *pgCreateTxOps) Insert(ctx context.Context, + req CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], - RawTx: req.rawTx, + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], + RawTx: req.RawTx, BlockHeight: o.blockHeight, - TxStatus: int16(req.params.Status), - ReceivedTime: req.received, - IsCoinbase: req.isCoinbase, - TxLabel: req.params.Label, + TxStatus: int16(req.Params.Status), + ReceivedTime: req.Received, + IsCoinbase: req.IsCoinbase, + TxLabel: req.Params.Label, }) if err != nil { - return 0, fmt.Errorf("insert tx row: %w", err) + return 0, fmt.Errorf("Insert tx row: %w", err) } return txID, nil } -// insertCredits stores any wallet-owned outputs created by the transaction. -func (o *pgCreateTxOps) insertCredits(ctx context.Context, - req createTxRequest, txID int64) error { +// InsertCredits stores any wallet-owned outputs created by the transaction. +func (o *pgCreateTxOps) InsertCredits(ctx context.Context, + req CreateTxRequest, txID int64) error { - return insertCreditsPg(ctx, o.qtx, req.params, txID) + return insertCreditsPg(ctx, o.qtx, req.Params, txID) } -// markInputsSpent records wallet-owned inputs spent by the transaction. -func (o *pgCreateTxOps) markInputsSpent(ctx context.Context, - req createTxRequest, txID int64) error { +// MarkInputsSpent records wallet-owned inputs spent by the transaction. +func (o *pgCreateTxOps) MarkInputsSpent(ctx context.Context, + req CreateTxRequest, txID int64) error { - return markInputsSpentPg(ctx, o.qtx, req.params, txID) + return markInputsSpentPg(ctx, o.qtx, req.Params, txID) } -// markTxnsReplaced marks the provided direct conflict roots replaced in one +// MarkTxnsReplaced marks the provided direct conflict roots replaced in one // batch update. -func (o *pgCreateTxOps) markTxnsReplaced( +func (o *pgCreateTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -273,9 +273,9 @@ func (o *pgCreateTxOps) markTxnsReplaced( return nil } -// insertReplacementEdges records replacement-history edges from each direct +// InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. -func (o *pgCreateTxOps) insertReplacementEdges( +func (o *pgCreateTxOps) InsertReplacementEdges( ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { @@ -288,7 +288,7 @@ func (o *pgCreateTxOps) insertReplacementEdges( }, ) if err != nil { - return fmt.Errorf("insert replacement edge for %d: %w", + return fmt.Errorf("Insert replacement edge for %d: %w", replacedTxID, err) } } @@ -330,7 +330,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("resolve credit address %d: %w", index, err) } - outputIndex, err := uint32ToInt32(index) + outputIndex, err := Uint32ToInt32(index) if err != nil { return fmt.Errorf("convert credit index %d: %w", index, err) } @@ -343,7 +343,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, AddressID: addrRow.ID, }) if err != nil { - return fmt.Errorf("insert credit output %d: %w", index, err) + return fmt.Errorf("Insert credit output %d: %w", index, err) } } @@ -355,7 +355,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { - convertedIndex, err := uint32ToInt32(outputIndex) + convertedIndex, err := Uint32ToInt32(outputIndex) if err != nil { return false, fmt.Errorf("convert credit index %d: %w", outputIndex, err) @@ -396,13 +396,13 @@ func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, } for inputIndex, txIn := range params.Tx.TxIn { - outputIndex, err := uint32ToInt32(txIn.PreviousOutPoint.Index) + outputIndex, err := Uint32ToInt32(txIn.PreviousOutPoint.Index) if err != nil { return fmt.Errorf("convert input outpoint index %d: %w", inputIndex, err) } - spentInputIndex, err := int64ToInt32(int64(inputIndex)) + spentInputIndex, err := Int64ToInt32(int64(inputIndex)) if err != nil { return fmt.Errorf("convert input index %d: %w", inputIndex, err) } diff --git a/wallet/internal/db/postgres_txstore_deletetx.go b/wallet/internal/db/postgres_txstore_deletetx.go index 2817b6185d..a845fef274 100644 --- a/wallet/internal/db/postgres_txstore_deletetx.go +++ b/wallet/internal/db/postgres_txstore_deletetx.go @@ -21,7 +21,7 @@ func (s *PostgresStore) DeleteTx(ctx context.Context, params DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return deleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) + return DeleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) }) } @@ -30,11 +30,11 @@ type pgDeleteTxOps struct { qtx *sqlcpg.Queries } -var _ deleteTxOps = (*pgDeleteTxOps)(nil) +var _ DeleteTxOps = (*pgDeleteTxOps)(nil) -// loadDeleteTarget loads and validates the unmined transaction row DeleteTx is +// LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. -func (o pgDeleteTxOps) loadDeleteTarget(ctx context.Context, walletID uint32, +func (o pgDeleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { meta, err := getDeleteTxMetaPg(ctx, o.qtx, walletID, txHash) @@ -45,17 +45,17 @@ func (o pgDeleteTxOps) loadDeleteTarget(ctx context.Context, walletID uint32, return meta.ID, nil } -// ensureLeaf rejects DeleteTx when the target still has direct unmined child +// EnsureLeaf rejects DeleteTx when the target still has direct unmined child // spenders. -func (o pgDeleteTxOps) ensureLeaf(ctx context.Context, walletID uint32, +func (o pgDeleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error { return ensureDeleteLeafPg(ctx, o.qtx, walletID, txHash, txID) } -// clearSpentUtxos restores any wallet-owned parent outputs the transaction had +// ClearSpentUtxos restores any wallet-owned parent outputs the transaction had // marked spent. -func (o pgDeleteTxOps) clearSpentUtxos(ctx context.Context, walletID uint32, +func (o pgDeleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -72,9 +72,9 @@ func (o pgDeleteTxOps) clearSpentUtxos(ctx context.Context, walletID uint32, return nil } -// deleteCreatedUtxos removes any wallet-owned outputs created by the +// DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. -func (o pgDeleteTxOps) deleteCreatedUtxos(ctx context.Context, +func (o pgDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.DeleteUtxosByTxID( @@ -91,9 +91,9 @@ func (o pgDeleteTxOps) deleteCreatedUtxos(ctx context.Context, return nil } -// deleteUnminedTransaction removes the target unmined row after its dependent +// DeleteUnminedTransaction removes the target unmined row after its dependent // wallet state has been cleaned up. -func (o pgDeleteTxOps) deleteUnminedTransaction(ctx context.Context, +func (o pgDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { rows, err := o.qtx.DeleteUnminedTransactionByHash( @@ -121,7 +121,7 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("list unmined txns: %w", err) } - candidates, err := buildUnminedTxRecords(rows, + candidates, err := BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, @@ -132,14 +132,14 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, filtered := candidates[:0] for _, candidate := range candidates { - if candidate.id == txID { + if candidate.ID == txID { continue } filtered = append(filtered, candidate) } - if len(collectDirectChildTxIDs(txHash, filtered)) > 0 { + if len(CollectDirectChildTxIDs(txHash, filtered)) > 0 { return fmt.Errorf("delete tx %s: %w", txHash, ErrDeleteRequiresLeaf) } @@ -169,12 +169,12 @@ func getDeleteTxMetaPg(ctx context.Context, qtx *sqlcpg.Queries, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(int64(meta.TxStatus)) + status, err := ParseTxStatus(int64(meta.TxStatus)) if err != nil { return sqlcpg.GetTransactionMetaByHashRow{}, err } - if meta.BlockHeight.Valid || !isUnminedStatus(status) { + if meta.BlockHeight.Valid || !IsUnminedStatus(status) { return sqlcpg.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, ErrDeleteRequiresUnmined) diff --git a/wallet/internal/db/postgres_txstore_gettx.go b/wallet/internal/db/postgres_txstore_gettx.go index 80f0c56955..4e154e0ad8 100644 --- a/wallet/internal/db/postgres_txstore_gettx.go +++ b/wallet/internal/db/postgres_txstore_gettx.go @@ -57,5 +57,5 @@ func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, } } - return buildTxInfo(hash, rawTx, received, block, status, label) + return BuildTxInfo(hash, rawTx, received, block, status, label) } diff --git a/wallet/internal/db/postgres_txstore_invalidateunmined.go b/wallet/internal/db/postgres_txstore_invalidateunmined.go index 339d1ae436..7eae1db2bf 100644 --- a/wallet/internal/db/postgres_txstore_invalidateunmined.go +++ b/wallet/internal/db/postgres_txstore_invalidateunmined.go @@ -16,7 +16,7 @@ func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, params InvalidateUnminedTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return invalidateUnminedTxWithOps( + return InvalidateUnminedTxWithOps( ctx, params, pgInvalidateUnminedTxOps{qtx: qtx}, ) }) @@ -28,12 +28,12 @@ type pgInvalidateUnminedTxOps struct { qtx *sqlcpg.Queries } -var _ invalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) +var _ InvalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) -// loadInvalidateTarget loads the root tx metadata used by the shared +// LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. -func (o pgInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { +func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcpg.GetTransactionMetaByHashParams{ @@ -43,48 +43,48 @@ func (o pgInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return invalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + return InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) } - return invalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + return InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(int64(row.TxStatus)) + status, err := ParseTxStatus(int64(row.TxStatus)) if err != nil { - return invalidateUnminedTxTarget{}, err + return InvalidateUnminedTxTarget{}, err } - return invalidateUnminedTxTarget{ - id: row.ID, - txHash: txHash, - status: status, - hasBlock: row.BlockHeight.Valid, - isCoinbase: row.IsCoinbase, + return InvalidateUnminedTxTarget{ + ID: row.ID, + TxHash: txHash, + Status: status, + HasBlock: row.BlockHeight.Valid, + IsCoinbase: row.IsCoinbase, }, nil } -// listUnminedTxRecords loads and decodes the wallet's active unmined +// ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. -func (o pgInvalidateUnminedTxOps) listUnminedTxRecords( - ctx context.Context, walletID int64) ([]unminedTxRecord, error) { +func (o pgInvalidateUnminedTxOps) ListUnminedTxRecords( + ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return buildUnminedTxRecords(rows, + return BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) } -// clearSpentUtxos restores any wallet-owned parent outputs spent by the given +// ClearSpentUtxos restores any wallet-owned parent outputs spent by the given // transaction row. -func (o pgInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, +func (o pgInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -103,9 +103,9 @@ func (o pgInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, return nil } -// markTxnsFailed marks the provided tx rows failed in one +// MarkTxnsFailed marks the provided tx rows failed in one // batch update. -func (o pgInvalidateUnminedTxOps) markTxnsFailed( +func (o pgInvalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( diff --git a/wallet/internal/db/postgres_txstore_listtxns.go b/wallet/internal/db/postgres_txstore_listtxns.go index d6977c3364..36ed4ed816 100644 --- a/wallet/internal/db/postgres_txstore_listtxns.go +++ b/wallet/internal/db/postgres_txstore_listtxns.go @@ -57,12 +57,12 @@ func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, query ListTxnsQuery) ([]TxInfo, error) { - startHeight, err := uint32ToInt32(query.StartHeight) + startHeight, err := Uint32ToInt32(query.StartHeight) if err != nil { return nil, fmt.Errorf("convert start height: %w", err) } - endHeight, err := uint32ToInt32(query.EndHeight) + endHeight, err := Uint32ToInt32(query.EndHeight) if err != nil { return nil, fmt.Errorf("convert end height: %w", err) } @@ -89,7 +89,7 @@ func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, return nil, err } - info, err := buildTxInfo( + info, err := BuildTxInfo( row.TxHash, row.RawTx, row.ReceivedTime, block, int64(row.TxStatus), row.TxLabel, ) diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/postgres_txstore_rollback.go index 5c5576c2b5..626aa9871b 100644 --- a/wallet/internal/db/postgres_txstore_rollback.go +++ b/wallet/internal/db/postgres_txstore_rollback.go @@ -16,7 +16,7 @@ func (s *PostgresStore) RollbackToBlock(ctx context.Context, height uint32) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return rollbackToBlockWithOps(ctx, height, + return RollbackToBlockWithOps(ctx, height, pgRollbackToBlockOps{qtx: qtx}) }) } @@ -27,14 +27,14 @@ type pgRollbackToBlockOps struct { qtx *sqlcpg.Queries } -var _ rollbackToBlockOps = (*pgRollbackToBlockOps)(nil) +var _ RollbackToBlockOps = (*pgRollbackToBlockOps)(nil) -// listRollbackRootHashes loads the coinbase roots that a rollback disconnects +// ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. -func (o pgRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, +func (o pgRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { - rollbackHeight, err := uint32ToInt32(height) + rollbackHeight, err := Uint32ToInt32(height) if err != nil { return nil, fmt.Errorf("convert rollback height: %w", err) } @@ -47,9 +47,9 @@ func (o pgRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, return groupRollbackCoinbaseRootsPg(rows) } -// rewindWalletSyncStateHeights clamps wallet sync-state references below the +// RewindWalletSyncStateHeights clamps wallet sync-state references below the // rollback boundary before the block rows are deleted. -func (o pgRollbackToBlockOps) rewindWalletSyncStateHeights( +func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( ctx context.Context, height uint32) error { // PostgreSQL stores block heights as INTEGER today, so rollback still needs @@ -60,14 +60,14 @@ func (o pgRollbackToBlockOps) rewindWalletSyncStateHeights( // // TODO(yy): Fix it when we are in year 42000, which will give us 800 years // before it's reached. - rollbackHeight, err := uint32ToInt32(height) + rollbackHeight, err := Uint32ToInt32(height) if err != nil { return fmt.Errorf("convert rollback height: %w", err) } newHeight := sql.NullInt32{} if height > 0 { - newHeight, err = uint32ToNullInt32(height - 1) + newHeight, err = Uint32ToNullInt32(height - 1) if err != nil { return fmt.Errorf("convert new height: %w", err) } @@ -86,12 +86,12 @@ func (o pgRollbackToBlockOps) rewindWalletSyncStateHeights( return nil } -// deleteBlocksAtOrAboveHeight removes the shared block rows after sync-state +// DeleteBlocksAtOrAboveHeight removes the shared block rows after sync-state // references have been rewound. -func (o pgRollbackToBlockOps) deleteBlocksAtOrAboveHeight( +func (o pgRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { - rollbackHeight, err := uint32ToInt32(height) + rollbackHeight, err := Uint32ToInt32(height) if err != nil { return fmt.Errorf("convert rollback height: %w", err) } @@ -104,9 +104,9 @@ func (o pgRollbackToBlockOps) deleteBlocksAtOrAboveHeight( return nil } -// markTxRootsOrphaned rewrites each disconnected coinbase root to the +// MarkTxRootsOrphaned rewrites each disconnected coinbase root to the // orphaned state once its confirming block has been deleted. -func (o pgRollbackToBlockOps) markTxRootsOrphaned(ctx context.Context, +func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error { for _, txHash := range rootHashes { @@ -134,26 +134,26 @@ func (o pgRollbackToBlockOps) markTxRootsOrphaned(ctx context.Context, return nil } -// listUnminedTxRecords loads and decodes every unmined transaction row for the +// ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. -func (o pgRollbackToBlockOps) listUnminedTxRecords( - ctx context.Context, walletID int64) ([]unminedTxRecord, error) { +func (o pgRollbackToBlockOps) ListUnminedTxRecords( + ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return buildUnminedTxRecords(rows, + return BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) } -// clearDescendantSpends removes any wallet-owned spend edges claimed by one +// ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant transaction before its status is rewritten. -func (o pgRollbackToBlockOps) clearDescendantSpends( +func (o pgRollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -172,9 +172,9 @@ func (o pgRollbackToBlockOps) clearDescendantSpends( return nil } -// markDescendantsFailed batch-marks the collected rollback descendants as +// MarkDescendantsFailed batch-marks the collected rollback descendants as // failed once every dependent spend edge has been cleared. -func (o pgRollbackToBlockOps) markDescendantsFailed( +func (o pgRollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -200,7 +200,7 @@ func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { - walletID, err := int64ToUint32(row.WalletID) + walletID, err := Int64ToUint32(row.WalletID) if err != nil { return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) } diff --git a/wallet/internal/db/postgres_txstore_updatetx.go b/wallet/internal/db/postgres_txstore_updatetx.go index 72bcb609f1..00ab8fc4b3 100644 --- a/wallet/internal/db/postgres_txstore_updatetx.go +++ b/wallet/internal/db/postgres_txstore_updatetx.go @@ -20,7 +20,7 @@ func (s *PostgresStore) UpdateTx(ctx context.Context, params UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return updateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) + return UpdateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) }) } @@ -38,11 +38,11 @@ type pgUpdateTxOps struct { status int16 } -var _ updateTxOps = (*pgUpdateTxOps)(nil) +var _ UpdateTxOps = (*pgUpdateTxOps)(nil) -// loadIsCoinbase loads the existing row metadata UpdateTx needs before it can +// LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. -func (o *pgUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, +func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -63,9 +63,9 @@ func (o *pgUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, return meta.IsCoinbase, nil } -// prepareState validates any referenced confirming block and captures the +// PrepareState validates any referenced confirming block and captures the // postgres-specific state params for the later row update. -func (o *pgUpdateTxOps) prepareState(ctx context.Context, +func (o *pgUpdateTxOps) PrepareState(ctx context.Context, state UpdateTxState) error { blockHeight := sql.NullInt32{} @@ -85,9 +85,9 @@ func (o *pgUpdateTxOps) prepareState(ctx context.Context, return nil } -// updateState writes one block/status patch after prepareState has validated +// UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. -func (o *pgUpdateTxOps) updateState(ctx context.Context, walletID uint32, +func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, _ UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( @@ -110,8 +110,8 @@ func (o *pgUpdateTxOps) updateState(ctx context.Context, walletID uint32, return nil } -// updateLabel writes one user-visible label change. -func (o *pgUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, +// UpdateLabel writes one user-visible label change. +func (o *pgUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { rows, err := o.qtx.UpdateTransactionLabelByHash( diff --git a/wallet/internal/db/postgres_utxostore_balance.go b/wallet/internal/db/postgres_utxostore_balance.go index 33c6d73b09..22d860a9bd 100644 --- a/wallet/internal/db/postgres_utxostore_balance.go +++ b/wallet/internal/db/postgres_utxostore_balance.go @@ -18,10 +18,10 @@ func (s *PostgresStore) Balance(ctx context.Context, balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: nullableUint32ToSQLInt64(params.Account), - MinConfirms: nullableInt32ToSQLInt32(params.MinConfs), - MaxConfirms: nullableInt32ToSQLInt32(params.MaxConfs), - CoinbaseMaturity: nullableInt32ToSQLInt32(params.CoinbaseMaturity), + AccountNumber: NullableUint32ToSQLInt64(params.Account), + MinConfirms: NullableInt32ToSQLInt32(params.MinConfs), + MaxConfirms: NullableInt32ToSQLInt32(params.MaxConfs), + CoinbaseMaturity: NullableInt32ToSQLInt32(params.CoinbaseMaturity), }) if err != nil { return BalanceResult{}, fmt.Errorf("balance: %w", err) diff --git a/wallet/internal/db/postgres_utxostore_getutxo.go b/wallet/internal/db/postgres_utxostore_getutxo.go index 6ffac436e5..b55ed91759 100644 --- a/wallet/internal/db/postgres_utxostore_getutxo.go +++ b/wallet/internal/db/postgres_utxostore_getutxo.go @@ -17,7 +17,7 @@ import ( func (s *PostgresStore) GetUtxo(ctx context.Context, query GetUtxoQuery) (*UtxoInfo, error) { - outputIndex, err := uint32ToInt32(query.OutPoint.Index) + outputIndex, err := Uint32ToInt32(query.OutPoint.Index) if err != nil { return nil, fmt.Errorf("convert output index: %w", err) } @@ -50,14 +50,14 @@ func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight sql.NullInt32) (*UtxoInfo, error) { - index, err := int64ToUint32(int64(outputIndex)) + index, err := Int64ToUint32(int64(outputIndex)) if err != nil { return nil, fmt.Errorf("utxo output index: %w", err) } var height *uint32 if blockHeight.Valid { - heightValue, err := nullInt32ToUint32(blockHeight) + heightValue, err := NullInt32ToUint32(blockHeight) if err != nil { return nil, fmt.Errorf("utxo block height: %w", err) } @@ -65,7 +65,7 @@ func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, height = &heightValue } - return buildUtxoInfo( + return BuildUtxoInfo( hash, index, amount, pkScript, received, isCoinbase, height, ) } diff --git a/wallet/internal/db/postgres_utxostore_leaseoutput.go b/wallet/internal/db/postgres_utxostore_leaseoutput.go index e3592f5493..05e0c7f624 100644 --- a/wallet/internal/db/postgres_utxostore_leaseoutput.go +++ b/wallet/internal/db/postgres_utxostore_leaseoutput.go @@ -14,14 +14,14 @@ import ( // // The lease lookup and acquisition run in one transaction so competing calls // cannot observe a partially-written lease. Expiration timestamps are -// normalized to UTC before insert. +// normalized to UTC before Insert. func (s *PostgresStore) LeaseOutput(ctx context.Context, params LeaseOutputParams) (*LeasedOutput, error) { var lease *LeasedOutput err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - acquiredLease, err := leaseOutputWithOps( + acquiredLease, err := LeaseOutputWithOps( ctx, params, &pgLeaseOutputOps{qtx: qtx}, ) if err != nil { @@ -45,15 +45,15 @@ type pgLeaseOutputOps struct { qtx *sqlcpg.Queries } -var _ leaseOutputOps = (*pgLeaseOutputOps)(nil) +var _ LeaseOutputOps = (*pgLeaseOutputOps)(nil) -// acquire attempts to write or renew one postgres lease row for the requested +// Acquire attempts to write or renew one postgres lease row for the requested // outpoint. -func (o *pgLeaseOutputOps) acquire(ctx context.Context, +func (o *pgLeaseOutputOps) Acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { - outputIndex, err := uint32ToInt32(params.OutPoint.Index) + outputIndex, err := Uint32ToInt32(params.OutPoint.Index) if err != nil { return time.Time{}, fmt.Errorf("convert output index: %w", err) } @@ -73,18 +73,18 @@ func (o *pgLeaseOutputOps) acquire(ctx context.Context, } if errors.Is(err, sql.ErrNoRows) { - return time.Time{}, errLeaseOutputNoRow + return time.Time{}, ErrLeaseOutputNoRow } - return time.Time{}, fmt.Errorf("acquire lease row: %w", err) + return time.Time{}, fmt.Errorf("Acquire lease row: %w", err) } -// hasUtxo reports whether the requested outpoint still exists as a current +// HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. -func (o *pgLeaseOutputOps) hasUtxo(ctx context.Context, +func (o *pgLeaseOutputOps) HasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) { - outputIndex, err := uint32ToInt32(params.OutPoint.Index) + outputIndex, err := Uint32ToInt32(params.OutPoint.Index) if err != nil { return false, fmt.Errorf("convert output index: %w", err) } diff --git a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go b/wallet/internal/db/postgres_utxostore_listleasedoutputs.go index 1944d7790e..79f8d522f2 100644 --- a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go +++ b/wallet/internal/db/postgres_utxostore_listleasedoutputs.go @@ -26,12 +26,12 @@ func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, leases := make([]LeasedOutput, len(rows)) for i, row := range rows { - outputIndex, err := int64ToUint32(int64(row.OutputIndex)) + outputIndex, err := Int64ToUint32(int64(row.OutputIndex)) if err != nil { return nil, fmt.Errorf("lease output index: %w", err) } - lease, err := buildLeasedOutput( + lease, err := BuildLeasedOutput( row.TxHash, outputIndex, row.LockID, row.ExpiresAt, ) if err != nil { diff --git a/wallet/internal/db/postgres_utxostore_listutxos.go b/wallet/internal/db/postgres_utxostore_listutxos.go index 2a9afdeeb3..fbbb76666b 100644 --- a/wallet/internal/db/postgres_utxostore_listutxos.go +++ b/wallet/internal/db/postgres_utxostore_listutxos.go @@ -40,8 +40,8 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, func buildListUtxosParamsPg(query ListUtxosQuery) sqlcpg.ListUtxosParams { return sqlcpg.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: nullableUint32ToSQLInt64(query.Account), - MinConfirms: nullableInt32ToSQLInt32(query.MinConfs), - MaxConfirms: nullableInt32ToSQLInt32(query.MaxConfs), + AccountNumber: NullableUint32ToSQLInt64(query.Account), + MinConfirms: NullableInt32ToSQLInt32(query.MinConfs), + MaxConfirms: NullableInt32ToSQLInt32(query.MaxConfs), } } diff --git a/wallet/internal/db/postgres_utxostore_releaseoutput.go b/wallet/internal/db/postgres_utxostore_releaseoutput.go index 579118dd17..5557e03a5b 100644 --- a/wallet/internal/db/postgres_utxostore_releaseoutput.go +++ b/wallet/internal/db/postgres_utxostore_releaseoutput.go @@ -19,7 +19,7 @@ func (s *PostgresStore) ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return releaseOutputWithOps( + return ReleaseOutputWithOps( ctx, params, &pgReleaseOutputOps{qtx: qtx}, ) }) @@ -31,14 +31,14 @@ type pgReleaseOutputOps struct { qtx *sqlcpg.Queries } -var _ releaseOutputOps = (*pgReleaseOutputOps)(nil) +var _ ReleaseOutputOps = (*pgReleaseOutputOps)(nil) -// lookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO +// LookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO // row ID. -func (o *pgReleaseOutputOps) lookupUtxoID(ctx context.Context, +func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) { - outputIndex, err := uint32ToInt32(params.OutPoint.Index) + outputIndex, err := Uint32ToInt32(params.OutPoint.Index) if err != nil { return 0, err } @@ -52,7 +52,7 @@ func (o *pgReleaseOutputOps) lookupUtxoID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return 0, errReleaseOutputUtxoNotFound + return 0, ErrReleaseOutputUtxoNotFound } return 0, fmt.Errorf("lookup utxo row: %w", err) @@ -61,9 +61,9 @@ func (o *pgReleaseOutputOps) lookupUtxoID(ctx context.Context, return utxoID, nil } -// release attempts to delete the postgres lease row for the provided UTXO ID +// Release attempts to delete the postgres lease row for the provided UTXO ID // and lock ID. -func (o *pgReleaseOutputOps) release(ctx context.Context, walletID uint32, +func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( @@ -74,18 +74,18 @@ func (o *pgReleaseOutputOps) release(ctx context.Context, walletID uint32, }, ) if err != nil { - return 0, fmt.Errorf("release lease row: %w", err) + return 0, fmt.Errorf("Release lease row: %w", err) } return rows, nil } -// activeLockID returns the currently active postgres lease lock ID for the +// ActiveLockID returns the currently active postgres lease lock ID for the // provided UTXO ID. -func (o *pgReleaseOutputOps) activeLockID(ctx context.Context, walletID uint32, +func (o *pgReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { - activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + ActiveLockID, err := o.qtx.GetActiveUtxoLeaseLockID( ctx, sqlcpg.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, @@ -94,11 +94,11 @@ func (o *pgReleaseOutputOps) activeLockID(ctx context.Context, walletID uint32, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, errReleaseOutputNoActiveLease + return nil, ErrReleaseOutputNoActiveLease } return nil, fmt.Errorf("lookup active lease row: %w", err) } - return activeLockID, nil + return ActiveLockID, nil } diff --git a/wallet/internal/db/safecasting.go b/wallet/internal/db/safecasting.go index 5188a5f608..ac878eb8d6 100644 --- a/wallet/internal/db/safecasting.go +++ b/wallet/internal/db/safecasting.go @@ -17,9 +17,9 @@ var ( ErrInvalidNullInt = errors.New("invalid NullInt") ) -// int64ToUint32 safely casts an int64 to an uint32, returning an error +// Int64ToUint32 safely casts an int64 to an uint32, returning an error // if the value is out of range. -func int64ToUint32(v int64) (uint32, error) { +func Int64ToUint32(v int64) (uint32, error) { if v < 0 || v > math.MaxUint32 { return 0, fmt.Errorf("could not cast %d to uint32: %w", v, ErrCastingOverflow) @@ -28,9 +28,9 @@ func int64ToUint32(v int64) (uint32, error) { return uint32(v), nil } -// int64ToInt32 safely casts an int64 to an int32, returning an error +// Int64ToInt32 safely casts an int64 to an int32, returning an error // if the value is out of range. -func int64ToInt32(v int64) (int32, error) { +func Int64ToInt32(v int64) (int32, error) { if v < math.MinInt32 || v > math.MaxInt32 { return 0, fmt.Errorf("could not cast %d to int32: %w", v, ErrCastingOverflow) @@ -50,9 +50,9 @@ func int64ToUint8(v int64) (uint8, error) { return uint8(v), nil } -// int16ToUint8 safely casts an int16 to an uint8, returning an error +// Int16ToUint8 safely casts an int16 to an uint8, returning an error // if the value is out of range. -func int16ToUint8(v int16) (uint8, error) { +func Int16ToUint8(v int16) (uint8, error) { if v < 0 || v > math.MaxUint8 { return 0, fmt.Errorf("could not cast %d to uint8: %w", v, ErrCastingOverflow) @@ -61,9 +61,9 @@ func int16ToUint8(v int16) (uint8, error) { return uint8(v), nil } -// uint32ToInt32 safely casts an uint32 to an int32, returning an error +// Uint32ToInt32 safely casts an uint32 to an int32, returning an error // if the value is out of range. -func uint32ToInt32(v uint32) (int32, error) { +func Uint32ToInt32(v uint32) (int32, error) { if v > math.MaxInt32 { return 0, fmt.Errorf("could not cast %d to int32: %w", v, ErrCastingOverflow) @@ -72,9 +72,9 @@ func uint32ToInt32(v uint32) (int32, error) { return int32(v), nil } -// uint32ToInt16 safely casts an uint32 to an int16, returning an error +// Uint32ToInt16 safely casts an uint32 to an int16, returning an error // if the value is out of range. -func uint32ToInt16(v uint32) (int16, error) { +func Uint32ToInt16(v uint32) (int16, error) { if v > math.MaxInt16 { return 0, fmt.Errorf("could not cast %d to int16: %w", v, ErrCastingOverflow) @@ -83,10 +83,10 @@ func uint32ToInt16(v uint32) (int16, error) { return int16(v), nil } -// uint32ToNullInt32 safely casts an uint32 to a sql.NullInt32, returning +// Uint32ToNullInt32 safely casts an uint32 to a sql.NullInt32, returning // an error if the value is out of range. -func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { - toInt32, err := uint32ToInt32(v) +func Uint32ToNullInt32(v uint32) (sql.NullInt32, error) { + toInt32, err := Uint32ToInt32(v) if err != nil { return sql.NullInt32{}, err } @@ -94,8 +94,8 @@ func uint32ToNullInt32(v uint32) (sql.NullInt32, error) { return sql.NullInt32{Int32: toInt32, Valid: true}, nil } -// nullableInt32ToSQLInt32 converts an optional int32 to sql.NullInt32. -func nullableInt32ToSQLInt32(v *int32) sql.NullInt32 { +// NullableInt32ToSQLInt32 converts an optional int32 to sql.NullInt32. +func NullableInt32ToSQLInt32(v *int32) sql.NullInt32 { if v == nil { return sql.NullInt32{} } @@ -103,8 +103,8 @@ func nullableInt32ToSQLInt32(v *int32) sql.NullInt32 { return sql.NullInt32{Int32: *v, Valid: true} } -// nullableInt32ToSQLInt64 converts an optional int32 to sql.NullInt64. -func nullableInt32ToSQLInt64(v *int32) sql.NullInt64 { +// NullableInt32ToSQLInt64 converts an optional int32 to sql.NullInt64. +func NullableInt32ToSQLInt64(v *int32) sql.NullInt64 { if v == nil { return sql.NullInt64{} } @@ -112,8 +112,8 @@ func nullableInt32ToSQLInt64(v *int32) sql.NullInt64 { return sql.NullInt64{Int64: int64(*v), Valid: true} } -// nullableUint32ToSQLInt64 converts an optional uint32 to sql.NullInt64. -func nullableUint32ToSQLInt64(v *uint32) sql.NullInt64 { +// NullableUint32ToSQLInt64 converts an optional uint32 to sql.NullInt64. +func NullableUint32ToSQLInt64(v *uint32) sql.NullInt64 { if v == nil { return sql.NullInt64{} } @@ -121,9 +121,9 @@ func nullableUint32ToSQLInt64(v *uint32) sql.NullInt64 { return sql.NullInt64{Int64: int64(*v), Valid: true} } -// nullInt32ToUint32 safely casts a sql.NullInt32 to an uint32, returning +// NullInt32ToUint32 safely casts a sql.NullInt32 to an uint32, returning // an error if the value is out of range or invalid. -func nullInt32ToUint32(n sql.NullInt32) (uint32, error) { +func NullInt32ToUint32(n sql.NullInt32) (uint32, error) { if !n.Valid { return 0, fmt.Errorf("could not cast invalid NullInt32 to uint32: %w", ErrInvalidNullInt) diff --git a/wallet/internal/db/safecasting_test.go b/wallet/internal/db/safecasting_test.go index 4523127a62..8b5360b5b3 100644 --- a/wallet/internal/db/safecasting_test.go +++ b/wallet/internal/db/safecasting_test.go @@ -38,7 +38,7 @@ func TestInt64ToUint32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := int64ToUint32(tc.val) + got, err := Int64ToUint32(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -88,7 +88,7 @@ func TestInt64ToInt32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := int64ToInt32(tc.val) + got, err := Int64ToInt32(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -188,7 +188,7 @@ func TestInt16ToUint8(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := int16ToUint8(tc.val) + got, err := Int16ToUint8(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -225,7 +225,7 @@ func TestUint32ToInt32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := uint32ToInt32(tc.val) + got, err := Uint32ToInt32(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -262,7 +262,7 @@ func TestUint32ToInt16(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := uint32ToInt16(tc.val) + got, err := Uint32ToInt16(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -310,7 +310,7 @@ func TestUint32ToNullInt32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := uint32ToNullInt32(tc.val) + got, err := Uint32ToNullInt32(tc.val) if tc.wantErr { require.ErrorIs(t, err, ErrCastingOverflow) return @@ -360,7 +360,7 @@ func TestNullInt32ToUint32(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := nullInt32ToUint32(tc.val) + got, err := NullInt32ToUint32(tc.val) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) return @@ -379,10 +379,10 @@ func TestNullableInt32ToSQLInt32(t *testing.T) { value := int32(42) - require.Equal(t, sql.NullInt32{}, nullableInt32ToSQLInt32(nil)) + require.Equal(t, sql.NullInt32{}, NullableInt32ToSQLInt32(nil)) require.Equal(t, sql.NullInt32{Int32: value, Valid: true}, - nullableInt32ToSQLInt32(&value), + NullableInt32ToSQLInt32(&value), ) } @@ -393,10 +393,10 @@ func TestNullableInt32ToSQLInt64(t *testing.T) { value := int32(42) - require.Equal(t, sql.NullInt64{}, nullableInt32ToSQLInt64(nil)) + require.Equal(t, sql.NullInt64{}, NullableInt32ToSQLInt64(nil)) require.Equal(t, sql.NullInt64{Int64: int64(value), Valid: true}, - nullableInt32ToSQLInt64(&value), + NullableInt32ToSQLInt64(&value), ) } @@ -407,9 +407,9 @@ func TestNullableUint32ToSQLInt64(t *testing.T) { value := uint32(42) - require.Equal(t, sql.NullInt64{}, nullableUint32ToSQLInt64(nil)) + require.Equal(t, sql.NullInt64{}, NullableUint32ToSQLInt64(nil)) require.Equal(t, sql.NullInt64{Int64: int64(value), Valid: true}, - nullableUint32ToSQLInt64(&value), + NullableUint32ToSQLInt64(&value), ) } diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite.go index 53e582f761..e12ec6dc13 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite.go @@ -87,7 +87,7 @@ func (s *SqliteStore) Close() error { func (s *SqliteStore) ExecuteTx(ctx context.Context, fn func(*sqlcsqlite.Queries) error) error { - return execInTx(ctx, s.db, func(tx *sql.Tx) error { + return ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) return fn(qtx) }) diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite_txstore_createtx.go index 3abea75519..af0ad7ade8 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite_txstore_createtx.go @@ -17,19 +17,19 @@ import ( // The full write runs inside ExecuteTx so the transaction row, created UTXOs, // spent-parent markers, and any required invalidation are either committed // together or not at all. Received timestamps are normalized to UTC before -// insert. When the wallet already stores the same unmined transaction hash, +// Insert. When the wallet already stores the same unmined transaction hash, // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. func (s *SqliteStore) CreateTx(ctx context.Context, params CreateTxParams) error { - req, err := newCreateTxRequest(params) + req, err := NewCreateTxRequest(params) if err != nil { return err } return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return createTxWithOps(ctx, req, &sqliteCreateTxOps{ + return CreateTxWithOps(ctx, req, &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: qtx, }, @@ -44,46 +44,46 @@ type sqliteCreateTxOps struct { blockHeight sql.NullInt64 } -var _ createTxOps = (*sqliteCreateTxOps)(nil) +var _ CreateTxOps = (*sqliteCreateTxOps)(nil) -// loadExisting loads any existing wallet-scoped row for the requested tx hash. -func (o *sqliteCreateTxOps) loadExisting(ctx context.Context, - req createTxRequest) (*createTxExistingTarget, error) { +// LoadExisting loads any existing wallet-scoped row for the requested tx hash. +func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, + req CreateTxRequest) (*CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcsqlite.GetTransactionMetaByHashParams{ - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, errCreateTxExistingNotFound + return nil, ErrCreateTxExistingNotFound } return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(meta.TxStatus) + status, err := ParseTxStatus(meta.TxStatus) if err != nil { return nil, err } - return &createTxExistingTarget{ - id: meta.ID, - status: status, - hasBlock: meta.BlockHeight.Valid, - isCoinbase: meta.IsCoinbase, + return &CreateTxExistingTarget{ + ID: meta.ID, + Status: status, + HasBlock: meta.BlockHeight.Valid, + IsCoinbase: meta.IsCoinbase, }, nil } -// confirmExisting promotes one existing unmined row to its confirmed state. -func (o *sqliteCreateTxOps) confirmExisting(ctx context.Context, - req createTxRequest, - _ createTxExistingTarget) error { +// ConfirmExisting promotes one existing unmined row to its confirmed state. +func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, + req CreateTxRequest, + _ CreateTxExistingTarget) error { - blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.params.Block) + blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -92,8 +92,8 @@ func (o *sqliteCreateTxOps) confirmExisting(ctx context.Context, ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{Int64: blockHeight, Valid: true}, Status: int64(TxStatusPublished), - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], }, ) if err != nil { @@ -101,24 +101,24 @@ func (o *sqliteCreateTxOps) confirmExisting(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", req.txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxNotFound) } return nil } -// prepareBlock validates the optional confirming block and caches the sqlite -// block-height value that the later insert query will store. -func (o *sqliteCreateTxOps) prepareBlock(ctx context.Context, - req createTxRequest) error { +// PrepareBlock validates the optional confirming block and caches the sqlite +// block-height value that the later Insert query will store. +func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, + req CreateTxRequest) error { o.blockHeight = sql.NullInt64{} - if req.params.Block == nil { + if req.Params.Block == nil { return nil } - height, err := requireBlockMatchesSqlite(ctx, o.qtx, req.params.Block) + height, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) if err != nil { return err } @@ -128,10 +128,10 @@ func (o *sqliteCreateTxOps) prepareBlock(ctx context.Context, return nil } -// listConflictTxns returns the direct conflict root IDs plus the matching tx +// ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. -func (o *sqliteCreateTxOps) listConflictTxns(ctx context.Context, - req createTxRequest) ([]int64, []chainhash.Hash, error) { +func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, + req CreateTxRequest) ([]int64, []chainhash.Hash, error) { rootIDs, err := collectSqliteConflictRootIDs(ctx, o.qtx, req) if err != nil { @@ -142,7 +142,7 @@ func (o *sqliteCreateTxOps) listConflictTxns(ctx context.Context, return nil, nil, nil } - rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.params.WalletID)) + rows, err := o.qtx.ListUnminedTransactions(ctx, int64(req.Params.WalletID)) if err != nil { return nil, nil, fmt.Errorf("list unmined txns: %w", err) } @@ -154,17 +154,17 @@ func (o *sqliteCreateTxOps) listConflictTxns(ctx context.Context, // IDs that currently own any wallet-controlled input spent by the incoming tx. func collectSqliteConflictRootIDs(ctx context.Context, qtx *sqlcsqlite.Queries, - req createTxRequest) (map[int64]struct{}, error) { + req CreateTxRequest) (map[int64]struct{}, error) { - if blockchain.IsCoinBaseTx(req.params.Tx) { + if blockchain.IsCoinBaseTx(req.Params.Tx) { return map[int64]struct{}{}, nil } - rootIDs := make(map[int64]struct{}, len(req.params.Tx.TxIn)) - for inputIndex, txIn := range req.params.Tx.TxIn { + rootIDs := make(map[int64]struct{}, len(req.Params.Tx.TxIn)) + for inputIndex, txIn := range req.Params.Tx.TxIn { spentByTxID, err := qtx.GetUtxoSpendByOutpoint( ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ - WalletID: int64(req.params.WalletID), + WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: int64(txIn.PreviousOutPoint.Index), }, @@ -214,47 +214,47 @@ func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, return rootIDs, rootHashes, nil } -// insert stores one new sqlite transaction row for CreateTx. -func (o *sqliteCreateTxOps) insert(ctx context.Context, - req createTxRequest) (int64, error) { +// Insert stores one new sqlite transaction row for CreateTx. +func (o *sqliteCreateTxOps) Insert(ctx context.Context, + req CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction( ctx, sqlcsqlite.InsertTransactionParams{ - WalletID: int64(req.params.WalletID), - TxHash: req.txHash[:], - RawTx: req.rawTx, + WalletID: int64(req.Params.WalletID), + TxHash: req.TxHash[:], + RawTx: req.RawTx, BlockHeight: o.blockHeight, - TxStatus: int64(req.params.Status), - ReceivedTime: req.received, - IsCoinbase: req.isCoinbase, - TxLabel: req.params.Label, + TxStatus: int64(req.Params.Status), + ReceivedTime: req.Received, + IsCoinbase: req.IsCoinbase, + TxLabel: req.Params.Label, }, ) if err != nil { - return 0, fmt.Errorf("insert tx row: %w", err) + return 0, fmt.Errorf("Insert tx row: %w", err) } return txID, nil } -// insertCredits stores any wallet-owned outputs created by the transaction. -func (o *sqliteCreateTxOps) insertCredits(ctx context.Context, - req createTxRequest, txID int64) error { +// InsertCredits stores any wallet-owned outputs created by the transaction. +func (o *sqliteCreateTxOps) InsertCredits(ctx context.Context, + req CreateTxRequest, txID int64) error { - return insertCreditsSqlite(ctx, o.qtx, req.params, txID) + return insertCreditsSqlite(ctx, o.qtx, req.Params, txID) } -// markInputsSpent records wallet-owned inputs spent by the transaction. -func (o *sqliteCreateTxOps) markInputsSpent(ctx context.Context, - req createTxRequest, txID int64) error { +// MarkInputsSpent records wallet-owned inputs spent by the transaction. +func (o *sqliteCreateTxOps) MarkInputsSpent(ctx context.Context, + req CreateTxRequest, txID int64) error { - return markInputsSpentSqlite(ctx, o.qtx, req.params, txID) + return markInputsSpentSqlite(ctx, o.qtx, req.Params, txID) } -// markTxnsReplaced marks the provided direct conflict roots replaced in one +// MarkTxnsReplaced marks the provided direct conflict roots replaced in one // batch update. -func (o *sqliteCreateTxOps) markTxnsReplaced( +func (o *sqliteCreateTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -271,9 +271,9 @@ func (o *sqliteCreateTxOps) markTxnsReplaced( return nil } -// insertReplacementEdges records replacement-history edges from each direct +// InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. -func (o *sqliteCreateTxOps) insertReplacementEdges( +func (o *sqliteCreateTxOps) InsertReplacementEdges( ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { @@ -286,7 +286,7 @@ func (o *sqliteCreateTxOps) insertReplacementEdges( }, ) if err != nil { - return fmt.Errorf("insert replacement edge for %d: %w", + return fmt.Errorf("Insert replacement edge for %d: %w", replacedTxID, err) } } @@ -336,7 +336,7 @@ func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, AddressID: addrRow.ID, }) if err != nil { - return fmt.Errorf("insert credit output %d: %w", index, err) + return fmt.Errorf("Insert credit output %d: %w", index, err) } } diff --git a/wallet/internal/db/sqlite_txstore_deletetx.go b/wallet/internal/db/sqlite_txstore_deletetx.go index 7ad8be7b51..691e57865f 100644 --- a/wallet/internal/db/sqlite_txstore_deletetx.go +++ b/wallet/internal/db/sqlite_txstore_deletetx.go @@ -21,7 +21,7 @@ func (s *SqliteStore) DeleteTx(ctx context.Context, params DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return deleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) + return DeleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) }) } @@ -30,11 +30,11 @@ type sqliteDeleteTxOps struct { qtx *sqlcsqlite.Queries } -var _ deleteTxOps = (*sqliteDeleteTxOps)(nil) +var _ DeleteTxOps = (*sqliteDeleteTxOps)(nil) -// loadDeleteTarget loads and validates the unmined transaction row DeleteTx is +// LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. -func (o sqliteDeleteTxOps) loadDeleteTarget(ctx context.Context, +func (o sqliteDeleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { meta, err := getDeleteTxMetaSqlite(ctx, o.qtx, walletID, txHash) @@ -45,17 +45,17 @@ func (o sqliteDeleteTxOps) loadDeleteTarget(ctx context.Context, return meta.ID, nil } -// ensureLeaf rejects DeleteTx when the target still has direct unmined child +// EnsureLeaf rejects DeleteTx when the target still has direct unmined child // spenders. -func (o sqliteDeleteTxOps) ensureLeaf(ctx context.Context, walletID uint32, +func (o sqliteDeleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error { return ensureDeleteLeafSqlite(ctx, o.qtx, walletID, txHash, txID) } -// clearSpentUtxos restores any wallet-owned parent outputs the transaction had +// ClearSpentUtxos restores any wallet-owned parent outputs the transaction had // marked spent. -func (o sqliteDeleteTxOps) clearSpentUtxos(ctx context.Context, +func (o sqliteDeleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -72,9 +72,9 @@ func (o sqliteDeleteTxOps) clearSpentUtxos(ctx context.Context, return nil } -// deleteCreatedUtxos removes any wallet-owned outputs created by the +// DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. -func (o sqliteDeleteTxOps) deleteCreatedUtxos(ctx context.Context, +func (o sqliteDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.DeleteUtxosByTxID( @@ -91,9 +91,9 @@ func (o sqliteDeleteTxOps) deleteCreatedUtxos(ctx context.Context, return nil } -// deleteUnminedTransaction removes the target unmined row after its dependent +// DeleteUnminedTransaction removes the target unmined row after its dependent // wallet state has been cleaned up. -func (o sqliteDeleteTxOps) deleteUnminedTransaction(ctx context.Context, +func (o sqliteDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { rows, err := o.qtx.DeleteUnminedTransactionByHash( @@ -121,7 +121,7 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return fmt.Errorf("list unmined txns: %w", err) } - candidates, err := buildUnminedTxRecords( + candidates, err := BuildUnminedTxRecords( rows, func(row sqlcsqlite.ListUnminedTransactionsRow) (int64, []byte, []byte) { @@ -135,14 +135,14 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, filtered := candidates[:0] for _, candidate := range candidates { - if candidate.id == txID { + if candidate.ID == txID { continue } filtered = append(filtered, candidate) } - if len(collectDirectChildTxIDs(txHash, filtered)) > 0 { + if len(CollectDirectChildTxIDs(txHash, filtered)) > 0 { return fmt.Errorf("delete tx %s: %w", txHash, ErrDeleteRequiresLeaf) } @@ -172,12 +172,12 @@ func getDeleteTxMetaSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(meta.TxStatus) + status, err := ParseTxStatus(meta.TxStatus) if err != nil { return sqlcsqlite.GetTransactionMetaByHashRow{}, err } - if meta.BlockHeight.Valid || !isUnminedStatus(status) { + if meta.BlockHeight.Valid || !IsUnminedStatus(status) { return sqlcsqlite.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, ErrDeleteRequiresUnmined) diff --git a/wallet/internal/db/sqlite_txstore_gettx.go b/wallet/internal/db/sqlite_txstore_gettx.go index c276800380..63c3382248 100644 --- a/wallet/internal/db/sqlite_txstore_gettx.go +++ b/wallet/internal/db/sqlite_txstore_gettx.go @@ -57,5 +57,5 @@ func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, } } - return buildTxInfo(hash, rawTx, received, block, status, label) + return BuildTxInfo(hash, rawTx, received, block, status, label) } diff --git a/wallet/internal/db/sqlite_txstore_invalidateunmined.go b/wallet/internal/db/sqlite_txstore_invalidateunmined.go index fe32b1a2ae..9d73c11c8e 100644 --- a/wallet/internal/db/sqlite_txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite_txstore_invalidateunmined.go @@ -16,7 +16,7 @@ func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, params InvalidateUnminedTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return invalidateUnminedTxWithOps( + return InvalidateUnminedTxWithOps( ctx, params, sqliteInvalidateUnminedTxOps{qtx: qtx}, ) }) @@ -28,12 +28,12 @@ type sqliteInvalidateUnminedTxOps struct { qtx *sqlcsqlite.Queries } -var _ invalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) +var _ InvalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) -// loadInvalidateTarget loads the root tx metadata used by the shared +// LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. -func (o sqliteInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { +func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, + walletID uint32, txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcsqlite.GetTransactionMetaByHashParams{ @@ -43,39 +43,39 @@ func (o sqliteInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return invalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + return InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) } - return invalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + return InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := parseTxStatus(row.TxStatus) + status, err := ParseTxStatus(row.TxStatus) if err != nil { - return invalidateUnminedTxTarget{}, err + return InvalidateUnminedTxTarget{}, err } - return invalidateUnminedTxTarget{ - id: row.ID, - txHash: txHash, - status: status, - hasBlock: row.BlockHeight.Valid, - isCoinbase: row.IsCoinbase, + return InvalidateUnminedTxTarget{ + ID: row.ID, + TxHash: txHash, + Status: status, + HasBlock: row.BlockHeight.Valid, + IsCoinbase: row.IsCoinbase, }, nil } -// listUnminedTxRecords loads and decodes the wallet's active unmined +// ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. -func (o sqliteInvalidateUnminedTxOps) listUnminedTxRecords( - ctx context.Context, walletID int64) ([]unminedTxRecord, error) { +func (o sqliteInvalidateUnminedTxOps) ListUnminedTxRecords( + ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return buildUnminedTxRecords( + return BuildUnminedTxRecords( rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( int64, []byte, []byte) { @@ -84,9 +84,9 @@ func (o sqliteInvalidateUnminedTxOps) listUnminedTxRecords( ) } -// clearSpentUtxos restores any wallet-owned parent outputs spent by the given +// ClearSpentUtxos restores any wallet-owned parent outputs spent by the given // transaction row. -func (o sqliteInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, +func (o sqliteInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -105,9 +105,9 @@ func (o sqliteInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, return nil } -// markTxnsFailed marks the provided tx rows failed in one +// MarkTxnsFailed marks the provided tx rows failed in one // batch update. -func (o sqliteInvalidateUnminedTxOps) markTxnsFailed( +func (o sqliteInvalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( diff --git a/wallet/internal/db/sqlite_txstore_listtxns.go b/wallet/internal/db/sqlite_txstore_listtxns.go index cc6fb642ef..0a92e1ac66 100644 --- a/wallet/internal/db/sqlite_txstore_listtxns.go +++ b/wallet/internal/db/sqlite_txstore_listtxns.go @@ -79,7 +79,7 @@ func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, return nil, err } - info, err := buildTxInfo( + info, err := BuildTxInfo( row.TxHash, row.RawTx, row.ReceivedTime, block, row.TxStatus, row.TxLabel, ) diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite_txstore_rollback.go index 81230b7d60..0f0e0b4829 100644 --- a/wallet/internal/db/sqlite_txstore_rollback.go +++ b/wallet/internal/db/sqlite_txstore_rollback.go @@ -16,7 +16,7 @@ func (s *SqliteStore) RollbackToBlock(ctx context.Context, height uint32) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return rollbackToBlockWithOps(ctx, height, + return RollbackToBlockWithOps(ctx, height, sqliteRollbackToBlockOps{qtx: qtx}) }) } @@ -27,11 +27,11 @@ type sqliteRollbackToBlockOps struct { qtx *sqlcsqlite.Queries } -var _ rollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) +var _ RollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) -// listRollbackRootHashes loads the coinbase roots that a rollback disconnects +// ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. -func (o sqliteRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, +func (o sqliteRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, int64(height)) @@ -42,9 +42,9 @@ func (o sqliteRollbackToBlockOps) listRollbackRootHashes(ctx context.Context, return groupRollbackCoinbaseRootsSqlite(rows) } -// rewindWalletSyncStateHeights clamps wallet sync-state references below the +// RewindWalletSyncStateHeights clamps wallet sync-state references below the // rollback boundary before the block rows are deleted. -func (o sqliteRollbackToBlockOps) rewindWalletSyncStateHeights( +func (o sqliteRollbackToBlockOps) RewindWalletSyncStateHeights( ctx context.Context, height uint32) error { newHeight := sql.NullInt64{} @@ -65,9 +65,9 @@ func (o sqliteRollbackToBlockOps) rewindWalletSyncStateHeights( return nil } -// deleteBlocksAtOrAboveHeight removes the shared block rows after sync-state +// DeleteBlocksAtOrAboveHeight removes the shared block rows after sync-state // references have been rewound. -func (o sqliteRollbackToBlockOps) deleteBlocksAtOrAboveHeight( +func (o sqliteRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { _, err := o.qtx.DeleteBlocksAtOrAboveHeight(ctx, int64(height)) @@ -78,9 +78,9 @@ func (o sqliteRollbackToBlockOps) deleteBlocksAtOrAboveHeight( return nil } -// markTxRootsOrphaned rewrites each disconnected coinbase root to the +// MarkTxRootsOrphaned rewrites each disconnected coinbase root to the // orphaned state once its confirming block has been deleted. -func (o sqliteRollbackToBlockOps) markTxRootsOrphaned( +func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error { @@ -109,17 +109,17 @@ func (o sqliteRollbackToBlockOps) markTxRootsOrphaned( return nil } -// listUnminedTxRecords loads and decodes every unmined transaction row for the +// ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. -func (o sqliteRollbackToBlockOps) listUnminedTxRecords( - ctx context.Context, walletID int64) ([]unminedTxRecord, error) { +func (o sqliteRollbackToBlockOps) ListUnminedTxRecords( + ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return buildUnminedTxRecords(rows, + return BuildUnminedTxRecords(rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( int64, []byte, []byte) { @@ -128,9 +128,9 @@ func (o sqliteRollbackToBlockOps) listUnminedTxRecords( ) } -// clearDescendantSpends removes any wallet-owned spend edges claimed by one +// ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant transaction before its status is rewritten. -func (o sqliteRollbackToBlockOps) clearDescendantSpends( +func (o sqliteRollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -149,9 +149,9 @@ func (o sqliteRollbackToBlockOps) clearDescendantSpends( return nil } -// markDescendantsFailed batch-marks the collected rollback descendants as +// MarkDescendantsFailed batch-marks the collected rollback descendants as // failed once every dependent spend edge has been cleared. -func (o sqliteRollbackToBlockOps) markDescendantsFailed( +func (o sqliteRollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -178,7 +178,7 @@ func groupRollbackCoinbaseRootsSqlite( map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { - walletID, err := int64ToUint32(row.WalletID) + walletID, err := Int64ToUint32(row.WalletID) if err != nil { return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) } diff --git a/wallet/internal/db/sqlite_txstore_updatetx.go b/wallet/internal/db/sqlite_txstore_updatetx.go index 3444543f97..d6a79f91bb 100644 --- a/wallet/internal/db/sqlite_txstore_updatetx.go +++ b/wallet/internal/db/sqlite_txstore_updatetx.go @@ -20,7 +20,7 @@ func (s *SqliteStore) UpdateTx(ctx context.Context, params UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return updateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) + return UpdateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) }) } @@ -38,11 +38,11 @@ type sqliteUpdateTxOps struct { status int64 } -var _ updateTxOps = (*sqliteUpdateTxOps)(nil) +var _ UpdateTxOps = (*sqliteUpdateTxOps)(nil) -// loadIsCoinbase loads the existing row metadata UpdateTx needs before it can +// LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. -func (o *sqliteUpdateTxOps) loadIsCoinbase(ctx context.Context, +func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -63,9 +63,9 @@ func (o *sqliteUpdateTxOps) loadIsCoinbase(ctx context.Context, return meta.IsCoinbase, nil } -// prepareState validates any referenced confirming block and captures the +// PrepareState validates any referenced confirming block and captures the // sqlite-specific state params for the later row update. -func (o *sqliteUpdateTxOps) prepareState(ctx context.Context, +func (o *sqliteUpdateTxOps) PrepareState(ctx context.Context, state UpdateTxState) error { blockHeight := sql.NullInt64{} @@ -85,8 +85,8 @@ func (o *sqliteUpdateTxOps) prepareState(ctx context.Context, return nil } -// updateLabel writes one user-visible label change. -func (o *sqliteUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, +// UpdateLabel writes one user-visible label change. +func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { rows, err := o.qtx.UpdateTransactionLabelByHash( @@ -108,9 +108,9 @@ func (o *sqliteUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, return nil } -// updateState writes one block/status patch after prepareState has validated +// UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. -func (o *sqliteUpdateTxOps) updateState(ctx context.Context, walletID uint32, +func (o *sqliteUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, _ UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( diff --git a/wallet/internal/db/sqlite_utxostore_balance.go b/wallet/internal/db/sqlite_utxostore_balance.go index 2067550dc2..306c3126c0 100644 --- a/wallet/internal/db/sqlite_utxostore_balance.go +++ b/wallet/internal/db/sqlite_utxostore_balance.go @@ -18,10 +18,10 @@ func (s *SqliteStore) Balance(ctx context.Context, balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: nullableUint32ToSQLInt64(params.Account), - MinConfirms: nullableInt32ToSQLInt64(params.MinConfs), - MaxConfirms: nullableInt32ToSQLInt64(params.MaxConfs), - CoinbaseMaturity: nullableInt32ToSQLInt64(params.CoinbaseMaturity), + AccountNumber: NullableUint32ToSQLInt64(params.Account), + MinConfirms: NullableInt32ToSQLInt64(params.MinConfs), + MaxConfirms: NullableInt32ToSQLInt64(params.MaxConfs), + CoinbaseMaturity: NullableInt32ToSQLInt64(params.CoinbaseMaturity), }) if err != nil { return BalanceResult{}, fmt.Errorf("balance: %w", err) diff --git a/wallet/internal/db/sqlite_utxostore_getutxo.go b/wallet/internal/db/sqlite_utxostore_getutxo.go index dce91783e5..c5efca3f26 100644 --- a/wallet/internal/db/sqlite_utxostore_getutxo.go +++ b/wallet/internal/db/sqlite_utxostore_getutxo.go @@ -45,14 +45,14 @@ func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight sql.NullInt64) (*UtxoInfo, error) { - index, err := int64ToUint32(outputIndex) + index, err := Int64ToUint32(outputIndex) if err != nil { return nil, fmt.Errorf("utxo output index: %w", err) } var height *uint32 if blockHeight.Valid { - heightValue, err := int64ToUint32(blockHeight.Int64) + heightValue, err := Int64ToUint32(blockHeight.Int64) if err != nil { return nil, fmt.Errorf("utxo block height: %w", err) } @@ -60,7 +60,7 @@ func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, height = &heightValue } - return buildUtxoInfo( + return BuildUtxoInfo( hash, index, amount, pkScript, received, isCoinbase, height, ) } diff --git a/wallet/internal/db/sqlite_utxostore_leaseoutput.go b/wallet/internal/db/sqlite_utxostore_leaseoutput.go index e3033a27be..202de6f4f9 100644 --- a/wallet/internal/db/sqlite_utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite_utxostore_leaseoutput.go @@ -14,14 +14,14 @@ import ( // // The lease lookup and acquisition run in one transaction so competing calls // cannot observe a partially-written lease. Expiration timestamps are -// normalized to UTC before insert. +// normalized to UTC before Insert. func (s *SqliteStore) LeaseOutput(ctx context.Context, params LeaseOutputParams) (*LeasedOutput, error) { var lease *LeasedOutput err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - acquiredLease, err := leaseOutputWithOps( + acquiredLease, err := LeaseOutputWithOps( ctx, params, &sqliteLeaseOutputOps{qtx: qtx}, ) if err != nil { @@ -45,11 +45,11 @@ type sqliteLeaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ leaseOutputOps = (*sqliteLeaseOutputOps)(nil) +var _ LeaseOutputOps = (*sqliteLeaseOutputOps)(nil) -// acquire attempts to write or renew one sqlite lease row for the requested +// Acquire attempts to write or renew one sqlite lease row for the requested // outpoint. -func (o *sqliteLeaseOutputOps) acquire(ctx context.Context, +func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { @@ -65,18 +65,18 @@ func (o *sqliteLeaseOutputOps) acquire(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return time.Time{}, errLeaseOutputNoRow + return time.Time{}, ErrLeaseOutputNoRow } - return time.Time{}, fmt.Errorf("acquire lease row: %w", err) + return time.Time{}, fmt.Errorf("Acquire lease row: %w", err) } return expiration, nil } -// hasUtxo reports whether the requested outpoint still exists as a current +// HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. -func (o *sqliteLeaseOutputOps) hasUtxo(ctx context.Context, +func (o *sqliteLeaseOutputOps) HasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) { _, err := o.qtx.GetUtxoIDByOutpoint( diff --git a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go index 4be3fbfee3..63c4000470 100644 --- a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go @@ -26,12 +26,12 @@ func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, leases := make([]LeasedOutput, len(rows)) for i, row := range rows { - outputIndex, err := int64ToUint32(row.OutputIndex) + outputIndex, err := Int64ToUint32(row.OutputIndex) if err != nil { return nil, fmt.Errorf("lease output index: %w", err) } - lease, err := buildLeasedOutput( + lease, err := BuildLeasedOutput( row.TxHash, outputIndex, row.LockID, row.ExpiresAt, ) if err != nil { diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite_utxostore_listutxos.go index 741b4ce2ae..20119197a6 100644 --- a/wallet/internal/db/sqlite_utxostore_listutxos.go +++ b/wallet/internal/db/sqlite_utxostore_listutxos.go @@ -16,9 +16,9 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: nullableUint32ToSQLInt64(query.Account), - MinConfirms: nullableInt32ToSQLInt64(query.MinConfs), - MaxConfirms: nullableInt32ToSQLInt64(query.MaxConfs), + AccountNumber: NullableUint32ToSQLInt64(query.Account), + MinConfirms: NullableInt32ToSQLInt64(query.MinConfs), + MaxConfirms: NullableInt32ToSQLInt64(query.MaxConfs), }) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) diff --git a/wallet/internal/db/sqlite_utxostore_releaseoutput.go b/wallet/internal/db/sqlite_utxostore_releaseoutput.go index 3ecc4d52bb..968ea65229 100644 --- a/wallet/internal/db/sqlite_utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite_utxostore_releaseoutput.go @@ -19,7 +19,7 @@ func (s *SqliteStore) ReleaseOutput(ctx context.Context, params ReleaseOutputParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return releaseOutputWithOps( + return ReleaseOutputWithOps( ctx, params, &sqliteReleaseOutputOps{qtx: qtx}, ) }) @@ -31,11 +31,11 @@ type sqliteReleaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ releaseOutputOps = (*sqliteReleaseOutputOps)(nil) +var _ ReleaseOutputOps = (*sqliteReleaseOutputOps)(nil) -// lookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row +// LookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row // ID. -func (o *sqliteReleaseOutputOps) lookupUtxoID(ctx context.Context, +func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) { utxoID, err := o.qtx.GetUtxoIDByOutpoint( @@ -47,7 +47,7 @@ func (o *sqliteReleaseOutputOps) lookupUtxoID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return 0, errReleaseOutputUtxoNotFound + return 0, ErrReleaseOutputUtxoNotFound } return 0, fmt.Errorf("lookup utxo row: %w", err) @@ -56,9 +56,9 @@ func (o *sqliteReleaseOutputOps) lookupUtxoID(ctx context.Context, return utxoID, nil } -// release attempts to delete the sqlite lease row for the provided UTXO ID and +// Release attempts to delete the sqlite lease row for the provided UTXO ID and // lock ID. -func (o *sqliteReleaseOutputOps) release(ctx context.Context, walletID uint32, +func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( @@ -69,18 +69,18 @@ func (o *sqliteReleaseOutputOps) release(ctx context.Context, walletID uint32, }, ) if err != nil { - return 0, fmt.Errorf("release lease row: %w", err) + return 0, fmt.Errorf("Release lease row: %w", err) } return rows, nil } -// activeLockID returns the currently active sqlite lease lock ID for the +// ActiveLockID returns the currently active sqlite lease lock ID for the // provided UTXO ID. -func (o *sqliteReleaseOutputOps) activeLockID(ctx context.Context, +func (o *sqliteReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { - activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + ActiveLockID, err := o.qtx.GetActiveUtxoLeaseLockID( ctx, sqlcsqlite.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, @@ -89,11 +89,11 @@ func (o *sqliteReleaseOutputOps) activeLockID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, errReleaseOutputNoActiveLease + return nil, ErrReleaseOutputNoActiveLease } return nil, fmt.Errorf("lookup active lease row: %w", err) } - return activeLockID, nil + return ActiveLockID, nil } diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go index a79ac45f31..ee34c55681 100644 --- a/wallet/internal/db/tx.go +++ b/wallet/internal/db/tx.go @@ -6,13 +6,13 @@ import ( "fmt" ) -// execInTx executes a function within a database transaction. It handles +// ExecInTx executes a function within a database transaction. It handles // the transaction lifecycle: begin, commit, and rollback on error. // // This is a helper function used by the public ExecuteTx methods on // PostgresStore and SqliteStore. It guarantees that the transaction // will be either committed (on success) or rolled back (on error or panic). -func execInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { +func ExecInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go index 596ae4e83c..fe887e0ca9 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -24,7 +24,7 @@ type errorDBTX struct { // ExecContext implements the sqlc DBTX interface. func (e errorDBTX) ExecContext(context.Context, string, - ...interface{}) (sql.Result, error) { + ...any) (sql.Result, error) { return nil, e.execErr } @@ -38,14 +38,14 @@ func (e errorDBTX) PrepareContext(context.Context, // QueryContext implements the sqlc DBTX interface. func (e errorDBTX) QueryContext(context.Context, string, - ...interface{}) (*sql.Rows, error) { + ...any) (*sql.Rows, error) { return nil, e.queryErr } // QueryRowContext implements the sqlc DBTX interface. func (e errorDBTX) QueryRowContext(context.Context, string, - ...interface{}) *sql.Row { + ...any) *sql.Row { return &sql.Row{} } @@ -63,24 +63,24 @@ func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { deleteOps := pgDeleteTxOps{qtx: qtx} rollbackOps := pgRollbackToBlockOps{qtx: qtx} - err := deleteOps.clearSpentUtxos(t.Context(), 1, 2) + err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxo rows") - err = deleteOps.deleteCreatedUtxos(t.Context(), 1, 2) + err = deleteOps.DeleteCreatedUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "delete created utxo rows") - _, err = deleteOps.deleteUnminedTransaction( + _, err = deleteOps.DeleteUnminedTransaction( t.Context(), 1, chainhash.Hash{1}, ) require.ErrorContains(t, err, "delete unmined tx row") - _, err = rollbackOps.listUnminedTxRecords(t.Context(), 1) + _, err = rollbackOps.ListUnminedTxRecords(t.Context(), 1) require.ErrorContains(t, err, "list unmined txns") - err = rollbackOps.clearDescendantSpends(t.Context(), 1, 2) + err = rollbackOps.ClearDescendantSpends(t.Context(), 1, 2) require.ErrorContains(t, err, "clear descendant spends") - err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) + err = rollbackOps.MarkDescendantsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark descendants failed") } @@ -94,30 +94,30 @@ func TestSqliteDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { deleteOps := sqliteDeleteTxOps{qtx: qtx} rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} - err := deleteOps.clearSpentUtxos(t.Context(), 1, 2) + err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxo rows") - err = deleteOps.deleteCreatedUtxos(t.Context(), 1, 2) + err = deleteOps.DeleteCreatedUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "delete created utxo rows") - _, err = deleteOps.deleteUnminedTransaction( + _, err = deleteOps.DeleteUnminedTransaction( t.Context(), 1, chainhash.Hash{1}, ) require.ErrorContains(t, err, "delete unmined tx row") - _, err = rollbackOps.listUnminedTxRecords(t.Context(), 1) + _, err = rollbackOps.ListUnminedTxRecords(t.Context(), 1) require.ErrorContains(t, err, "list unmined txns") - err = rollbackOps.clearDescendantSpends(t.Context(), 1, 2) + err = rollbackOps.ClearDescendantSpends(t.Context(), 1, 2) require.ErrorContains(t, err, "clear descendant spends") - err = rollbackOps.markDescendantsFailed(t.Context(), 1, []int64{2}) + err = rollbackOps.MarkDescendantsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark descendants failed") } // TestPgTxStoreOpsWrapBackendErrors verifies that the postgres tx-store helper // adapters preserve step-specific error context for create, invalidate, -// rollback, update, and release workflows. +// rollback, update, and Release workflows. func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() @@ -130,15 +130,15 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { updateOps := &pgUpdateTxOps{qtx: qtx} releaseOps := pgReleaseOutputOps{qtx: qtx} - err := createOps.markTxnsReplaced( + err := createOps.MarkTxnsReplaced( t.Context(), 1, []int64{2}, ) require.ErrorContains(t, err, "mark txns replaced") - err = createOps.insertReplacementEdges( + err = createOps.InsertReplacementEdges( t.Context(), 1, []int64{2}, 3, ) - require.ErrorContains(t, err, "insert replacement edge") + require.ErrorContains(t, err, "Insert replacement edge") err = markInputsSpentPg(t.Context(), qtx, CreateTxParams{ WalletID: 1, @@ -148,45 +148,45 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { }, 7) require.ErrorContains(t, err, "mark spent input 0") - _, err = invalidateOps.listUnminedTxRecords(t.Context(), 1) + _, err = invalidateOps.ListUnminedTxRecords(t.Context(), 1) require.ErrorContains(t, err, "list unmined txns") - err = invalidateOps.clearSpentUtxos(t.Context(), 1, 2) + err = invalidateOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxos") - err = invalidateOps.markTxnsFailed(t.Context(), 1, []int64{2}) + err = invalidateOps.MarkTxnsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark txns failed") - _, err = rollbackOps.listRollbackRootHashes(t.Context(), 1) + _, err = rollbackOps.ListRollbackRootHashes(t.Context(), 1) require.ErrorContains(t, err, "query rollback coinbase roots") - err = rollbackOps.rewindWalletSyncStateHeights(t.Context(), 1) + err = rollbackOps.RewindWalletSyncStateHeights(t.Context(), 1) require.ErrorContains(t, err, "rewind wallet sync state heights query") - err = rollbackOps.deleteBlocksAtOrAboveHeight(t.Context(), 1) + err = rollbackOps.DeleteBlocksAtOrAboveHeight(t.Context(), 1) require.ErrorContains(t, err, "delete blocks at or above height query") - err = rollbackOps.markTxRootsOrphaned( + err = rollbackOps.MarkTxRootsOrphaned( t.Context(), 1, []chainhash.Hash{{1}}, ) require.ErrorContains(t, err, "update rollback coinbase state query") updateOps.blockHeight = sql.NullInt32{} updateOps.status = int16(TxStatusPublished) - err = updateOps.updateState(t.Context(), 1, chainhash.Hash{1}, + err = updateOps.UpdateState(t.Context(), 1, chainhash.Hash{1}, UpdateTxState{Status: TxStatusPublished}) require.ErrorContains(t, err, "update tx state query") - err = updateOps.updateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") require.ErrorContains(t, err, "update tx label query") - _, err = releaseOps.release(t.Context(), 1, 2, [32]byte{1}) - require.ErrorContains(t, err, "release lease row") + _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "Release lease row") } // TestSqliteTxStoreOpsWrapBackendErrors verifies that the sqlite tx-store // helper adapters preserve step-specific error context for create, invalidate, -// rollback, update, and release workflows. +// rollback, update, and Release workflows. func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() @@ -201,15 +201,15 @@ func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { updateOps := &sqliteUpdateTxOps{qtx: qtx} releaseOps := sqliteReleaseOutputOps{qtx: qtx} - err := createOps.markTxnsReplaced( + err := createOps.MarkTxnsReplaced( t.Context(), 1, []int64{2}, ) require.ErrorContains(t, err, "mark txns replaced") - err = createOps.insertReplacementEdges( + err = createOps.InsertReplacementEdges( t.Context(), 1, []int64{2}, 3, ) - require.ErrorContains(t, err, "insert replacement edge") + require.ErrorContains(t, err, "Insert replacement edge") err = markInputsSpentSqlite(t.Context(), qtx, CreateTxParams{ WalletID: 1, @@ -219,40 +219,40 @@ func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { }, 7) require.ErrorContains(t, err, "mark spent input 0") - _, err = invalidateOps.listUnminedTxRecords(t.Context(), 1) + _, err = invalidateOps.ListUnminedTxRecords(t.Context(), 1) require.ErrorContains(t, err, "list unmined txns") - err = invalidateOps.clearSpentUtxos(t.Context(), 1, 2) + err = invalidateOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxos") - err = invalidateOps.markTxnsFailed(t.Context(), 1, []int64{2}) + err = invalidateOps.MarkTxnsFailed(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark txns failed") - _, err = rollbackOps.listRollbackRootHashes(t.Context(), 1) + _, err = rollbackOps.ListRollbackRootHashes(t.Context(), 1) require.ErrorContains(t, err, "query rollback coinbase roots") - err = rollbackOps.rewindWalletSyncStateHeights(t.Context(), 1) + err = rollbackOps.RewindWalletSyncStateHeights(t.Context(), 1) require.ErrorContains(t, err, "rewind wallet sync state heights query") - err = rollbackOps.deleteBlocksAtOrAboveHeight(t.Context(), 1) + err = rollbackOps.DeleteBlocksAtOrAboveHeight(t.Context(), 1) require.ErrorContains(t, err, "delete blocks at or above height query") - err = rollbackOps.markTxRootsOrphaned( + err = rollbackOps.MarkTxRootsOrphaned( t.Context(), 1, []chainhash.Hash{{1}}, ) require.ErrorContains(t, err, "update rollback coinbase state query") updateOps.blockHeight = sql.NullInt64{} updateOps.status = int64(TxStatusPublished) - err = updateOps.updateState(t.Context(), 1, chainhash.Hash{1}, + err = updateOps.UpdateState(t.Context(), 1, chainhash.Hash{1}, UpdateTxState{Status: TxStatusPublished}) require.ErrorContains(t, err, "update tx state query") - err = updateOps.updateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") require.ErrorContains(t, err, "update tx label query") - _, err = releaseOps.release(t.Context(), 1, 2, [32]byte{1}) - require.ErrorContains(t, err, "release lease row") + _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "Release lease row") } // TestPgBackendHelpersRejectOverflow verifies the remaining postgres helper @@ -260,7 +260,7 @@ func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { func TestPgBackendHelpersRejectOverflow(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 1, Tx: &wire.MsgTx{ Version: wire.TxVersion, @@ -300,12 +300,12 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }, 3) require.ErrorContains(t, err, "convert input outpoint index 0") - err = pgRollbackToBlockOps{}.rewindWalletSyncStateHeights( + err = pgRollbackToBlockOps{}.RewindWalletSyncStateHeights( t.Context(), ^uint32(0), ) require.ErrorContains(t, err, "convert rollback height") - err = pgRollbackToBlockOps{}.deleteBlocksAtOrAboveHeight( + err = pgRollbackToBlockOps{}.DeleteBlocksAtOrAboveHeight( t.Context(), ^uint32(0), ) require.ErrorContains(t, err, "convert rollback height") @@ -319,14 +319,14 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { leaseOps := &pgLeaseOutputOps{} - _, err = leaseOps.acquire(t.Context(), LeaseOutputParams{ + _, err = leaseOps.Acquire(t.Context(), LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, }, time.Now(), time.Now().Add(time.Minute)) require.ErrorContains(t, err, "convert output index") - _, err = leaseOps.hasUtxo(t.Context(), LeaseOutputParams{ + _, err = leaseOps.HasUtxo(t.Context(), LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/tx_store_backend_rows_test.go index 4908380cb3..1370d0275d 100644 --- a/wallet/internal/db/tx_store_backend_rows_test.go +++ b/wallet/internal/db/tx_store_backend_rows_test.go @@ -39,7 +39,7 @@ type rowDBTX struct { // ExecContext implements the sqlc DBTX interface. func (r rowDBTX) ExecContext(context.Context, string, - ...interface{}) (sql.Result, error) { + ...any) (sql.Result, error) { if r.execErr != nil { return nil, r.execErr @@ -55,14 +55,14 @@ func (r rowDBTX) PrepareContext(context.Context, string) (*sql.Stmt, error) { // QueryContext implements the sqlc DBTX interface. func (r rowDBTX) QueryContext(context.Context, string, - ...interface{}) (*sql.Rows, error) { + ...any) (*sql.Rows, error) { return nil, r.queryErr } // QueryRowContext implements the sqlc DBTX interface. func (r rowDBTX) QueryRowContext(context.Context, string, - ...interface{}) *sql.Row { + ...any) *sql.Row { if r.row != nil { return r.row @@ -73,7 +73,7 @@ func (r rowDBTX) QueryRowContext(context.Context, string, // newSQLiteRow creates a query row backed by an in-memory sqlite database so // sqlc scan paths can fail without standing up a real store. -func newSQLiteRow(t *testing.T, query string, args ...interface{}) *sql.Row { +func newSQLiteRow(t *testing.T, query string, args ...any) *sql.Row { t.Helper() db, err := sql.Open("sqlite", ":memory:") @@ -95,12 +95,12 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { loadOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), }, } - _, err := loadOps.loadExisting(ctx, req) + _, err := loadOps.LoadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") block := &Block{ @@ -120,10 +120,10 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - err = confirmOps.confirmExisting(ctx, createTxRequest{ - params: CreateTxParams{WalletID: 1, Block: block}, - txHash: chainhash.Hash{9}, - }, createTxExistingTarget{}) + err = confirmOps.ConfirmExisting(ctx, CreateTxRequest{ + Params: CreateTxParams{WalletID: 1, Block: block}, + TxHash: chainhash.Hash{9}, + }, CreateTxExistingTarget{}) require.ErrorIs(t, err, ErrTxNotFound) conflictOps := &pgCreateTxOps{ @@ -134,7 +134,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - _, _, err = conflictOps.listConflictTxns(ctx, req) + _, _, err = conflictOps.ListConflictTxns(ctx, req) require.ErrorContains(t, err, "list unmined txns") } @@ -149,12 +149,12 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { loadOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), }, } - _, err := loadOps.loadExisting(ctx, req) + _, err := loadOps.LoadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") block := &Block{ @@ -174,21 +174,21 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - err = confirmOps.confirmExisting(ctx, createTxRequest{ - params: CreateTxParams{WalletID: 1, Block: block}, - txHash: chainhash.Hash{9}, - }, createTxExistingTarget{}) + err = confirmOps.ConfirmExisting(ctx, CreateTxRequest{ + Params: CreateTxParams{WalletID: 1, Block: block}, + TxHash: chainhash.Hash{9}, + }, CreateTxExistingTarget{}) require.ErrorIs(t, err, ErrTxNotFound) prepareOps := &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), }, } - err = prepareOps.prepareBlock(ctx, createTxRequest{ - params: CreateTxParams{WalletID: 1, Block: block}, + err = prepareOps.PrepareBlock(ctx, CreateTxRequest{ + Params: CreateTxParams{WalletID: 1, Block: block}, }) require.ErrorContains(t, err, "get block by height") @@ -200,26 +200,26 @@ func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - _, _, err = conflictOps.listConflictTxns(ctx, req) + _, _, err = conflictOps.ListConflictTxns(ctx, req) require.ErrorContains(t, err, "list unmined txns") } // TestSqliteReleaseOutputOpsAdditionalBranches covers the remaining sqlite -// release-helper query-row error wrappers. +// Release-helper query-row error wrappers. func TestSqliteReleaseOutputOpsAdditionalBranches(t *testing.T) { t.Parallel() ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} - _, err := ops.lookupUtxoID(context.Background(), ReleaseOutputParams{ + _, err := ops.LookupUtxoID(context.Background(), ReleaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, }) require.ErrorContains(t, err, "lookup utxo row") - _, err = ops.activeLockID(context.Background(), 1, 2, time.Now()) + _, err = ops.ActiveLockID(context.Background(), 1, 2, time.Now()) require.ErrorContains(t, err, "lookup active lease row") } @@ -232,7 +232,7 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} loadOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} stateOps := &pgUpdateTxOps{ qtx: sqlcpg.New(rowDBTX{rows: 0}), @@ -241,15 +241,15 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { } labelOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} - _, err := loadOps.loadIsCoinbase(ctx, 1, txHash) + _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") - err = stateOps.updateState(ctx, 1, txHash, UpdateTxState{ + err = stateOps.UpdateState(ctx, 1, txHash, UpdateTxState{ Status: TxStatusPublished, }) require.ErrorIs(t, err, ErrTxNotFound) - err = labelOps.updateLabel(ctx, 1, txHash, "note") + err = labelOps.UpdateLabel(ctx, 1, txHash, "note") require.ErrorIs(t, err, ErrTxNotFound) } @@ -262,7 +262,7 @@ func TestSqliteUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT * FROM missing_table"), + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} stateOps := &sqliteUpdateTxOps{ qtx: sqlcsqlite.New(rowDBTX{rows: 0}), @@ -271,14 +271,14 @@ func TestSqliteUpdateTxOpsAdditionalBranches(t *testing.T) { } labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} - _, err := loadOps.loadIsCoinbase(ctx, 1, txHash) + _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") - err = stateOps.updateState(ctx, 1, txHash, UpdateTxState{ + err = stateOps.UpdateState(ctx, 1, txHash, UpdateTxState{ Status: TxStatusPublished, }) require.ErrorIs(t, err, ErrTxNotFound) - err = labelOps.updateLabel(ctx, 1, txHash, "note") + err = labelOps.UpdateLabel(ctx, 1, txHash, "note") require.ErrorIs(t, err, ErrTxNotFound) } diff --git a/wallet/internal/db/tx_store_common.go b/wallet/internal/db/tx_store_common.go index 90fc273cca..5cea56ecfb 100644 --- a/wallet/internal/db/tx_store_common.go +++ b/wallet/internal/db/tx_store_common.go @@ -69,9 +69,9 @@ func deserializeMsgTx(rawTx []byte) (*wire.MsgTx, error) { return &tx, nil } -// parseTxStatus converts a stored numeric status code into the strongly typed +// ParseTxStatus converts a stored numeric status code into the strongly typed // TxStatus enum used by the public db API. -func parseTxStatus(status int64) (TxStatus, error) { +func ParseTxStatus(status int64) (TxStatus, error) { txStatus, err := int64ToUint8(status) if err != nil { return TxStatus(0), fmt.Errorf("status %d: %w", status, @@ -93,9 +93,9 @@ func parseTxStatus(status int64) (TxStatus, error) { } } -// buildTxInfo converts normalized transaction fields into the public TxInfo +// BuildTxInfo converts normalized transaction fields into the public TxInfo // shape returned by the db interfaces. -func buildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, +func BuildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, status int64, label string) (*TxInfo, error) { txHash, err := chainhash.NewHash(hash) @@ -103,7 +103,7 @@ func buildTxInfo(hash []byte, rawTx []byte, received time.Time, block *Block, return nil, fmt.Errorf("tx hash: %w", err) } - txStatus, err := parseTxStatus(status) + txStatus, err := ParseTxStatus(status) if err != nil { return nil, err } @@ -167,7 +167,7 @@ func validateCreateTxParams(params CreateTxParams) error { func validateCreateTxStatus(status TxStatus, hasBlock bool, isCoinbase bool) error { - _, err := parseTxStatus(int64(status)) + _, err := ParseTxStatus(int64(status)) if err != nil { return fmt.Errorf("%w: status %d is not supported: %w", ErrInvalidParam, status, ErrInvalidStatus) @@ -177,7 +177,7 @@ func validateCreateTxStatus(status TxStatus, hasBlock bool, // coinbase transaction. CreateTx records the initial observed facts, so it // never inserts orphaned history directly. if status == TxStatusOrphaned { - return fmt.Errorf("%w: CreateTx cannot insert orphaned txns: %w", + return fmt.Errorf("%w: CreateTx cannot Insert orphaned txns: %w", ErrInvalidParam, ErrInvalidStatus) } @@ -212,63 +212,71 @@ func validateCreateTxStatus(status TxStatus, hasBlock bool, return nil } -// createTxRequest captures the backend-independent CreateTx inputs after the +// CreateTxRequest captures the backend-independent CreateTx inputs after the // shared validation and normalization step has already succeeded. -type createTxRequest struct { - // params keeps the original public request available for backend helpers +type CreateTxRequest struct { + // Params keeps the original public request available for backend helpers // that still need the caller-supplied CreateTx metadata. - params CreateTxParams + Params CreateTxParams - // rawTx stores the serialized transaction bytes once so both backends reuse + // RawTx stores the serialized transaction bytes once so both backends reuse // the same payload throughout the write. - rawTx []byte + RawTx []byte - // txHash avoids recomputing the transaction hash across the shared flow and + // TxHash avoids recomputing the transaction hash across the shared flow and // backend adapters. - txHash chainhash.Hash + TxHash chainhash.Hash - // received is normalized to UTC before any backend insert logic runs. - received time.Time + // Received is normalized to UTC before any backend insert logic runs. + Received time.Time - // isCoinbase caches the consensus coinbase check for backend insert params. - isCoinbase bool + // IsCoinbase caches the consensus coinbase check for backend insert params. + IsCoinbase bool } -// newCreateTxRequest performs the backend-independent CreateTx preparation +// NewCreateTxRequest performs the backend-independent CreateTx preparation // shared by both SQL stores before they open a write transaction. -func newCreateTxRequest(params CreateTxParams) (createTxRequest, error) { +func NewCreateTxRequest(params CreateTxParams) (CreateTxRequest, error) { rawTx, err := serializeMsgTx(params.Tx) if err != nil { - return createTxRequest{}, err + return CreateTxRequest{}, err } err = validateCreateTxParams(params) if err != nil { - return createTxRequest{}, err + return CreateTxRequest{}, err } - return createTxRequest{ - params: params, - rawTx: rawTx, - txHash: params.Tx.TxHash(), - received: params.Received.UTC(), - isCoinbase: blockchain.IsCoinBaseTx(params.Tx), + return CreateTxRequest{ + Params: params, + RawTx: rawTx, + TxHash: params.Tx.TxHash(), + Received: params.Received.UTC(), + IsCoinbase: blockchain.IsCoinBaseTx(params.Tx), }, nil } -// createTxExistingTarget is the normalized metadata the shared CreateTx flow +// CreateTxExistingTarget is the normalized metadata the shared CreateTx flow // needs when the wallet already stores the requested tx hash. -type createTxExistingTarget struct { - id int64 - status TxStatus - hasBlock bool - isCoinbase bool +type CreateTxExistingTarget struct { + // ID is the backend row ID. + ID int64 + + // Status is the wallet-relative transaction status. + Status TxStatus + + // HasBlock reports whether the row already has confirming block metadata. + HasBlock bool + + // IsCoinbase reports whether the row records coinbase history. + IsCoinbase bool } -var errCreateTxExistingNotFound = errors.New("create tx existing target not " + +// ErrCreateTxExistingNotFound reports that CreateTx found no existing row. +var ErrCreateTxExistingNotFound = errors.New("create tx existing target not " + "found") -// createTxOps is the small semantic adapter CreateTx needs from one SQL +// CreateTxOps is the small semantic adapter CreateTx needs from one SQL // backend. // // The shared CreateTx algorithm is intentionally linear: @@ -277,101 +285,101 @@ var errCreateTxExistingNotFound = errors.New("create tx existing target not " + // inserting a duplicate // - validate and cache any confirming block metadata before later writes use // it -// - insert the base transaction row exactly once when no existing row can be +// - Insert the base transaction row exactly once when no existing row can be // reused // - when the incoming tx is confirmed, discover any direct conflict roots // before later writes claim shared inputs or rewrite that branch // - if direct conflicts were found, rewrite those roots to replaced state, // fail their descendants, and record replacement history before the new // row claims their wallet-owned inputs -// - insert every wallet-owned credited output as a UTXO +// - Insert every wallet-owned credited output as a UTXO // - attach any wallet-owned spent inputs to that final transaction row // // Each backend implements those steps with its own sqlc-generated query types -// while createTxWithOps keeps the high-level sequencing in one place. +// while CreateTxWithOps keeps the high-level sequencing in one place. // That sequencing matters because confirmation, conflict invalidation, credit // creation, and spent-input claims must either all observe the same tx row or // all roll back together. -type createTxOps interface { - invalidateUnminedTxOps +type CreateTxOps interface { + InvalidateUnminedTxOps - // loadExisting loads any existing wallet-scoped transaction row for the + // LoadExisting loads any existing wallet-scoped transaction row for the // same hash. - loadExisting(ctx context.Context, req createTxRequest) ( - *createTxExistingTarget, error) + LoadExisting(ctx context.Context, req CreateTxRequest) ( + *CreateTxExistingTarget, error) - // confirmExisting reuses one existing row when CreateTx learns about the + // ConfirmExisting reuses one existing row when CreateTx learns about the // same transaction with confirming block context later. - confirmExisting(ctx context.Context, req createTxRequest, - existing createTxExistingTarget) error + ConfirmExisting(ctx context.Context, req CreateTxRequest, + existing CreateTxExistingTarget) error - // prepareBlock validates and caches any optional confirming block metadata - // the later insert step needs. - prepareBlock(ctx context.Context, req createTxRequest) error + // PrepareBlock validates and caches any optional confirming block metadata + // the later Insert step needs. + PrepareBlock(ctx context.Context, req CreateTxRequest) error - // listConflictTxns returns the direct wallet-owned conflict tx IDs plus the + // ListConflictTxns returns the direct wallet-owned conflict tx IDs plus the // corresponding hashes used for descendant discovery. - listConflictTxns(ctx context.Context, req createTxRequest) ([]int64, + ListConflictTxns(ctx context.Context, req CreateTxRequest) ([]int64, []chainhash.Hash, error) - // markTxnsReplaced batch-marks the provided direct conflict roots + // MarkTxnsReplaced batch-marks the provided direct conflict roots // as replaced. - markTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error + MarkTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error - // insertReplacementEdges records replacement-history edges from each direct + // InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. - insertReplacementEdges(ctx context.Context, walletID int64, + InsertReplacementEdges(ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error - // insert writes the base transaction row and returns its new primary key. - insert(ctx context.Context, req createTxRequest) (int64, error) + // Insert writes the base transaction row and returns its new primary key. + Insert(ctx context.Context, req CreateTxRequest) (int64, error) - // insertCredits records every wallet-owned output that the caller + // InsertCredits records every wallet-owned output that the caller // marked as a credit for this transaction. - insertCredits(ctx context.Context, req createTxRequest, txID int64) error + InsertCredits(ctx context.Context, req CreateTxRequest, txID int64) error - // markInputsSpent attaches wallet-owned parent outpoints to this + // MarkInputsSpent attaches wallet-owned parent outpoints to this // transaction row and rejects conflicts or invalid wallet parents. - markInputsSpent(ctx context.Context, req createTxRequest, txID int64) error + MarkInputsSpent(ctx context.Context, req CreateTxRequest, txID int64) error } // checkReuseCreateTx reports whether CreateTx should reuse an existing wallet- // scoped row instead of inserting a new one. -func checkReuseCreateTx(req createTxRequest, - existing createTxExistingTarget) bool { +func checkReuseCreateTx(req CreateTxRequest, + existing CreateTxExistingTarget) bool { // Only a newly confirmed observation can reuse an existing row. // Plain unmined inserts still create fresh unmined history // instead of rewriting one existing record in place. - if req.params.Block == nil { + if req.Params.Block == nil { return false } // Reuse is only for the mined published state that records // the wallet's final view of the tx once a block anchors it. - if req.params.Status != TxStatusPublished { + if req.Params.Status != TxStatusPublished { return false } // A row that already has a confirming block is already in its final mined // form, so CreateTx must reject the duplicate instead of mutating it again. - if existing.hasBlock { + if existing.HasBlock { return false } // Coinbase rows only reuse the orphaned state. That path restores the same // coinbase hash after rollback disconnected its previous confirming block. - if existing.isCoinbase { + if existing.IsCoinbase { // Both sides must still be coinbase history, and the existing // row must be the rollback-created orphan that is waiting for a // confirming block again. - return req.isCoinbase && existing.status == TxStatusOrphaned + return req.IsCoinbase && existing.Status == TxStatusOrphaned } // Non-coinbase rows only reuse the current unmined states. // Once a row is invalidated, UpdateTx/DeleteTx no longer // treat it as a live unmined target. - if !isUnminedStatus(existing.status) { + if !IsUnminedStatus(existing.Status) { return false } @@ -380,15 +388,15 @@ func checkReuseCreateTx(req createTxRequest, // loadCreateTxExisting resolves any wallet-scoped row already stored for the // requested tx hash and reports whether one was found. -func loadCreateTxExisting(ctx context.Context, req createTxRequest, - ops createTxOps) (*createTxExistingTarget, bool, error) { +func loadCreateTxExisting(ctx context.Context, req CreateTxRequest, + ops CreateTxOps) (*CreateTxExistingTarget, bool, error) { - existing, err := ops.loadExisting(ctx, req) - if err != nil && !errors.Is(err, errCreateTxExistingNotFound) { + existing, err := ops.LoadExisting(ctx, req) + if err != nil && !errors.Is(err, ErrCreateTxExistingNotFound) { return nil, false, fmt.Errorf("load create tx target: %w", err) } - if errors.Is(err, errCreateTxExistingNotFound) { + if errors.Is(err, ErrCreateTxExistingNotFound) { return nil, false, nil } @@ -405,14 +413,14 @@ func loadCreateTxExisting(ctx context.Context, req createTxRequest, // NOTE: rootHashes is expected to be a set with unique tx hashes. func collectConflictDescendants(ctx context.Context, walletID int64, rootHashes []chainhash.Hash, rootIDs []int64, - ops createTxOps) ([]int64, error) { + ops CreateTxOps) ([]int64, error) { - candidates, err := ops.listUnminedTxRecords(ctx, walletID) + candidates, err := ops.ListUnminedTxRecords(ctx, walletID) if err != nil { return nil, fmt.Errorf("list create tx conflict candidates: %w", err) } - descendantIDs := collectDescendantTxIDs(rootHashes, rootIDs, candidates) + descendantIDs := CollectDescendantTxIDs(rootHashes, rootIDs, candidates) return descendantIDs, nil } @@ -420,21 +428,21 @@ func collectConflictDescendants(ctx context.Context, walletID int64, // handleRootTxns clears the direct root spends, marks those rows replaced, and // records replacement edges to the winning confirmed tx. func handleRootTxns(ctx context.Context, walletID int64, rootIDs []int64, - replacementTxID int64, ops createTxOps) error { + replacementTxID int64, ops CreateTxOps) error { for _, rootID := range rootIDs { - err := ops.clearSpentUtxos(ctx, walletID, rootID) + err := ops.ClearSpentUtxos(ctx, walletID, rootID) if err != nil { return fmt.Errorf("clear replaced root spent utxos: %w", err) } } - err := ops.markTxnsReplaced(ctx, walletID, rootIDs) + err := ops.MarkTxnsReplaced(ctx, walletID, rootIDs) if err != nil { return fmt.Errorf("mark direct conflicts replaced: %w", err) } - err = ops.insertReplacementEdges(ctx, walletID, rootIDs, replacementTxID) + err = ops.InsertReplacementEdges(ctx, walletID, rootIDs, replacementTxID) if err != nil { return fmt.Errorf("record conflict replacement edges: %w", err) } @@ -445,20 +453,20 @@ func handleRootTxns(ctx context.Context, walletID int64, rootIDs []int64, // handleTxDescendants clears the discovered descendant spends and then marks // that dependent branch failed. func handleTxDescendants(ctx context.Context, walletID int64, - descendantIDs []int64, ops createTxOps) error { + descendantIDs []int64, ops CreateTxOps) error { if len(descendantIDs) == 0 { return nil } for _, descendantID := range descendantIDs { - err := ops.clearSpentUtxos(ctx, walletID, descendantID) + err := ops.ClearSpentUtxos(ctx, walletID, descendantID) if err != nil { return fmt.Errorf("clear failed descendant spent utxos: %w", err) } } - err := ops.markTxnsFailed(ctx, walletID, descendantIDs) + err := ops.MarkTxnsFailed(ctx, walletID, descendantIDs) if err != nil { return fmt.Errorf("mark conflict descendants failed: %w", err) } @@ -480,17 +488,17 @@ func handleTxDescendants(ctx context.Context, walletID int64, // That sequencing preserves replacement history for the direct conflicts while // still invalidating the dependent branch atomically inside one SQL // transaction. -func handleTxConflicts(ctx context.Context, req createTxRequest, - replacementTxID int64, ops createTxOps) error { +func handleTxConflicts(ctx context.Context, req CreateTxRequest, + replacementTxID int64, ops CreateTxOps) error { // Only confirmed inserts can replace an active unmined branch. - if req.params.Block == nil { + if req.Params.Block == nil { return nil } // Load the direct roots first so every later step works from the currently // visible spend edges on the shared parent inputs. - rootIDs, rootHashes, err := ops.listConflictTxns(ctx, req) + rootIDs, rootHashes, err := ops.ListConflictTxns(ctx, req) if err != nil { return fmt.Errorf("list conflict txns: %w", err) } @@ -500,7 +508,7 @@ func handleTxConflicts(ctx context.Context, req createTxRequest, return nil } - walletID := int64(req.params.WalletID) + walletID := int64(req.Params.WalletID) // Discover descendants before any mutation starts. // Later rewrites can otherwise hide part of the displaced @@ -527,28 +535,28 @@ func handleTxConflicts(ctx context.Context, req createTxRequest, return nil } -// insertCreateTx completes the fresh-insert CreateTx path. +// insertCreateTx completes the fresh-Insert CreateTx path. // // The order is important: -// - insert first so the new winner row has a stable tx ID +// - Insert first so the new winner row has a stable tx ID // - reconcile conflicts next while the displaced unmined branch is still // discoverable through the current spend edges // - create wallet-owned credits after replacement handling // - claim wallet-owned parent inputs last, because that rewires the shared // spend edges to the new winner row -func insertCreateTx(ctx context.Context, req createTxRequest, - ops createTxOps) error { +func insertCreateTx(ctx context.Context, req CreateTxRequest, + ops CreateTxOps) error { // Insert the winner row first so every later write can point at its stable // primary key. In particular, replacement-history edges need the new tx ID. - txID, err := ops.insert(ctx, req) + txID, err := ops.Insert(ctx, req) if err != nil { return fmt.Errorf("insert tx: %w", err) } // Reconcile any conflicting unmined branch before this tx claims the shared // parent inputs. Conflict discovery looks at the current spend edges on - // those parents; once markInputsSpent rewires them to this new row, the old + // those parents; once MarkInputsSpent rewires them to this new row, the old // branch is no longer discoverable as the direct conflict root. err = handleTxConflicts(ctx, req, txID, ops) if err != nil { @@ -559,7 +567,7 @@ func insertCreateTx(ctx context.Context, req createTxRequest, // not interfere with conflict discovery. Keep them after replacement // handling so the branch rewrite stays grouped with the shared-input // reconciliation. - err = ops.insertCredits(ctx, req, txID) + err = ops.InsertCredits(ctx, req, txID) if err != nil { return fmt.Errorf("create tx credits: %w", err) } @@ -567,7 +575,7 @@ func insertCreateTx(ctx context.Context, req createTxRequest, // Claim wallet-owned parent inputs last. This is the write that makes the // new tx the recorded spender of the shared parents, so doing it earlier // would hide the displaced unmined branch from the replacement walk. - err = ops.markInputsSpent(ctx, req, txID) + err = ops.MarkInputsSpent(ctx, req, txID) if err != nil { return fmt.Errorf("create tx spends: %w", err) } @@ -575,15 +583,15 @@ func insertCreateTx(ctx context.Context, req createTxRequest, return nil } -// createTxWithOps runs the backend-independent CreateTx orchestration once the +// CreateTxWithOps runs the backend-independent CreateTx orchestration once the // caller has opened a backend-specific SQL transaction. // -// The helper can either confirm an existing unmined row or insert a new row. +// The helper can either confirm an existing unmined row or Insert a new row. // For confirmed inserts it also reconciles any current direct conflict branch // before the new row claims wallet-owned inputs. The helper owns that ordering // so the backends only need to supply query wiring and type conversion. -func createTxWithOps(ctx context.Context, req createTxRequest, - ops createTxOps) error { +func CreateTxWithOps(ctx context.Context, req CreateTxRequest, + ops CreateTxOps) error { existing, foundExisting, err := loadCreateTxExisting(ctx, req, ops) if err != nil { @@ -592,10 +600,10 @@ func createTxWithOps(ctx context.Context, req createTxRequest, if foundExisting { if !checkReuseCreateTx(req, *existing) { - return fmt.Errorf("tx %s: %w", req.txHash, ErrTxAlreadyExists) + return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxAlreadyExists) } - err = ops.confirmExisting(ctx, req, *existing) + err = ops.ConfirmExisting(ctx, req, *existing) if err != nil { return fmt.Errorf("confirm existing tx: %w", err) } @@ -603,7 +611,7 @@ func createTxWithOps(ctx context.Context, req createTxRequest, return nil } - err = ops.prepareBlock(ctx, req) + err = ops.PrepareBlock(ctx, req) if err != nil { return fmt.Errorf("prepare create block assignment: %w", err) } @@ -630,7 +638,7 @@ func validateUpdateTxParams(params UpdateTxParams, isCoinbase bool) error { // validateUpdateTxState checks the block/status combinations UpdateTx may store // on an existing row. func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { - _, err := parseTxStatus(int64(state.Status)) + _, err := ParseTxStatus(int64(state.Status)) if err != nil { return fmt.Errorf("%w: status %d is not supported: %w", ErrInvalidParam, state.Status, ErrInvalidStatus) @@ -665,7 +673,7 @@ func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { return nil } -// updateTxOps is the minimal backend adapter the shared UpdateTx workflow +// UpdateTxOps is the minimal backend adapter the shared UpdateTx workflow // needs. // // The shared UpdateTx algorithm is intentionally ordered: @@ -680,36 +688,36 @@ func validateUpdateTxState(state UpdateTxState, isCoinbase bool) error { // shared helper owns the mutation ordering and the invariants that keep // block/status patches from accidentally turning into graph-level // reconciliation. -type updateTxOps interface { - // loadIsCoinbase returns whether the existing row is coinbase history +type UpdateTxOps interface { + // LoadIsCoinbase returns whether the existing row is coinbase history // so the shared validation can enforce orphaning rules correctly. - loadIsCoinbase(ctx context.Context, walletID uint32, + LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) - // prepareState validates and caches any backend-specific block/status + // PrepareState validates and caches any backend-specific block/status // params needed for the later row update. - prepareState(ctx context.Context, state UpdateTxState) error + PrepareState(ctx context.Context, state UpdateTxState) error - // updateLabel applies one user-visible label patch. - updateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, + // UpdateLabel applies one user-visible label patch. + UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error - // updateState applies one block/status patch after prepareState succeeds. - updateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, + // UpdateState applies one block/status patch after PrepareState succeeds. + UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, state UpdateTxState) error } -// updateTxWithOps runs the shared UpdateTx patch workflow inside one backend- +// UpdateTxWithOps runs the shared UpdateTx patch workflow inside one backend- // specific SQL transaction. // // The helper validates the existing row first, prepares any requested state // patch next, and then applies the label patch and state patch in that order. // That keeps block validation and row mutation inside one transaction while // still allowing callers to update either field independently. -func updateTxWithOps(ctx context.Context, params UpdateTxParams, - ops updateTxOps) error { +func UpdateTxWithOps(ctx context.Context, params UpdateTxParams, + ops UpdateTxOps) error { - isCoinbase, err := ops.loadIsCoinbase(ctx, params.WalletID, params.Txid) + isCoinbase, err := ops.LoadIsCoinbase(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("load update tx target: %w", err) } @@ -720,21 +728,21 @@ func updateTxWithOps(ctx context.Context, params UpdateTxParams, } if params.State != nil { - err = ops.prepareState(ctx, *params.State) + err = ops.PrepareState(ctx, *params.State) if err != nil { return fmt.Errorf("prepare tx state update: %w", err) } } if params.Label != nil { - err = ops.updateLabel(ctx, params.WalletID, params.Txid, *params.Label) + err = ops.UpdateLabel(ctx, params.WalletID, params.Txid, *params.Label) if err != nil { return fmt.Errorf("update tx label: %w", err) } } if params.State != nil { - err = ops.updateState(ctx, params.WalletID, params.Txid, *params.State) + err = ops.UpdateState(ctx, params.WalletID, params.Txid, *params.State) if err != nil { return fmt.Errorf("update tx state: %w", err) } @@ -743,7 +751,7 @@ func updateTxWithOps(ctx context.Context, params UpdateTxParams, return nil } -// deleteTxOps is the minimal backend adapter the shared DeleteTx workflow +// DeleteTxOps is the minimal backend adapter the shared DeleteTx workflow // needs. // // The shared delete sequence is: @@ -756,61 +764,61 @@ func updateTxWithOps(ctx context.Context, params UpdateTxParams, // DeleteTx is the ordinary leaf-cleanup path, not the invalidation path. The // shared helper therefore proves the leaf invariant first and only then unwinds // the wallet-owned state that points at the target row. -type deleteTxOps interface { - // loadDeleteTarget returns the row ID of the unmined transaction +type DeleteTxOps interface { + // LoadDeleteTarget returns the row ID of the unmined transaction // DeleteTx is allowed to remove. - loadDeleteTarget(ctx context.Context, walletID uint32, + LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) - // ensureLeaf rejects DeleteTx when the target still has direct + // EnsureLeaf rejects DeleteTx when the target still has direct // unmined child spenders. - ensureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, + EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error - // clearSpentUtxos restores any wallet-owned parent outputs the + // ClearSpentUtxos restores any wallet-owned parent outputs the // transaction had marked spent. - clearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error + ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error - // deleteCreatedUtxos removes any wallet-owned outputs created by the + // DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. - deleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error + DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error - // deleteUnminedTransaction removes the target row after its dependent + // DeleteUnminedTransaction removes the target row after its dependent // wallet state has been cleaned up. - deleteUnminedTransaction(ctx context.Context, walletID uint32, + DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) } -// deleteTxWithOps runs the shared DeleteTx sequence inside a backend-specific +// DeleteTxWithOps runs the shared DeleteTx sequence inside a backend-specific // SQL transaction. // // The helper restores wallet-owned parent state before deleting created wallet // outputs and only removes the transaction row last, so a failed delete cannot // leave partial wallet bookkeeping behind. -func deleteTxWithOps(ctx context.Context, params DeleteTxParams, - ops deleteTxOps) error { +func DeleteTxWithOps(ctx context.Context, params DeleteTxParams, + ops DeleteTxOps) error { - txID, err := ops.loadDeleteTarget(ctx, params.WalletID, params.Txid) + txID, err := ops.LoadDeleteTarget(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("load delete tx target: %w", err) } - err = ops.ensureLeaf(ctx, params.WalletID, params.Txid, txID) + err = ops.EnsureLeaf(ctx, params.WalletID, params.Txid, txID) if err != nil { return fmt.Errorf("check delete tx leaf: %w", err) } - err = ops.clearSpentUtxos(ctx, params.WalletID, txID) + err = ops.ClearSpentUtxos(ctx, params.WalletID, txID) if err != nil { return fmt.Errorf("clear spent utxos: %w", err) } - err = ops.deleteCreatedUtxos(ctx, params.WalletID, txID) + err = ops.DeleteCreatedUtxos(ctx, params.WalletID, txID) if err != nil { return fmt.Errorf("delete created utxos: %w", err) } - rows, err := ops.deleteUnminedTransaction(ctx, params.WalletID, params.Txid) + rows, err := ops.DeleteUnminedTransaction(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("delete unmined tx: %w", err) } @@ -822,19 +830,24 @@ func deleteTxWithOps(ctx context.Context, params DeleteTxParams, return nil } -// unminedTxRecord is the decoded view of one unmined transaction row used by +// UnminedTxRecord is the decoded view of one unmined transaction row used by // shared descendant checks. -type unminedTxRecord struct { - id int64 - hash chainhash.Hash - tx *wire.MsgTx +type UnminedTxRecord struct { + // ID is the backend row ID. + ID int64 + + // Hash is the transaction hash used for graph traversal. + Hash chainhash.Hash + + // Tx is the decoded transaction payload. + Tx *wire.MsgTx } -// extractUnminedTxFn projects one backend-specific unmined transaction row into +// ExtractUnminedTxFn projects one backend-specific unmined transaction row into // the shared `(id, tx_hash, raw_tx)` shape used by the invalidation walk. -type extractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) +type ExtractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) -// rollbackToBlockOps adapts one SQL backend to the full RollbackToBlock +// RollbackToBlockOps adapts one SQL backend to the full RollbackToBlock // sequence, including sync-state rewinds, block deletion, and descendant // invalidation. // @@ -848,65 +861,65 @@ type extractUnminedTxFn[Row any] func(Row) (int64, []byte, []byte) // // The adapter methods map directly to those stages so the shared helper can own // the rollback sequencing while the backends keep only query details. -type rollbackToBlockOps interface { - // listRollbackRootHashes returns the coinbase roots disconnected by the +type RollbackToBlockOps interface { + // ListRollbackRootHashes returns the coinbase roots disconnected by the // rollback, grouped by wallet for the later descendant walk. - listRollbackRootHashes(ctx context.Context, + ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) - // rewindWalletSyncStateHeights clamps wallet sync-state references + // RewindWalletSyncStateHeights clamps wallet sync-state references // below the rollback boundary before block rows are removed. - rewindWalletSyncStateHeights(ctx context.Context, height uint32) error + RewindWalletSyncStateHeights(ctx context.Context, height uint32) error - // deleteBlocksAtOrAboveHeight removes the shared block rows at or above the + // DeleteBlocksAtOrAboveHeight removes the shared block rows at or above the // rollback boundary after sync-state references have been rewound. - deleteBlocksAtOrAboveHeight(ctx context.Context, height uint32) error + DeleteBlocksAtOrAboveHeight(ctx context.Context, height uint32) error - // markTxRootsOrphaned rewrites the disconnected coinbase roots to the + // MarkTxRootsOrphaned rewrites the disconnected coinbase roots to the // orphaned state after their confirming blocks are deleted. - markTxRootsOrphaned(ctx context.Context, walletID uint32, + MarkTxRootsOrphaned(ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error - // listUnminedTxRecords loads the wallet's current unmined transaction + // ListUnminedTxRecords loads the wallet's current unmined transaction // rows in the normalized shape the descendant walk expects. - listUnminedTxRecords(ctx context.Context, - walletID int64) ([]unminedTxRecord, error) + ListUnminedTxRecords(ctx context.Context, + walletID int64) ([]UnminedTxRecord, error) - // clearDescendantSpends removes any wallet-owned spend edges claimed by one + // ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant before its status is rewritten. - clearDescendantSpends(ctx context.Context, walletID int64, + ClearDescendantSpends(ctx context.Context, walletID int64, descendantID int64) error - // markDescendantsFailed batch-marks the discovered descendants as + // MarkDescendantsFailed batch-marks the discovered descendants as // failed once every dependent spend edge has been cleared. - markDescendantsFailed(ctx context.Context, walletID int64, + MarkDescendantsFailed(ctx context.Context, walletID int64, descendantIDs []int64) error } // newUnminedTxRecord decodes one normalized unmined transaction row into the // shared dependency-walk shape. func newUnminedTxRecord(id int64, hash []byte, - rawTx []byte) (unminedTxRecord, error) { + rawTx []byte) (UnminedTxRecord, error) { txHash, err := chainhash.NewHash(hash) if err != nil { - return unminedTxRecord{}, fmt.Errorf("tx hash: %w", err) + return UnminedTxRecord{}, fmt.Errorf("tx hash: %w", err) } tx, err := deserializeMsgTx(rawTx) if err != nil { - return unminedTxRecord{}, err + return UnminedTxRecord{}, err } - return unminedTxRecord{id: id, hash: *txHash, tx: tx}, nil + return UnminedTxRecord{ID: id, Hash: *txHash, Tx: tx}, nil } -// buildUnminedTxRecords decodes backend-specific unmined transaction rows into +// BuildUnminedTxRecords decodes backend-specific unmined transaction rows into // the shared dependency-walk shape. -func buildUnminedTxRecords[T any](rows []T, - extract extractUnminedTxFn[T]) ([]unminedTxRecord, error) { +func BuildUnminedTxRecords[T any](rows []T, + extract ExtractUnminedTxFn[T]) ([]UnminedTxRecord, error) { - records := make([]unminedTxRecord, 0, len(rows)) + records := make([]UnminedTxRecord, 0, len(rows)) for _, row := range rows { id, hash, rawTx := extract(row) @@ -921,10 +934,10 @@ func buildUnminedTxRecords[T any](rows []T, return records, nil } -// collectDirectChildTxIDs returns the IDs of unmined transactions that directly +// CollectDirectChildTxIDs returns the IDs of unmined transactions that directly // spend any output created by the provided parent hash. -func collectDirectChildTxIDs(parentHash chainhash.Hash, - candidates []unminedTxRecord) []int64 { +func CollectDirectChildTxIDs(parentHash chainhash.Hash, + candidates []UnminedTxRecord) []int64 { parentHashes := map[chainhash.Hash]struct{}{ parentHash: {}, @@ -932,19 +945,19 @@ func collectDirectChildTxIDs(parentHash chainhash.Hash, childIDs := make([]int64, 0, len(candidates)) for _, candidate := range candidates { - if txSpendsAnyParent(candidate.tx, parentHashes) { - childIDs = append(childIDs, candidate.id) + if txSpendsAnyParent(candidate.Tx, parentHashes) { + childIDs = append(childIDs, candidate.ID) } } return childIDs } -// collectDescendantTxIDs returns every discovered descendant in the original +// CollectDescendantTxIDs returns every discovered descendant in the original // candidate order. Any ID also listed in rootIDs is excluded so direct roots // can keep their own state transition instead of being treated as descendants. -func collectDescendantTxIDs(rootHashes []chainhash.Hash, - rootIDs []int64, candidates []unminedTxRecord) []int64 { +func CollectDescendantTxIDs(rootHashes []chainhash.Hash, + rootIDs []int64, candidates []UnminedTxRecord) []int64 { invalidHashes := make(map[chainhash.Hash]struct{}, len(rootHashes)) for _, hash := range rootHashes { @@ -960,16 +973,16 @@ func collectDescendantTxIDs(rootHashes []chainhash.Hash, changed = false for _, candidate := range candidates { - if _, ok := invalidIDs[candidate.id]; ok { + if _, ok := invalidIDs[candidate.ID]; ok { continue } - if !txSpendsAnyParent(candidate.tx, invalidHashes) { + if !txSpendsAnyParent(candidate.Tx, invalidHashes) { continue } - invalidIDs[candidate.id] = struct{}{} - invalidHashes[candidate.hash] = struct{}{} + invalidIDs[candidate.ID] = struct{}{} + invalidHashes[candidate.Hash] = struct{}{} changed = true } } @@ -982,8 +995,8 @@ func collectDescendantTxIDs(rootHashes []chainhash.Hash, orderedIDs := make([]int64, 0, len(invalidIDs)) for _, candidate := range candidates { - if _, ok := invalidIDs[candidate.id]; ok { - orderedIDs = append(orderedIDs, candidate.id) + if _, ok := invalidIDs[candidate.ID]; ok { + orderedIDs = append(orderedIDs, candidate.ID) } } @@ -994,31 +1007,31 @@ func collectDescendantTxIDs(rootHashes []chainhash.Hash, // unmined descendant discovered from the provided wallet-scoped rollback roots. func invalidateRollbackDescendants(ctx context.Context, rootHashesByWallet map[uint32][]chainhash.Hash, - ops rollbackToBlockOps) error { + ops RollbackToBlockOps) error { for walletID, rootHashes := range rootHashesByWallet { walletID64 := int64(walletID) - candidates, err := ops.listUnminedTxRecords(ctx, walletID64) + candidates, err := ops.ListUnminedTxRecords(ctx, walletID64) if err != nil { return fmt.Errorf("list unmined rollback descendants for "+ "wallet %d: %w", walletID, err) } - descendantIDs := collectDescendantTxIDs(rootHashes, nil, candidates) + descendantIDs := CollectDescendantTxIDs(rootHashes, nil, candidates) if len(descendantIDs) == 0 { continue } for _, descendantID := range descendantIDs { - err = ops.clearDescendantSpends(ctx, walletID64, descendantID) + err = ops.ClearDescendantSpends(ctx, walletID64, descendantID) if err != nil { return fmt.Errorf("clear rollback descendant spends for "+ "wallet %d: %w", walletID, err) } } - err = ops.markDescendantsFailed(ctx, walletID64, descendantIDs) + err = ops.MarkDescendantsFailed(ctx, walletID64, descendantIDs) if err != nil { return fmt.Errorf("mark rollback descendants failed for "+ "wallet %d: %w", walletID, err) @@ -1028,14 +1041,14 @@ func invalidateRollbackDescendants(ctx context.Context, return nil } -// markTxRootsOrphaned rewrites every disconnected coinbase root to the +// MarkTxRootsOrphaned rewrites every disconnected coinbase root to the // orphaned state before descendant invalidation completes. -func markTxRootsOrphaned(ctx context.Context, +func MarkTxRootsOrphaned(ctx context.Context, rootHashesByWallet map[uint32][]chainhash.Hash, - ops rollbackToBlockOps) error { + ops RollbackToBlockOps) error { for walletID, rootHashes := range rootHashesByWallet { - err := ops.markTxRootsOrphaned(ctx, walletID, rootHashes) + err := ops.MarkTxRootsOrphaned(ctx, walletID, rootHashes) if err != nil { return fmt.Errorf("mark rollback coinbase roots orphaned for "+ "wallet %d: %w", walletID, err) @@ -1045,31 +1058,31 @@ func markTxRootsOrphaned(ctx context.Context, return nil } -// rollbackToBlockWithOps runs the shared RollbackToBlock sequence inside one +// RollbackToBlockWithOps runs the shared RollbackToBlock sequence inside one // backend-specific SQL transaction. // // The helper rewinds sync-state heights before deleting blocks, then clears and // fails any now-invalid unmined descendants rooted in disconnected coinbase // history so rollback cannot leave dangling references behind. -func rollbackToBlockWithOps(ctx context.Context, height uint32, - ops rollbackToBlockOps) error { +func RollbackToBlockWithOps(ctx context.Context, height uint32, + ops RollbackToBlockOps) error { - rootHashesByWallet, err := ops.listRollbackRootHashes(ctx, height) + rootHashesByWallet, err := ops.ListRollbackRootHashes(ctx, height) if err != nil { return fmt.Errorf("list rollback coinbase roots: %w", err) } - err = ops.rewindWalletSyncStateHeights(ctx, height) + err = ops.RewindWalletSyncStateHeights(ctx, height) if err != nil { return fmt.Errorf("rewind wallet sync state heights: %w", err) } - err = ops.deleteBlocksAtOrAboveHeight(ctx, height) + err = ops.DeleteBlocksAtOrAboveHeight(ctx, height) if err != nil { return fmt.Errorf("delete blocks at or above height: %w", err) } - err = markTxRootsOrphaned(ctx, rootHashesByWallet, ops) + err = MarkTxRootsOrphaned(ctx, rootHashesByWallet, ops) if err != nil { return err } @@ -1096,9 +1109,9 @@ func txSpendsAnyParent(tx *wire.MsgTx, return false } -// isUnminedStatus reports whether a status still represents an unmined +// IsUnminedStatus reports whether a status still represents an unmined // transaction that DeleteTx may erase. -func isUnminedStatus(status TxStatus) bool { +func IsUnminedStatus(status TxStatus) bool { switch status { case TxStatusPending, TxStatusPublished: return true diff --git a/wallet/internal/db/tx_store_common_test.go b/wallet/internal/db/tx_store_common_test.go index b3ab861263..724f0ff636 100644 --- a/wallet/internal/db/tx_store_common_test.go +++ b/wallet/internal/db/tx_store_common_test.go @@ -73,7 +73,7 @@ func TestParseTxStatus(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := parseTxStatus(tc.status) + got, err := ParseTxStatus(tc.status) require.ErrorIs(t, err, tc.wantErr) require.Equal(t, tc.want, got) }) @@ -85,7 +85,7 @@ func TestParseTxStatus(t *testing.T) { func TestParseTxStatusNegativeValue(t *testing.T) { t.Parallel() - _, err := parseTxStatus(-1) + _, err := ParseTxStatus(-1) require.ErrorIs(t, err, ErrInvalidStatus) } @@ -110,7 +110,7 @@ func TestIsUnminedStatus(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() - require.Equal(t, test.want, isUnminedStatus(test.status)) + require.Equal(t, test.want, IsUnminedStatus(test.status)) }) } } @@ -134,7 +134,7 @@ func TestBuildTxInfo(t *testing.T) { } // Act: Convert the normalized row fields into TxInfo. - info, err := buildTxInfo( + info, err := BuildTxInfo( hash[:], rawTx, time.Unix(600, 0).In(time.FixedZone("X", 3600)), block, int64(TxStatusPublished), "note", ) @@ -149,7 +149,7 @@ func TestBuildTxInfo(t *testing.T) { require.Equal(t, block, info.Block) } -// TestBuildTxInfoInvalidHash verifies that buildTxInfo rejects malformed hash +// TestBuildTxInfoInvalidHash verifies that BuildTxInfo rejects malformed hash // bytes. func TestBuildTxInfoInvalidHash(t *testing.T) { t.Parallel() @@ -158,12 +158,12 @@ func TestBuildTxInfoInvalidHash(t *testing.T) { rawTx, err := serializeMsgTx(tx) require.NoError(t, err) - _, err = buildTxInfo([]byte{1, 2, 3}, rawTx, time.Now(), nil, + _, err = BuildTxInfo([]byte{1, 2, 3}, rawTx, time.Now(), nil, int64(TxStatusPending), "") require.Error(t, err) } -// TestBuildTxInfoInvalidStatus verifies that buildTxInfo rejects unknown status +// TestBuildTxInfoInvalidStatus verifies that BuildTxInfo rejects unknown status // codes. func TestBuildTxInfoInvalidStatus(t *testing.T) { t.Parallel() @@ -173,7 +173,7 @@ func TestBuildTxInfoInvalidStatus(t *testing.T) { rawTx, err := serializeMsgTx(tx) require.NoError(t, err) - _, err = buildTxInfo(hash[:], rawTx, time.Now(), nil, 9, "") + _, err = BuildTxInfo(hash[:], rawTx, time.Now(), nil, 9, "") require.ErrorIs(t, err, ErrInvalidStatus) } @@ -335,23 +335,23 @@ func TestNewCreateTxRequest(t *testing.T) { Label: "note", } - // Act: Normalize it through newCreateTxRequest. - req, err := newCreateTxRequest(params) + // Act: Normalize it through NewCreateTxRequest. + req, err := NewCreateTxRequest(params) require.NoError(t, err) wantRawTx, err := serializeMsgTx(params.Tx) require.NoError(t, err) // Assert: The prepared request caches the normalized transaction facts. - require.Equal(t, params, req.params) - require.Equal(t, params.Tx.TxHash(), req.txHash) - require.Equal(t, wantRawTx, req.rawTx) - require.Equal(t, time.UTC, req.received.Location()) - require.False(t, req.isCoinbase) + require.Equal(t, params, req.Params) + require.Equal(t, params.Tx.TxHash(), req.TxHash) + require.Equal(t, wantRawTx, req.RawTx) + require.Equal(t, time.UTC, req.Received.Location()) + require.False(t, req.IsCoinbase) } // TestCreateTxWithOpsInsert verifies that the shared CreateTx orchestration -// performs the full insert path in order for one fresh transaction row. +// performs the full Insert path in order for one fresh transaction row. func TestCreateTxWithOpsInsert(t *testing.T) { t.Parallel() @@ -359,7 +359,7 @@ func TestCreateTxWithOpsInsert(t *testing.T) { req := testCreateTxRequest(t) var ( - insertReq createTxRequest + insertReq CreateTxRequest creditsID int64 inputsID int64 ) @@ -369,43 +369,46 @@ func TestCreateTxWithOpsInsert(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return( - nil, errCreateTxExistingNotFound).Once() + ops.On("LoadExisting", mock.Anything, req).Return( + nil, ErrCreateTxExistingNotFound).Once() - ops.On("prepareBlock", mock.Anything, req).Return(nil).Once() + ops.On("PrepareBlock", mock.Anything, req).Return(nil).Once() - ops.On("insert", mock.Anything, req).Return(int64(11), nil).Run( + ops.On("Insert", mock.Anything, req).Return(int64(11), nil).Run( func(args mock.Arguments) { - reqArg, ok := args.Get(1).(createTxRequest) + reqArg, ok := args.Get(1).(CreateTxRequest) require.True(t, ok) + insertReq = reqArg }, ).Once() - ops.On("insertCredits", mock.Anything, req, int64(11)).Return(nil).Run( + ops.On("InsertCredits", mock.Anything, req, int64(11)).Return(nil).Run( func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + creditsID = txID }, ).Once() - ops.On("markInputsSpent", mock.Anything, req, int64(11)).Return(nil).Run( + ops.On("MarkInputsSpent", mock.Anything, req, int64(11)).Return(nil).Run( func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + inputsID = txID }, ).Once() - // Act: Run createTxWithOps. - err := createTxWithOps(context.Background(), req, ops) + // Act: Run CreateTxWithOps. + err := CreateTxWithOps(context.Background(), req, ops) require.NoError(t, err) // Assert: The shared flow uses the inserted tx ID consistently. require.Equal(t, int64(11), creditsID) require.Equal(t, int64(11), inputsID) - require.Equal(t, req.txHash, insertReq.txHash) + require.Equal(t, req.TxHash, insertReq.TxHash) } // TestCreateTxWithOpsDuplicate verifies that the shared CreateTx helper maps an @@ -419,10 +422,10 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return( - &createTxExistingTarget{id: 4}, nil).Once() + ops.On("LoadExisting", mock.Anything, req).Return( + &CreateTxExistingTarget{ID: 4}, nil).Once() - err := createTxWithOps(context.Background(), req, ops) + err := CreateTxWithOps(context.Background(), req, ops) require.ErrorIs(t, err, ErrTxAlreadyExists) } @@ -432,7 +435,7 @@ func TestCreateTxWithOpsDuplicate(t *testing.T) { func TestCreateTxWithOpsConfirmExisting(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 5, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -442,21 +445,21 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { }) require.NoError(t, err) - existing := createTxExistingTarget{ - id: 7, - status: TxStatusPending, - hasBlock: false, + existing := CreateTxExistingTarget{ + ID: 7, + Status: TxStatusPending, + HasBlock: false, } ops := &mockCreateTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return(&existing, nil).Once() + ops.On("LoadExisting", mock.Anything, req).Return(&existing, nil).Once() - ops.On("confirmExisting", mock.Anything, req, existing).Return(nil).Once() + ops.On("ConfirmExisting", mock.Anything, req, existing).Return(nil).Once() - err = createTxWithOps(context.Background(), req, ops) + err = CreateTxWithOps(context.Background(), req, ops) require.NoError(t, err) } @@ -466,7 +469,7 @@ func TestCreateTxWithOpsConfirmExisting(t *testing.T) { func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 5, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -483,34 +486,34 @@ func TestCreateTxWithOpsReplaceConflicts(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return( - nil, errCreateTxExistingNotFound).Once() + ops.On("LoadExisting", mock.Anything, req).Return( + nil, ErrCreateTxExistingNotFound).Once() - ops.On("prepareBlock", mock.Anything, req).Return(nil).Once() + ops.On("PrepareBlock", mock.Anything, req).Return(nil).Once() - ops.On("insert", mock.Anything, req).Return(int64(11), nil).Once() + ops.On("Insert", mock.Anything, req).Return(int64(11), nil).Once() - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( rootIDs, rootHashes, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(5)).Return( - []unminedTxRecord(nil), nil).Once() + ops.On("ListUnminedTxRecords", mock.Anything, int64(5)).Return( + []UnminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(5), int64(5)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(5), int64(5)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(5), []int64{5}).Return(nil). + ops.On("MarkTxnsReplaced", mock.Anything, int64(5), []int64{5}).Return(nil). Once() - ops.On("insertReplacementEdges", mock.Anything, int64(5), []int64{5}, + ops.On("InsertReplacementEdges", mock.Anything, int64(5), []int64{5}, int64(11)).Return(nil).Once() - ops.On("insertCredits", mock.Anything, req, int64(11)).Return(nil).Once() + ops.On("InsertCredits", mock.Anything, req, int64(11)).Return(nil).Once() - ops.On("markInputsSpent", mock.Anything, req, int64(11)).Return(nil).Once() + ops.On("MarkInputsSpent", mock.Anything, req, int64(11)).Return(nil).Once() - err = createTxWithOps(context.Background(), req, ops) + err = CreateTxWithOps(context.Background(), req, ops) require.NoError(t, err) } @@ -544,51 +547,51 @@ func testCoinbaseMsgTx() *wire.MsgTx { return tx } -// mockCreateTxOps is a mock implementation of createTxOps. +// mockCreateTxOps is a mock implementation of CreateTxOps. type mockCreateTxOps struct { mock.Mock } -var _ createTxOps = (*mockCreateTxOps)(nil) +var _ CreateTxOps = (*mockCreateTxOps)(nil) -// loadExisting implements createTxOps. -func (m *mockCreateTxOps) loadExisting(ctx context.Context, - req createTxRequest) (*createTxExistingTarget, error) { +// LoadExisting implements CreateTxOps. +func (m *mockCreateTxOps) LoadExisting(ctx context.Context, + req CreateTxRequest) (*CreateTxExistingTarget, error) { args := m.Called(ctx, req) if args.Get(0) == nil { return nil, args.Error(1) } - existing, ok := args.Get(0).(*createTxExistingTarget) + existing, ok := args.Get(0).(*CreateTxExistingTarget) if !ok { - return nil, mockTypeError("loadExisting result") + return nil, mockTypeError("LoadExisting result") } return existing, args.Error(1) } -// confirmExisting implements createTxOps. -func (m *mockCreateTxOps) confirmExisting(ctx context.Context, - req createTxRequest, existing createTxExistingTarget) error { +// ConfirmExisting implements CreateTxOps. +func (m *mockCreateTxOps) ConfirmExisting(ctx context.Context, + req CreateTxRequest, existing CreateTxExistingTarget) error { args := m.Called(ctx, req, existing) return args.Error(0) } -// prepareBlock implements createTxOps. -func (m *mockCreateTxOps) prepareBlock(ctx context.Context, - req createTxRequest) error { +// PrepareBlock implements CreateTxOps. +func (m *mockCreateTxOps) PrepareBlock(ctx context.Context, + req CreateTxRequest) error { args := m.Called(ctx, req) return args.Error(0) } -// listConflictTxns implements createTxOps. -func (m *mockCreateTxOps) listConflictTxns(ctx context.Context, - req createTxRequest) ([]int64, []chainhash.Hash, error) { +// ListConflictTxns implements CreateTxOps. +func (m *mockCreateTxOps) ListConflictTxns(ctx context.Context, + req CreateTxRequest) ([]int64, []chainhash.Hash, error) { args := m.Called(ctx, req) if args.Get(0) == nil { @@ -597,56 +600,56 @@ func (m *mockCreateTxOps) listConflictTxns(ctx context.Context, txIDs, ok := args.Get(0).([]int64) if !ok { - return nil, nil, mockTypeError("listConflictTxns ids") + return nil, nil, mockTypeError("ListConflictTxns ids") } hashes, ok := args.Get(1).([]chainhash.Hash) if !ok { - return nil, nil, mockTypeError("listConflictTxns hashes") + return nil, nil, mockTypeError("ListConflictTxns hashes") } return txIDs, hashes, args.Error(2) } -// loadInvalidateTarget implements invalidateUnminedTxOps. -func (m *mockCreateTxOps) loadInvalidateTarget(ctx context.Context, +// LoadInvalidateTarget implements InvalidateUnminedTxOps. +func (m *mockCreateTxOps) LoadInvalidateTarget(ctx context.Context, walletID uint32, - txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { - var zeroTarget invalidateUnminedTxTarget + var zeroTarget InvalidateUnminedTxTarget args := m.Called(ctx, walletID, txHash) if args.Get(0) == nil { return zeroTarget, args.Error(1) } - target, ok := args.Get(0).(invalidateUnminedTxTarget) + target, ok := args.Get(0).(InvalidateUnminedTxTarget) if !ok { - return zeroTarget, mockTypeError("loadInvalidateTarget result") + return zeroTarget, mockTypeError("LoadInvalidateTarget result") } return target, args.Error(1) } -// listUnminedTxRecords implements invalidateUnminedTxOps. -func (m *mockCreateTxOps) listUnminedTxRecords(ctx context.Context, - walletID int64) ([]unminedTxRecord, error) { +// ListUnminedTxRecords implements InvalidateUnminedTxOps. +func (m *mockCreateTxOps) ListUnminedTxRecords(ctx context.Context, + walletID int64) ([]UnminedTxRecord, error) { args := m.Called(ctx, walletID) if args.Get(0) == nil { return nil, args.Error(1) } - records, ok := args.Get(0).([]unminedTxRecord) + records, ok := args.Get(0).([]UnminedTxRecord) if !ok { - return nil, mockTypeError("listUnminedTxRecords result") + return nil, mockTypeError("ListUnminedTxRecords result") } return records, args.Error(1) } -// clearSpentUtxos implements invalidateUnminedTxOps. -func (m *mockCreateTxOps) clearSpentUtxos(ctx context.Context, walletID int64, +// ClearSpentUtxos implements InvalidateUnminedTxOps. +func (m *mockCreateTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { args := m.Called(ctx, walletID, txID) @@ -654,8 +657,8 @@ func (m *mockCreateTxOps) clearSpentUtxos(ctx context.Context, walletID int64, return args.Error(0) } -// markTxnsFailed implements invalidateUnminedTxOps. -func (m *mockCreateTxOps) markTxnsFailed(ctx context.Context, walletID int64, +// MarkTxnsFailed implements InvalidateUnminedTxOps. +func (m *mockCreateTxOps) MarkTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error { args := m.Called(ctx, walletID, txIDs) @@ -663,8 +666,8 @@ func (m *mockCreateTxOps) markTxnsFailed(ctx context.Context, walletID int64, return args.Error(0) } -// markTxnsReplaced implements createTxOps. -func (m *mockCreateTxOps) markTxnsReplaced(ctx context.Context, walletID int64, +// MarkTxnsReplaced implements CreateTxOps. +func (m *mockCreateTxOps) MarkTxnsReplaced(ctx context.Context, walletID int64, txIDs []int64) error { args := m.Called(ctx, walletID, txIDs) @@ -672,8 +675,8 @@ func (m *mockCreateTxOps) markTxnsReplaced(ctx context.Context, walletID int64, return args.Error(0) } -// insertReplacementEdges implements createTxOps. -func (m *mockCreateTxOps) insertReplacementEdges(ctx context.Context, +// InsertReplacementEdges implements CreateTxOps. +func (m *mockCreateTxOps) InsertReplacementEdges(ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { args := m.Called(ctx, walletID, replacedTxIDs, replacementTxID) @@ -681,61 +684,61 @@ func (m *mockCreateTxOps) insertReplacementEdges(ctx context.Context, return args.Error(0) } -// insert implements createTxOps. -func (m *mockCreateTxOps) insert(ctx context.Context, - req createTxRequest) (int64, error) { +// Insert implements CreateTxOps. +func (m *mockCreateTxOps) Insert(ctx context.Context, + req CreateTxRequest) (int64, error) { args := m.Called(ctx, req) txID, ok := args.Get(0).(int64) if !ok { - return 0, mockTypeError("insert result") + return 0, mockTypeError("Insert result") } return txID, args.Error(1) } -// insertCredits implements createTxOps. -func (m *mockCreateTxOps) insertCredits(ctx context.Context, - req createTxRequest, txID int64) error { +// InsertCredits implements CreateTxOps. +func (m *mockCreateTxOps) InsertCredits(ctx context.Context, + req CreateTxRequest, txID int64) error { args := m.Called(ctx, req, txID) return args.Error(0) } -// markInputsSpent implements createTxOps. -func (m *mockCreateTxOps) markInputsSpent(ctx context.Context, - req createTxRequest, txID int64) error { +// MarkInputsSpent implements CreateTxOps. +func (m *mockCreateTxOps) MarkInputsSpent(ctx context.Context, + req CreateTxRequest, txID int64) error { args := m.Called(ctx, req, txID) return args.Error(0) } -// mockUpdateTxOps is a mock implementation of updateTxOps. +// mockUpdateTxOps is a mock implementation of UpdateTxOps. type mockUpdateTxOps struct { mock.Mock } -var _ updateTxOps = (*mockUpdateTxOps)(nil) +var _ UpdateTxOps = (*mockUpdateTxOps)(nil) -// loadIsCoinbase implements updateTxOps. -func (m *mockUpdateTxOps) loadIsCoinbase(ctx context.Context, walletID uint32, +// LoadIsCoinbase implements UpdateTxOps. +func (m *mockUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { args := m.Called(ctx, walletID, txHash) isCoinbase, ok := args.Get(0).(bool) if !ok { - return false, mockTypeError("loadIsCoinbase result") + return false, mockTypeError("LoadIsCoinbase result") } return isCoinbase, args.Error(1) } -// prepareState implements updateTxOps. -func (m *mockUpdateTxOps) prepareState(ctx context.Context, +// PrepareState implements UpdateTxOps. +func (m *mockUpdateTxOps) PrepareState(ctx context.Context, state UpdateTxState) error { args := m.Called(ctx, state) @@ -743,8 +746,8 @@ func (m *mockUpdateTxOps) prepareState(ctx context.Context, return args.Error(0) } -// updateLabel implements updateTxOps. -func (m *mockUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, +// UpdateLabel implements UpdateTxOps. +func (m *mockUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { args := m.Called(ctx, walletID, txHash, label) @@ -752,8 +755,8 @@ func (m *mockUpdateTxOps) updateLabel(ctx context.Context, walletID uint32, return args.Error(0) } -// updateState implements updateTxOps. -func (m *mockUpdateTxOps) updateState(ctx context.Context, walletID uint32, +// UpdateState implements UpdateTxOps. +func (m *mockUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, state UpdateTxState) error { args := m.Called(ctx, walletID, txHash, state) @@ -763,10 +766,10 @@ func (m *mockUpdateTxOps) updateState(ctx context.Context, walletID uint32, // testCreateTxRequest builds one valid normalized CreateTx request for the // shared CreateTx orchestration tests. -func testCreateTxRequest(t *testing.T) createTxRequest { +func testCreateTxRequest(t *testing.T) CreateTxRequest { t.Helper() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 5, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -785,16 +788,16 @@ var errConflictMarkFailed = errors.New("mark failed") func TestCollectConflictDescendants(t *testing.T) { t.Parallel() - candidates := []unminedTxRecord{{ - id: 12, - hash: chainhash.Hash{2}, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + candidates := []UnminedTxRecord{{ + ID: 12, + Hash: chainhash.Hash{2}, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, }}}, }, { - id: 13, - hash: chainhash.Hash{3}, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ID: 13, + Hash: chainhash.Hash{3}, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 0}, }}}, }} @@ -806,7 +809,7 @@ func TestCollectConflictDescendants(t *testing.T) { rootIDs := []int64{11, 12} rootHashes := []chainhash.Hash{{1}, {2}} - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( candidates, nil).Once() descendantIDs, err := collectConflictDescendants( @@ -824,7 +827,7 @@ func TestHandleTxConflicts(t *testing.T) { rootHash := chainhash.Hash{1} childHash := chainhash.Hash{2} grandchildHash := chainhash.Hash{3} - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -833,16 +836,16 @@ func TestHandleTxConflicts(t *testing.T) { }) require.NoError(t, err) - candidates := []unminedTxRecord{{ - id: 2, - hash: childHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + candidates := []UnminedTxRecord{{ + ID: 2, + Hash: childHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootHash, Index: 0}, }}}, }, { - id: 3, - hash: grandchildHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ID: 3, + Hash: grandchildHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: childHash, Index: 0}, }}}, }} @@ -852,29 +855,29 @@ func TestHandleTxConflicts(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{rootHash}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( candidates, nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1}, int64(9)).Return(nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). Once() - ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{2, 3}). + ops.On("MarkTxnsFailed", mock.Anything, int64(7), []int64{2, 3}). Return(nil).Once() err = handleTxConflicts(t.Context(), req, 9, ops) @@ -890,7 +893,7 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { rootAHash := chainhash.Hash{1} rootBHash := chainhash.Hash{2} childHash := chainhash.Hash{3} - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -904,38 +907,38 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1, 2}, []chainhash.Hash{rootAHash, rootBHash}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord{{ - id: 2, - hash: rootBHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord{{ + ID: 2, + Hash: rootBHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootAHash, Index: 0}, }}}, }, { - id: 3, - hash: childHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ID: 3, + Hash: childHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootBHash, Index: 0}, }}}, }}, nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}). + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1, 2}). Return(nil).Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1, 2}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1, 2}, int64(9)).Return(nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(3)).Return(nil). Once() - ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{3}).Return(nil). + ops.On("MarkTxnsFailed", mock.Anything, int64(7), []int64{3}).Return(nil). Once() err = handleTxConflicts(t.Context(), req, 9, ops) @@ -947,7 +950,7 @@ func TestHandleTxConflictsKeepsDirectRootsReplaced(t *testing.T) { func TestHandleTxConflictsNoDescendants(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -961,18 +964,18 @@ func TestHandleTxConflictsNoDescendants(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord(nil), nil).Once() + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1}, int64(9)).Return(nil).Once() err = handleTxConflicts(t.Context(), req, 9, ops) @@ -984,7 +987,7 @@ func TestHandleTxConflictsNoDescendants(t *testing.T) { func TestHandleTxConflictsMarkFailedError(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -998,15 +1001,15 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord{{ - id: 2, - hash: chainhash.Hash{2}, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord{{ + ID: 2, + Hash: chainhash.Hash{2}, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{ Hash: chainhash.Hash{1}, Index: 0, @@ -1014,15 +1017,15 @@ func TestHandleTxConflictsMarkFailedError(t *testing.T) { }}}, }}, nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return(nil). Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return(nil). Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1}, int64(9)).Return(nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(2)).Return(nil). Once() - ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{2}).Return( + ops.On("MarkTxnsFailed", mock.Anything, int64(7), []int64{2}).Return( errConflictMarkFailed).Once() err = handleTxConflicts(t.Context(), req, 9, ops) @@ -1056,31 +1059,33 @@ func TestUpdateTxWithOpsLabelAndState(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + ops.On("LoadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). Return(false, nil).Once() - ops.On("prepareState", mock.Anything, UpdateTxState{ + ops.On("PrepareState", mock.Anything, UpdateTxState{ Status: TxStatusPublished, }).Return(nil).Once() - ops.On("updateLabel", mock.Anything, uint32(5), chainhash.Hash{1}, + ops.On("UpdateLabel", mock.Anything, uint32(5), chainhash.Hash{1}, label).Return(nil).Run(func(args mock.Arguments) { labelArg, ok := args.Get(3).(string) require.True(t, ok) + updatedLabel = labelArg }).Once() - ops.On("updateState", mock.Anything, uint32(5), chainhash.Hash{1}, + ops.On("UpdateState", mock.Anything, uint32(5), chainhash.Hash{1}, UpdateTxState{Status: TxStatusPublished}).Return(nil).Run( func(args mock.Arguments) { stateArg, ok := args.Get(3).(UpdateTxState) require.True(t, ok) + updatedState = stateArg }, ).Once() - // Act: Run updateTxWithOps against a stub backend adapter. - err := updateTxWithOps(context.Background(), params, ops) + // Act: Run UpdateTxWithOps against a stub backend adapter. + err := UpdateTxWithOps(context.Background(), params, ops) require.NoError(t, err) // Assert: The shared flow applies both patches. @@ -1102,10 +1107,10 @@ func TestUpdateTxWithOpsEmptyPatch(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). + ops.On("LoadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}). Return(false, nil).Once() - err := updateTxWithOps(context.Background(), params, ops) + err := UpdateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) } @@ -1169,10 +1174,10 @@ func TestUpdateTxWithOpsRejectsInvalidatingStates(t *testing.T) { }) ops.On( - "loadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}, + "LoadIsCoinbase", mock.Anything, uint32(5), chainhash.Hash{1}, ).Return(test.isCoinbase, nil).Once() - err := updateTxWithOps(context.Background(), params, ops) + err := UpdateTxWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) require.ErrorIs(t, err, ErrInvalidStatus) }) @@ -1185,7 +1190,7 @@ var errCreateTxTest = errors.New("create tx test") func TestCheckReuseCreateTx(t *testing.T) { t.Parallel() - coinbaseReq, err := newCreateTxRequest(CreateTxParams{ + coinbaseReq, err := NewCreateTxRequest(CreateTxParams{ WalletID: 9, Tx: testCoinbaseMsgTx(), Received: time.Unix(555, 0), @@ -1195,7 +1200,7 @@ func TestCheckReuseCreateTx(t *testing.T) { }) require.NoError(t, err) - confirmedReq, err := newCreateTxRequest(CreateTxParams{ + confirmedReq, err := NewCreateTxRequest(CreateTxParams{ WalletID: 9, Tx: testRegularMsgTx(), Received: time.Unix(556, 0), @@ -1207,55 +1212,55 @@ func TestCheckReuseCreateTx(t *testing.T) { tests := []struct { name string - req createTxRequest - existing createTxExistingTarget + req CreateTxRequest + existing CreateTxExistingTarget want bool }{ { name: "confirmed unmined row reused", req: confirmedReq, - existing: createTxExistingTarget{ - status: TxStatusPending, + existing: CreateTxExistingTarget{ + Status: TxStatusPending, }, want: true, }, { name: "missing block not reused", req: testCreateTxRequest(t), - existing: createTxExistingTarget{ - status: TxStatusPending, + existing: CreateTxExistingTarget{ + Status: TxStatusPending, }, }, { name: "existing confirmed row not reused", req: confirmedReq, - existing: createTxExistingTarget{ - status: TxStatusPublished, - hasBlock: true, + existing: CreateTxExistingTarget{ + Status: TxStatusPublished, + HasBlock: true, }, }, { name: "non coinbase orphan not reused", req: confirmedReq, - existing: createTxExistingTarget{ - status: TxStatusOrphaned, + existing: CreateTxExistingTarget{ + Status: TxStatusOrphaned, }, }, { name: "orphaned coinbase reused", req: coinbaseReq, - existing: createTxExistingTarget{ - status: TxStatusOrphaned, - isCoinbase: true, + existing: CreateTxExistingTarget{ + Status: TxStatusOrphaned, + IsCoinbase: true, }, want: true, }, { name: "coinbase row not reused for non coinbase tx", req: confirmedReq, - existing: createTxExistingTarget{ - status: TxStatusOrphaned, - isCoinbase: true, + existing: CreateTxExistingTarget{ + Status: TxStatusOrphaned, + IsCoinbase: true, }, }, } @@ -1280,22 +1285,22 @@ func TestLoadCreateTxExisting(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("loadExisting", mock.Anything, req).Return( - nil, errCreateTxExistingNotFound).Once() + ops.On("LoadExisting", mock.Anything, req).Return( + nil, ErrCreateTxExistingNotFound).Once() existing, found, err := loadCreateTxExisting(context.Background(), req, ops) require.NoError(t, err) require.False(t, found) require.Nil(t, existing) - ops.On("loadExisting", mock.Anything, req).Return(nil, nil).Once() + ops.On("LoadExisting", mock.Anything, req).Return(nil, nil).Once() existing, found, err = loadCreateTxExisting(context.Background(), req, ops) require.NoError(t, err) require.False(t, found) require.Nil(t, existing) - ops.On("loadExisting", mock.Anything, req).Return( + ops.On("LoadExisting", mock.Anything, req).Return( nil, errCreateTxTest, ).Once() @@ -1314,7 +1319,7 @@ func TestHandleRootTxnsClearError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("clearSpentUtxos", mock.Anything, int64(9), int64(1)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(9), int64(1)).Return( errCreateTxTest).Once() err := handleRootTxns(context.Background(), 9, []int64{1}, 11, ops) @@ -1327,7 +1332,7 @@ func TestHandleRootTxnsClearError(t *testing.T) { func TestHandleTxConflictsEdgeError(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -1341,18 +1346,18 @@ func TestHandleTxConflictsEdgeError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord(nil), nil).Once() + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( nil, ).Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( nil, ).Once() - ops.On("insertReplacementEdges", mock.Anything, int64(7), []int64{1}, + ops.On("InsertReplacementEdges", mock.Anything, int64(7), []int64{1}, int64(9)).Return(errCreateTxTest).Once() err = handleTxConflicts(context.Background(), req, 9, ops) @@ -1365,7 +1370,7 @@ func TestHandleTxConflictsEdgeError(t *testing.T) { func TestHandleTxConflictsListError(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -1379,10 +1384,10 @@ func TestHandleTxConflictsListError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( nil, errCreateTxTest).Once() err = handleTxConflicts(context.Background(), req, 9, ops) @@ -1395,7 +1400,7 @@ func TestHandleTxConflictsListError(t *testing.T) { func TestHandleTxConflictsMarkReplacedError(t *testing.T) { t.Parallel() - req, err := newCreateTxRequest(CreateTxParams{ + req, err := NewCreateTxRequest(CreateTxParams{ WalletID: 7, Tx: testRegularMsgTx(), Received: time.Unix(456, 0), @@ -1409,15 +1414,15 @@ func TestHandleTxConflictsMarkReplacedError(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("listConflictTxns", mock.Anything, req).Return( + ops.On("ListConflictTxns", mock.Anything, req).Return( []int64{1}, []chainhash.Hash{{1}}, nil, ).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( - []unminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( + []UnminedTxRecord(nil), nil).Once() + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( nil, ).Once() - ops.On("markTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( + ops.On("MarkTxnsReplaced", mock.Anything, int64(7), []int64{1}).Return( errCreateTxTest).Once() err = handleTxConflicts(context.Background(), req, 9, ops) diff --git a/wallet/internal/db/tx_store_invalidateunmined_common.go b/wallet/internal/db/tx_store_invalidateunmined_common.go index 6eeaf3ce53..ef103954ab 100644 --- a/wallet/internal/db/tx_store_invalidateunmined_common.go +++ b/wallet/internal/db/tx_store_invalidateunmined_common.go @@ -14,26 +14,26 @@ var ( ErrInvalidateTx = errors.New("invalidate tx") ) -// invalidateUnminedTxTarget is the normalized metadata the shared invalidation +// InvalidateUnminedTxTarget is the normalized metadata the shared invalidation // workflow needs for the root transaction. -type invalidateUnminedTxTarget struct { - // id is the backend row ID for the transaction being invalidated. - id int64 +type InvalidateUnminedTxTarget struct { + // ID is the backend row ID for the transaction being invalidated. + ID int64 - // txHash is the network transaction hash used for descendant discovery. - txHash chainhash.Hash + // TxHash is the network transaction hash used for descendant discovery. + TxHash chainhash.Hash - // status is the wallet-relative state that must still be unmined. - status TxStatus + // Status is the wallet-relative state that must still be unmined. + Status TxStatus - // hasBlock reports whether the row is already confirmed in a block. - hasBlock bool + // HasBlock reports whether the row is already confirmed in a block. + HasBlock bool - // isCoinbase reports whether the row is a coinbase transaction. - isCoinbase bool + // IsCoinbase reports whether the row is a coinbase transaction. + IsCoinbase bool } -// invalidateUnminedTxOps is the small backend adapter the shared +// InvalidateUnminedTxOps is the small backend adapter the shared // InvalidateUnminedTx workflow needs. // // The shared invalidation algorithm is intentionally ordered: @@ -49,47 +49,47 @@ type invalidateUnminedTxTarget struct { // invalid branch from retaining wallet-owned spend edges if any later step // fails. The backend adapters only supply query wiring and row-shape // conversions. -type invalidateUnminedTxOps interface { - // loadInvalidateTarget loads the wallet-scoped root tx metadata. - loadInvalidateTarget(ctx context.Context, walletID uint32, +type InvalidateUnminedTxOps interface { + // LoadInvalidateTarget loads the wallet-scoped root tx metadata. + LoadInvalidateTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) ( - invalidateUnminedTxTarget, error) + InvalidateUnminedTxTarget, error) - // listUnminedTxRecords loads the wallet's active unmined transaction rows + // ListUnminedTxRecords loads the wallet's active unmined transaction rows // in the normalized shape the descendant walk expects. - listUnminedTxRecords(ctx context.Context, walletID int64) ( - []unminedTxRecord, error) + ListUnminedTxRecords(ctx context.Context, walletID int64) ( + []UnminedTxRecord, error) - // clearSpentUtxos restores any wallet-owned parent outputs spent by the + // ClearSpentUtxos restores any wallet-owned parent outputs spent by the // given transaction row. - clearSpentUtxos(ctx context.Context, walletID int64, txID int64) error + ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error - // markTxnsFailed batch-marks the provided tx rows as failed. - markTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error + // MarkTxnsFailed batch-marks the provided tx rows as failed. + MarkTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error } // validateUnminedTxTarget checks that the requested root is a current // unmined non-coinbase transaction. -func validateUnminedTxTarget(target invalidateUnminedTxTarget) error { - if target.hasBlock { - return fmt.Errorf("tx %s is confirmed: %w", target.txHash, +func validateUnminedTxTarget(target InvalidateUnminedTxTarget) error { + if target.HasBlock { + return fmt.Errorf("tx %s is confirmed: %w", target.TxHash, ErrInvalidateTx) } - if target.isCoinbase { - return fmt.Errorf("tx %s is coinbase: %w", target.txHash, + if target.IsCoinbase { + return fmt.Errorf("tx %s is coinbase: %w", target.TxHash, ErrInvalidateTx) } - if !isUnminedStatus(target.status) { - return fmt.Errorf("tx %s has status %d: %w", target.txHash, - target.status, ErrInvalidateTx) + if !IsUnminedStatus(target.Status) { + return fmt.Errorf("tx %s has status %d: %w", target.TxHash, + target.Status, ErrInvalidateTx) } return nil } -// invalidateUnminedTxWithOps invalidates one wallet-owned unmined transaction +// InvalidateUnminedTxWithOps invalidates one wallet-owned unmined transaction // root together with any descendant branch that depends on it. // // The helper performs descendant discovery before any spend-edge or status @@ -97,10 +97,10 @@ func validateUnminedTxTarget(target invalidateUnminedTxTarget) error { // finally marks the combined branch failed. Keeping that ordering in one shared // helper ensures postgres and sqlite invalidate branches with identical wallet // semantics. -func invalidateUnminedTxWithOps(ctx context.Context, - params InvalidateUnminedTxParams, ops invalidateUnminedTxOps) error { +func InvalidateUnminedTxWithOps(ctx context.Context, + params InvalidateUnminedTxParams, ops InvalidateUnminedTxOps) error { - target, err := ops.loadInvalidateTarget(ctx, params.WalletID, params.Txid) + target, err := ops.LoadInvalidateTarget(ctx, params.WalletID, params.Txid) if err != nil { return fmt.Errorf("load invalidate tx target: %w", err) } @@ -110,32 +110,32 @@ func invalidateUnminedTxWithOps(ctx context.Context, return err } - candidates, err := ops.listUnminedTxRecords(ctx, int64(params.WalletID)) + candidates, err := ops.ListUnminedTxRecords(ctx, int64(params.WalletID)) if err != nil { return fmt.Errorf("list unmined invalidation txns: %w", err) } - descendantIDs := collectDescendantTxIDs( - []chainhash.Hash{target.txHash}, nil, candidates, + descendantIDs := CollectDescendantTxIDs( + []chainhash.Hash{target.TxHash}, nil, candidates, ) - err = ops.clearSpentUtxos(ctx, int64(params.WalletID), target.id) + err = ops.ClearSpentUtxos(ctx, int64(params.WalletID), target.ID) if err != nil { return fmt.Errorf("clear root spent utxos: %w", err) } for _, descendantID := range descendantIDs { - err = ops.clearSpentUtxos(ctx, int64(params.WalletID), descendantID) + err = ops.ClearSpentUtxos(ctx, int64(params.WalletID), descendantID) if err != nil { return fmt.Errorf("clear descendant spent utxos: %w", err) } } failedIDs := make([]int64, 0, len(descendantIDs)+1) - failedIDs = append(failedIDs, target.id) + failedIDs = append(failedIDs, target.ID) failedIDs = append(failedIDs, descendantIDs...) - err = ops.markTxnsFailed(ctx, int64(params.WalletID), failedIDs) + err = ops.MarkTxnsFailed(ctx, int64(params.WalletID), failedIDs) if err != nil { return fmt.Errorf("mark invalidated txns failed: %w", err) } diff --git a/wallet/internal/db/tx_store_invalidateunmined_common_test.go b/wallet/internal/db/tx_store_invalidateunmined_common_test.go index ab9dceb8a2..bcf7ba516a 100644 --- a/wallet/internal/db/tx_store_invalidateunmined_common_test.go +++ b/wallet/internal/db/tx_store_invalidateunmined_common_test.go @@ -15,49 +15,49 @@ var ( errInvalidateCommonTest = errors.New("invalidate common test") errInvalidateMockLoadInvalidateTargetType = errors.New( - "loadInvalidateTarget result is not invalidateUnminedTxTarget", + "LoadInvalidateTarget result is not InvalidateUnminedTxTarget", ) errInvalidateMockListUnminedType = errors.New( - "listUnminedTxRecords result is not []unminedTxRecord", + "ListUnminedTxRecords result is not []UnminedTxRecord", ) ) // mockInvalidateUnminedTxOps is a mock implementation of -// invalidateUnminedTxOps. +// InvalidateUnminedTxOps. type mockInvalidateUnminedTxOps struct { mock.Mock } -// loadInvalidateTarget implements invalidateUnminedTxOps. -func (m *mockInvalidateUnminedTxOps) loadInvalidateTarget(ctx context.Context, +// LoadInvalidateTarget implements InvalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, walletID uint32, - txHash chainhash.Hash) (invalidateUnminedTxTarget, error) { + txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { args := m.Called(ctx, walletID, txHash) if args.Get(0) == nil { - return invalidateUnminedTxTarget{}, args.Error(1) + return InvalidateUnminedTxTarget{}, args.Error(1) } - target, ok := args.Get(0).(invalidateUnminedTxTarget) + target, ok := args.Get(0).(InvalidateUnminedTxTarget) if !ok { - return invalidateUnminedTxTarget{}, + return InvalidateUnminedTxTarget{}, errInvalidateMockLoadInvalidateTargetType } return target, args.Error(1) } -// listUnminedTxRecords implements invalidateUnminedTxOps. -func (m *mockInvalidateUnminedTxOps) listUnminedTxRecords(ctx context.Context, - walletID int64) ([]unminedTxRecord, error) { +// ListUnminedTxRecords implements InvalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) ListUnminedTxRecords(ctx context.Context, + walletID int64) ([]UnminedTxRecord, error) { args := m.Called(ctx, walletID) if args.Get(0) == nil { return nil, args.Error(1) } - records, ok := args.Get(0).([]unminedTxRecord) + records, ok := args.Get(0).([]UnminedTxRecord) if !ok { return nil, errInvalidateMockListUnminedType } @@ -65,8 +65,8 @@ func (m *mockInvalidateUnminedTxOps) listUnminedTxRecords(ctx context.Context, return records, args.Error(1) } -// clearSpentUtxos implements invalidateUnminedTxOps. -func (m *mockInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, +// ClearSpentUtxos implements InvalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { args := m.Called(ctx, walletID, txID) @@ -74,8 +74,8 @@ func (m *mockInvalidateUnminedTxOps) clearSpentUtxos(ctx context.Context, return args.Error(0) } -// markTxnsFailed implements invalidateUnminedTxOps. -func (m *mockInvalidateUnminedTxOps) markTxnsFailed(ctx context.Context, +// MarkTxnsFailed implements InvalidateUnminedTxOps. +func (m *mockInvalidateUnminedTxOps) MarkTxnsFailed(ctx context.Context, walletID int64, txIDs []int64) error { args := m.Called(ctx, walletID, txIDs) @@ -90,56 +90,56 @@ func TestValidateInvalidateUnminedTxTarget(t *testing.T) { tests := []struct { name string - target invalidateUnminedTxTarget + target InvalidateUnminedTxTarget wantErr error }{ { name: "pending root", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{1}, - status: TxStatusPending, - hasBlock: false, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{1}, + Status: TxStatusPending, + HasBlock: false, }, }, { name: "published root", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{2}, - status: TxStatusPublished, - hasBlock: false, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{2}, + Status: TxStatusPublished, + HasBlock: false, }, }, { name: "confirmed root rejected", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{3}, - status: TxStatusPublished, - hasBlock: true, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{3}, + Status: TxStatusPublished, + HasBlock: true, }, wantErr: ErrInvalidateTx, }, { name: "failed root rejected", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{4}, - status: TxStatusFailed, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{4}, + Status: TxStatusFailed, }, wantErr: ErrInvalidateTx, }, { name: "coinbase root rejected", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{5}, - status: TxStatusPublished, - isCoinbase: true, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{5}, + Status: TxStatusPublished, + IsCoinbase: true, }, wantErr: ErrInvalidateTx, }, { name: "orphaned root rejected", - target: invalidateUnminedTxTarget{ - txHash: chainhash.Hash{6}, - status: TxStatusOrphaned, + target: InvalidateUnminedTxTarget{ + TxHash: chainhash.Hash{6}, + Status: TxStatusOrphaned, }, wantErr: ErrInvalidateTx, }, @@ -164,16 +164,16 @@ func TestInvalidateUnminedTxWithOps(t *testing.T) { childHash := chainhash.Hash{2} grandchildHash := chainhash.Hash{3} - candidates := []unminedTxRecord{{ - id: 2, - hash: childHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + candidates := []UnminedTxRecord{{ + ID: 2, + Hash: childHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: rootHash, Index: 0}, }}}, }, { - id: 3, - hash: grandchildHash, - tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ + ID: 3, + Hash: grandchildHash, + Tx: &wire.MsgTx{TxIn: []*wire.TxIn{{ PreviousOutPoint: wire.OutPoint{Hash: childHash, Index: 0}, }}}, }} @@ -186,45 +186,49 @@ func TestInvalidateUnminedTxWithOps(t *testing.T) { ops := &mockInvalidateUnminedTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadInvalidateTarget", mock.Anything, uint32(7), rootHash).Return( - invalidateUnminedTxTarget{ - id: 1, - txHash: rootHash, - status: TxStatusPublished, + ops.On("LoadInvalidateTarget", mock.Anything, uint32(7), rootHash).Return( + InvalidateUnminedTxTarget{ + ID: 1, + TxHash: rootHash, + Status: TxStatusPublished, }, nil).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(7)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(7)).Return( candidates, nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(1)).Return( nil).Run(func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + cleared = append(cleared, txID) }).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(2)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(2)).Return( nil).Run(func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + cleared = append(cleared, txID) }).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(7), int64(3)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(7), int64(3)).Return( nil).Run(func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + cleared = append(cleared, txID) }).Once() - ops.On("markTxnsFailed", mock.Anything, int64(7), []int64{1, 2, 3}).Return( + ops.On("MarkTxnsFailed", mock.Anything, int64(7), []int64{1, 2, 3}).Return( nil).Run(func(args mock.Arguments) { txIDs, ok := args.Get(2).([]int64) require.True(t, ok) + failedIDs = append([]int64(nil), txIDs...) }).Once() - err := invalidateUnminedTxWithOps( + err := InvalidateUnminedTxWithOps( t.Context(), InvalidateUnminedTxParams{WalletID: 7, Txid: rootHash}, ops, @@ -250,33 +254,35 @@ func TestInvalidateUnminedTxWithOpsNoDescendants(t *testing.T) { ops := &mockInvalidateUnminedTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadInvalidateTarget", + ops.On("LoadInvalidateTarget", mock.Anything, uint32(8), chainhash.Hash{9}, ).Return( - invalidateUnminedTxTarget{ - id: 4, - txHash: chainhash.Hash{9}, - status: TxStatusPending, + InvalidateUnminedTxTarget{ + ID: 4, + TxHash: chainhash.Hash{9}, + Status: TxStatusPending, }, nil).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(8)).Return( - []unminedTxRecord(nil), nil).Once() + ops.On("ListUnminedTxRecords", mock.Anything, int64(8)).Return( + []UnminedTxRecord(nil), nil).Once() - ops.On("clearSpentUtxos", mock.Anything, int64(8), int64(4)).Return( + ops.On("ClearSpentUtxos", mock.Anything, int64(8), int64(4)).Return( nil).Run(func(args mock.Arguments) { txID, ok := args.Get(2).(int64) require.True(t, ok) + cleared = append(cleared, txID) }).Once() - ops.On("markTxnsFailed", mock.Anything, int64(8), []int64{4}).Return( + ops.On("MarkTxnsFailed", mock.Anything, int64(8), []int64{4}).Return( nil).Run(func(args mock.Arguments) { txIDs, ok := args.Get(2).([]int64) require.True(t, ok) + failedIDs = append([]int64(nil), txIDs...) }).Once() - err := invalidateUnminedTxWithOps( + err := InvalidateUnminedTxWithOps( t.Context(), InvalidateUnminedTxParams{WalletID: 8, Txid: chainhash.Hash{9}}, ops, @@ -297,11 +303,11 @@ func TestInvalidateUnminedTxWithOpsErrors(t *testing.T) { ops := &mockInvalidateUnminedTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadInvalidateTarget", + ops.On("LoadInvalidateTarget", mock.Anything, uint32(8), chainhash.Hash{1}).Return( nil, errInvalidateCommonTest).Once() - err := invalidateUnminedTxWithOps( + err := InvalidateUnminedTxWithOps( t.Context(), InvalidateUnminedTxParams{ WalletID: 8, @@ -319,18 +325,18 @@ func TestInvalidateUnminedTxWithOpsErrors(t *testing.T) { ops := &mockInvalidateUnminedTxOps{} t.Cleanup(func() { ops.AssertExpectations(t) }) - ops.On("loadInvalidateTarget", + ops.On("LoadInvalidateTarget", mock.Anything, uint32(8), chainhash.Hash{2}).Return( - invalidateUnminedTxTarget{ - id: 5, - txHash: chainhash.Hash{2}, - status: TxStatusPublished, + InvalidateUnminedTxTarget{ + ID: 5, + TxHash: chainhash.Hash{2}, + Status: TxStatusPublished, }, nil).Once() - ops.On("listUnminedTxRecords", mock.Anything, int64(8)).Return( + ops.On("ListUnminedTxRecords", mock.Anything, int64(8)).Return( nil, errInvalidateCommonTest).Once() - err := invalidateUnminedTxWithOps( + err := InvalidateUnminedTxWithOps( t.Context(), InvalidateUnminedTxParams{ WalletID: 8, diff --git a/wallet/internal/db/utxo_store_common.go b/wallet/internal/db/utxo_store_common.go index 6d7107ad39..b61299fbde 100644 --- a/wallet/internal/db/utxo_store_common.go +++ b/wallet/internal/db/utxo_store_common.go @@ -21,25 +21,25 @@ var ( // another active lock on the same output. ErrOutputAlreadyLeased = errors.New("output already leased") - // ErrOutputUnlockNotAllowed reports that a UTXO release request used a lock + // ErrOutputUnlockNotAllowed reports that a UTXO Release request used a lock // ID different from the active lease. ErrOutputUnlockNotAllowed = errors.New("output unlock not allowed") - // errLeaseOutputNoRow indicates that the backend lease write found no + // ErrLeaseOutputNoRow indicates that the backend lease write found no // leasable current UTXO row for the requested outpoint. - errLeaseOutputNoRow = errors.New("lease output no row") + ErrLeaseOutputNoRow = errors.New("lease output no row") - // errReleaseOutputUtxoNotFound indicates that ReleaseOutput could not + // ErrReleaseOutputUtxoNotFound indicates that ReleaseOutput could not // resolve the requested outpoint to a current wallet-owned UTXO row. - errReleaseOutputUtxoNotFound = errors.New( - "release output utxo not found", + ErrReleaseOutputUtxoNotFound = errors.New( + "Release output utxo not found", ) - // errReleaseOutputNoActiveLease indicates that the target UTXO no longer + // ErrReleaseOutputNoActiveLease indicates that the target UTXO no longer // has an active lease row by the time ReleaseOutput checks the fallback // path. - errReleaseOutputNoActiveLease = errors.New( - "release output no active lease", + ErrReleaseOutputNoActiveLease = errors.New( + "Release output no active lease", ) ) @@ -54,9 +54,9 @@ func buildOutPoint(hash []byte, outputIndex uint32) (wire.OutPoint, error) { return wire.OutPoint{Hash: *txHash, Index: outputIndex}, nil } -// buildUtxoInfo converts normalized SQL result fields into the public UtxoInfo +// BuildUtxoInfo converts normalized SQL result fields into the public UtxoInfo // shape returned by the db interfaces. -func buildUtxoInfo(hash []byte, outputIndex uint32, amount int64, +func BuildUtxoInfo(hash []byte, outputIndex uint32, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight *uint32) (*UtxoInfo, error) { @@ -80,9 +80,9 @@ func buildUtxoInfo(hash []byte, outputIndex uint32, amount int64, }, nil } -// buildLeasedOutput converts SQL lease-row fields into the public LeasedOutput +// BuildLeasedOutput converts SQL lease-row fields into the public LeasedOutput // type. -func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, +func BuildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, expiration time.Time) (*LeasedOutput, error) { outPoint, err := buildOutPoint(hash, outputIndex) @@ -104,7 +104,7 @@ func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, }, nil } -// leaseOutputOps is the backend adapter the shared LeaseOutput workflow uses. +// LeaseOutputOps is the backend adapter the shared LeaseOutput workflow uses. // // The shared lease algorithm is intentionally ordered: // - validate the public lease request up front @@ -115,25 +115,25 @@ func buildLeasedOutput(hash []byte, outputIndex uint32, lockID []byte, // // The adapter methods map directly to those stages so the shared helper keeps // the policy and sequencing while each backend keeps only query details. -type leaseOutputOps interface { - // acquire attempts to write or renew the lease and returns the stored +type LeaseOutputOps interface { + // Acquire attempts to write or renew the lease and returns the stored // expiration timestamp when the write succeeds. - acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, + Acquire(ctx context.Context, params LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) - // hasUtxo reports whether the requested outpoint still exists as a current + // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. - hasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) + HasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) } -// leaseOutputWithOps runs the backend-independent LeaseOutput workflow once the +// LeaseOutputWithOps runs the backend-independent LeaseOutput workflow once the // caller has opened a backend-specific SQL transaction. // // The helper owns the lease sequencing so every backend answers the same two // questions in the same order: did the lease write succeed, and if not, was the // target output missing or merely already leased by a different lock? -func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, - ops leaseOutputOps) (*LeasedOutput, error) { +func LeaseOutputWithOps(ctx context.Context, params LeaseOutputParams, + ops LeaseOutputOps) (*LeasedOutput, error) { if params.Duration <= 0 { return nil, fmt.Errorf("%w: lease duration must be positive", @@ -143,7 +143,7 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, nowUTC := time.Now().UTC() expiresAt := nowUTC.Add(params.Duration) - expiration, err := ops.acquire(ctx, params, nowUTC, expiresAt) + expiration, err := ops.Acquire(ctx, params, nowUTC, expiresAt) if err == nil { return &LeasedOutput{ OutPoint: params.OutPoint, @@ -152,14 +152,14 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, }, nil } - if !errors.Is(err, errLeaseOutputNoRow) { + if !errors.Is(err, ErrLeaseOutputNoRow) { return nil, fmt.Errorf("acquire utxo lease: %w", err) } - // A no-row acquire means the write path found no leasable row. + // A no-row Acquire means the write path found no leasable row. // Distinguish a missing UTXO from an already-active lease before // returning a public error. - exists, err := ops.hasUtxo(ctx, params) + exists, err := ops.HasUtxo(ctx, params) if err != nil { return nil, fmt.Errorf("lookup utxo before lease conflict: %w", err) } @@ -173,10 +173,10 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, ErrOutputAlreadyLeased) } -// releaseOutputOps is the backend adapter the shared ReleaseOutput workflow +// ReleaseOutputOps is the backend adapter the shared ReleaseOutput workflow // uses. // -// The shared release algorithm is intentionally ordered: +// The shared Release algorithm is intentionally ordered: // - resolve the wallet-owned outpoint to a stable UTXO row first // - attempt the lease delete by lock ID second // - if no row is deleted, check the active lease state for that UTXO @@ -184,39 +184,39 @@ func leaseOutputWithOps(ctx context.Context, params LeaseOutputParams, // - map a different active lock to ErrOutputUnlockNotAllowed // // The adapter methods map directly to those stages so the shared helper keeps -// the release policy and sequencing while each backend keeps only query +// the Release policy and sequencing while each backend keeps only query // details. -type releaseOutputOps interface { - // lookupUtxoID resolves the current wallet-owned outpoint to its stable +type ReleaseOutputOps interface { + // LookupUtxoID resolves the current wallet-owned outpoint to its stable // UTXO row ID. - lookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) + LookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) - // release attempts to delete the lease row for the provided UTXO ID and + // Release attempts to delete the lease row for the provided UTXO ID and // lock ID, returning the number of deleted rows. - release(ctx context.Context, walletID uint32, utxoID int64, + Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) - // activeLockID returns the currently active lock ID for the provided UTXO + // ActiveLockID returns the currently active lock ID for the provided UTXO // ID. - activeLockID(ctx context.Context, walletID uint32, utxoID int64, + ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) } -// releaseOutputWithOps runs the backend-independent ReleaseOutput workflow once +// ReleaseOutputWithOps runs the backend-independent ReleaseOutput workflow once // the caller has opened a backend-specific SQL transaction. // // The helper resolves the stable UTXO row first, attempts the lock-specific // delete second, and only falls back to the active-lock lookup when no row was // deleted. That keeps a released-or-expired lease as a no-op while still // surfacing conflicting active locks consistently across backends. -func releaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, - ops releaseOutputOps) error { +func ReleaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, + ops ReleaseOutputOps) error { nowUTC := time.Now().UTC() - utxoID, err := ops.lookupUtxoID(ctx, params) + utxoID, err := ops.LookupUtxoID(ctx, params) if err != nil { - if errors.Is(err, errReleaseOutputUtxoNotFound) { + if errors.Is(err, ErrReleaseOutputUtxoNotFound) { return fmt.Errorf("utxo %s: %w", params.OutPoint, ErrUtxoNotFound) } @@ -224,7 +224,7 @@ func releaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, return fmt.Errorf("lookup utxo for release: %w", err) } - rows, err := ops.release(ctx, params.WalletID, utxoID, params.ID) + rows, err := ops.Release(ctx, params.WalletID, utxoID, params.ID) if err != nil { return fmt.Errorf("release utxo lease: %w", err) } @@ -235,11 +235,11 @@ func releaseOutputWithOps(ctx context.Context, params ReleaseOutputParams, // No row was deleted, so either the lease already expired or was released, // or a different active lock still owns this UTXO. - activeLockID, err := ops.activeLockID( + activeLockID, err := ops.ActiveLockID( ctx, params.WalletID, utxoID, nowUTC, ) if err != nil { - if errors.Is(err, errReleaseOutputNoActiveLease) { + if errors.Is(err, ErrReleaseOutputNoActiveLease) { return nil } diff --git a/wallet/internal/db/utxo_store_common_test.go b/wallet/internal/db/utxo_store_common_test.go index 88f7129506..b67ddb5ddf 100644 --- a/wallet/internal/db/utxo_store_common_test.go +++ b/wallet/internal/db/utxo_store_common_test.go @@ -2,7 +2,6 @@ package db import ( "context" - "database/sql" "testing" "time" @@ -65,7 +64,7 @@ func TestBuildOutPoint_InvalidHash(t *testing.T) { require.Error(t, err) } -// TestBuildUtxoInfo_Confirmed verifies that buildUtxoInfo preserves confirmed +// TestBuildUtxoInfo_Confirmed verifies that BuildUtxoInfo preserves confirmed // UTXO metadata. // // Scenario: @@ -85,7 +84,7 @@ func TestBuildUtxoInfo_Confirmed(t *testing.T) { confirmedHeight := uint32(33) // Act: Build the public UTXO view for the confirmed row. - confirmed, err := buildUtxoInfo( + confirmed, err := BuildUtxoInfo( hash[:], 1, 1234, []byte{0x51}, time.Unix(111, 0), true, &confirmedHeight, ) @@ -97,7 +96,7 @@ func TestBuildUtxoInfo_Confirmed(t *testing.T) { require.Equal(t, uint32(1), confirmed.OutPoint.Index) } -// TestBuildUtxoInfo_Unconfirmed verifies that buildUtxoInfo maps unconfirmed +// TestBuildUtxoInfo_Unconfirmed verifies that BuildUtxoInfo maps unconfirmed // rows to the public unmined sentinel. // // Scenario: @@ -117,7 +116,7 @@ func TestBuildUtxoInfo_Unconfirmed(t *testing.T) { hash := chainhash.Hash{9} // Act: Build the public UTXO view for the unconfirmed row. - unconfirmed, err := buildUtxoInfo( + unconfirmed, err := BuildUtxoInfo( hash[:], 2, 5678, []byte{0x52}, time.Unix(222, 0), false, nil, ) @@ -149,7 +148,7 @@ func TestBuildLeasedOutput(t *testing.T) { lockID[0] = 7 // Act: Build the public leased-output view. - lease, err := buildLeasedOutput( + lease, err := BuildLeasedOutput( hash[:], 9, lockID, time.Unix(333, 0).In(time.FixedZone("X", 3600)), ) @@ -161,7 +160,7 @@ func TestBuildLeasedOutput(t *testing.T) { require.Equal(t, time.UTC, lease.Expiration.Location()) } -// TestBuildLeasedOutput_InvalidLockID verifies that buildLeasedOutput rejects +// TestBuildLeasedOutput_InvalidLockID verifies that BuildLeasedOutput rejects // malformed lock IDs. // // Scenario: @@ -181,14 +180,14 @@ func TestBuildLeasedOutput_InvalidLockID(t *testing.T) { shortLockID := []byte{1, 2, 3} // Act: Attempt to build the public leased-output view. - _, err := buildLeasedOutput(hash[:], 0, shortLockID, time.Now()) + _, err := BuildLeasedOutput(hash[:], 0, shortLockID, time.Now()) // Assert: The helper returns the invalid-lock-ID sentinel. require.ErrorIs(t, err, errInvalidLockID) } // TestLeaseOutputWithOps verifies that the shared LeaseOutput helper returns -// the leased outpoint when the backend acquire step succeeds. +// the leased outpoint when the backend Acquire step succeeds. func TestLeaseOutputWithOps(t *testing.T) { t.Parallel() @@ -206,11 +205,11 @@ func TestLeaseOutputWithOps(t *testing.T) { }) nowMatcher := mock.AnythingOfType("time.Time") - ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + ops.On("Acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( acquireExpiration, nil).Once() // Act: Run the shared LeaseOutput flow. - lease, err := leaseOutputWithOps(context.Background(), params, ops) + lease, err := LeaseOutputWithOps(context.Background(), params, ops) // Assert: The helper returns the leased outpoint and UTC expiration. require.NoError(t, err) @@ -235,14 +234,14 @@ func TestLeaseOutputWithOpsRejectsNonPositiveDuration(t *testing.T) { ops.AssertExpectations(t) }) - _, err := leaseOutputWithOps(context.Background(), params, ops) + _, err := LeaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrInvalidParam) - ops.AssertNotCalled(t, "acquire", mock.Anything, mock.Anything, + ops.AssertNotCalled(t, "Acquire", mock.Anything, mock.Anything, mock.Anything, mock.Anything) } // TestLeaseOutputWithOpsMissingUtxo verifies that the shared LeaseOutput helper -// maps a no-row acquire and missing outpoint lookup to ErrUtxoNotFound. +// maps a no-row Acquire and missing outpoint lookup to ErrUtxoNotFound. func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { t.Parallel() @@ -258,17 +257,17 @@ func TestLeaseOutputWithOpsMissingUtxo(t *testing.T) { }) nowMatcher := mock.AnythingOfType("time.Time") - ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( - time.Time{}, errLeaseOutputNoRow).Once() + ops.On("Acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + time.Time{}, ErrLeaseOutputNoRow).Once() - ops.On("hasUtxo", mock.Anything, params).Return(false, nil).Once() + ops.On("HasUtxo", mock.Anything, params).Return(false, nil).Once() - _, err := leaseOutputWithOps(context.Background(), params, ops) + _, err := LeaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrUtxoNotFound) } // TestLeaseOutputWithOpsAlreadyLeased verifies that the shared LeaseOutput -// helper maps a no-row acquire and existing outpoint to ErrOutputAlreadyLeased. +// helper maps a no-row Acquire and existing outpoint to ErrOutputAlreadyLeased. func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { t.Parallel() @@ -284,12 +283,12 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { }) nowMatcher := mock.AnythingOfType("time.Time") - ops.On("acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( - time.Time{}, errLeaseOutputNoRow).Once() + ops.On("Acquire", mock.Anything, params, nowMatcher, nowMatcher).Return( + time.Time{}, ErrLeaseOutputNoRow).Once() - ops.On("hasUtxo", mock.Anything, params).Return(true, nil).Once() + ops.On("HasUtxo", mock.Anything, params).Return(true, nil).Once() - _, err := leaseOutputWithOps(context.Background(), params, ops) + _, err := LeaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrOutputAlreadyLeased) } @@ -298,7 +297,7 @@ func TestLeaseOutputWithOpsAlreadyLeased(t *testing.T) { func TestReleaseOutputWithOps(t *testing.T) { t.Parallel() - // Arrange: Build one valid release request and one successful stub adapter. + // Arrange: Build one valid Release request and one successful stub adapter. params := ReleaseOutputParams{ WalletID: 5, OutPoint: testLeaseOutPoint(), @@ -309,13 +308,13 @@ func TestReleaseOutputWithOps(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + ops.On("LookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() - ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + ops.On("Release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( int64(1), nil).Once() // Act: Run the shared ReleaseOutput flow. - err := releaseOutputWithOps(context.Background(), params, ops) + err := ReleaseOutputWithOps(context.Background(), params, ops) // Assert: The helper stops after the successful delete path. require.NoError(t, err) @@ -336,10 +335,10 @@ func TestReleaseOutputWithOpsMissingUtxo(t *testing.T) { ops.AssertExpectations(t) }) - ops.On("lookupUtxoID", mock.Anything, params).Return( - int64(0), errReleaseOutputUtxoNotFound).Once() + ops.On("LookupUtxoID", mock.Anything, params).Return( + int64(0), ErrReleaseOutputUtxoNotFound).Once() - err := releaseOutputWithOps(context.Background(), params, ops) + err := ReleaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrUtxoNotFound) } @@ -359,15 +358,16 @@ func TestReleaseOutputWithOpsWrongLock(t *testing.T) { }) releaseTimeMatcher := mock.AnythingOfType("time.Time") - ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() - ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + ops.On("LookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("Release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( int64(0), nil).Once() - ops.On("activeLockID", mock.Anything, uint32(5), int64(11), + ops.On("ActiveLockID", mock.Anything, uint32(5), int64(11), releaseTimeMatcher).Return([]byte{1, 2, 3}, nil).Once() - err := releaseOutputWithOps(context.Background(), params, ops) + err := ReleaseOutputWithOps(context.Background(), params, ops) require.ErrorIs(t, err, ErrOutputUnlockNotAllowed) } @@ -388,27 +388,28 @@ func TestReleaseOutputWithOpsMissingActiveLease(t *testing.T) { }) releaseTimeMatcher := mock.AnythingOfType("time.Time") - ops.On("lookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() - ops.On("release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( + ops.On("LookupUtxoID", mock.Anything, params).Return(int64(11), nil).Once() + + ops.On("Release", mock.Anything, uint32(5), int64(11), [32]byte{9}).Return( int64(0), nil).Once() - ops.On("activeLockID", mock.Anything, uint32(5), int64(11), - releaseTimeMatcher).Return(nil, errReleaseOutputNoActiveLease).Once() + ops.On("ActiveLockID", mock.Anything, uint32(5), int64(11), + releaseTimeMatcher).Return(nil, ErrReleaseOutputNoActiveLease).Once() - err := releaseOutputWithOps(context.Background(), params, ops) + err := ReleaseOutputWithOps(context.Background(), params, ops) require.NoError(t, err) } -// mockLeaseOutputOps is a mock implementation of leaseOutputOps. +// mockLeaseOutputOps is a mock implementation of LeaseOutputOps. type mockLeaseOutputOps struct { mock.Mock } -var _ leaseOutputOps = (*mockLeaseOutputOps)(nil) +var _ LeaseOutputOps = (*mockLeaseOutputOps)(nil) -// acquire implements leaseOutputOps. -func (m *mockLeaseOutputOps) acquire(ctx context.Context, +// Acquire implements LeaseOutputOps. +func (m *mockLeaseOutputOps) Acquire(ctx context.Context, params LeaseOutputParams, start time.Time, expiration time.Time) (time.Time, error) { @@ -422,29 +423,29 @@ func (m *mockLeaseOutputOps) acquire(ctx context.Context, return leaseExpiration, args.Error(1) } -// hasUtxo implements leaseOutputOps. -func (m *mockLeaseOutputOps) hasUtxo(ctx context.Context, +// HasUtxo implements LeaseOutputOps. +func (m *mockLeaseOutputOps) HasUtxo(ctx context.Context, params LeaseOutputParams) (bool, error) { args := m.Called(ctx, params) - hasUtxo, ok := args.Get(0).(bool) + HasUtxo, ok := args.Get(0).(bool) if !ok { return false, errMockType } - return hasUtxo, args.Error(1) + return HasUtxo, args.Error(1) } -// mockReleaseOutputOps is a mock implementation of releaseOutputOps. +// mockReleaseOutputOps is a mock implementation of ReleaseOutputOps. type mockReleaseOutputOps struct { mock.Mock } -var _ releaseOutputOps = (*mockReleaseOutputOps)(nil) +var _ ReleaseOutputOps = (*mockReleaseOutputOps)(nil) -// lookupUtxoID implements releaseOutputOps. -func (m *mockReleaseOutputOps) lookupUtxoID(ctx context.Context, +// LookupUtxoID implements ReleaseOutputOps. +func (m *mockReleaseOutputOps) LookupUtxoID(ctx context.Context, params ReleaseOutputParams) (int64, error) { args := m.Called(ctx, params) @@ -457,8 +458,8 @@ func (m *mockReleaseOutputOps) lookupUtxoID(ctx context.Context, return utxoID, args.Error(1) } -// release implements releaseOutputOps. -func (m *mockReleaseOutputOps) release(ctx context.Context, walletID uint32, +// Release implements ReleaseOutputOps. +func (m *mockReleaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { args := m.Called(ctx, walletID, utxoID, lockID) @@ -471,8 +472,8 @@ func (m *mockReleaseOutputOps) release(ctx context.Context, walletID uint32, return releasedRows, args.Error(1) } -// activeLockID implements releaseOutputOps. -func (m *mockReleaseOutputOps) activeLockID(ctx context.Context, +// ActiveLockID implements ReleaseOutputOps. +func (m *mockReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, now time.Time) ([]byte, error) { args := m.Called(ctx, walletID, utxoID, now) @@ -494,13 +495,13 @@ func testLeaseOutPoint() wire.OutPoint { return wire.OutPoint{Hash: chainhash.Hash{7}, Index: 1} } -// TestBuildUtxoInfoMaxAmount verifies that buildUtxoInfo preserves the largest +// TestBuildUtxoInfoMaxAmount verifies that BuildUtxoInfo preserves the largest // valid satoshi amount. func TestBuildUtxoInfoMaxAmount(t *testing.T) { t.Parallel() hash := chainhash.Hash{10} - info, err := buildUtxoInfo( + info, err := BuildUtxoInfo( hash[:], 3, int64(btcutil.MaxSatoshi), []byte{0x53}, time.Unix(333, 0), false, nil, ) @@ -510,81 +511,25 @@ func TestBuildUtxoInfoMaxAmount(t *testing.T) { require.Equal(t, UnminedHeight, info.Height) } -// TestBuildUtxoInfoInvalidHash verifies that buildUtxoInfo rejects malformed +// TestBuildUtxoInfoInvalidHash verifies that BuildUtxoInfo rejects malformed // hash bytes. func TestBuildUtxoInfoInvalidHash(t *testing.T) { t.Parallel() - _, err := buildUtxoInfo( + _, err := BuildUtxoInfo( []byte{1, 2, 3}, 6, 1000, []byte{0x56}, time.Unix(666, 0), false, nil, ) require.Error(t, err) } -// TestBuildLeasedOutputInvalidHash verifies that buildLeasedOutput rejects +// TestBuildLeasedOutputInvalidHash verifies that BuildLeasedOutput rejects // malformed hash bytes. func TestBuildLeasedOutputInvalidHash(t *testing.T) { t.Parallel() lockID := make([]byte, 32) - _, err := buildLeasedOutput([]byte{1, 2, 3}, 7, lockID, time.Unix(777, 0)) + _, err := BuildLeasedOutput([]byte{1, 2, 3}, 7, lockID, time.Unix(777, 0)) require.Error(t, err) } - -// TestUtxoInfoFromSqliteRowInvalidOutputIndex verifies that the sqlite row -// decoder rejects output indexes outside the uint32 range. -func TestUtxoInfoFromSqliteRowInvalidOutputIndex(t *testing.T) { - t.Parallel() - - hash := chainhash.Hash{13} - _, err := utxoInfoFromSqliteRow( - hash[:], -1, 1000, []byte{0x57}, time.Unix(888, 0), false, - sql.NullInt64{}, - ) - - require.ErrorContains(t, err, "utxo output index") -} - -// TestUtxoInfoFromSqliteRowInvalidBlockHeight verifies that the sqlite row -// decoder rejects invalid confirmed block heights. -func TestUtxoInfoFromSqliteRowInvalidBlockHeight(t *testing.T) { - t.Parallel() - - hash := chainhash.Hash{14} - _, err := utxoInfoFromSqliteRow( - hash[:], 0, 1000, []byte{0x58}, time.Unix(999, 0), false, - sql.NullInt64{Int64: -1, Valid: true}, - ) - - require.ErrorContains(t, err, "utxo block height") -} - -// TestUtxoInfoFromPgRowInvalidOutputIndex verifies that the postgres row -// decoder rejects output indexes outside the uint32 range. -func TestUtxoInfoFromPgRowInvalidOutputIndex(t *testing.T) { - t.Parallel() - - hash := chainhash.Hash{15} - _, err := utxoInfoFromPgRow( - hash[:], -1, 1000, []byte{0x59}, time.Unix(1000, 0), false, - sql.NullInt32{}, - ) - - require.ErrorContains(t, err, "utxo output index") -} - -// TestUtxoInfoFromPgRowInvalidBlockHeight verifies that the postgres row -// decoder rejects invalid confirmed block heights. -func TestUtxoInfoFromPgRowInvalidBlockHeight(t *testing.T) { - t.Parallel() - - hash := chainhash.Hash{16} - _, err := utxoInfoFromPgRow( - hash[:], 0, 1000, []byte{0x5a}, time.Unix(1001, 0), false, - sql.NullInt32{Int32: -1, Valid: true}, - ) - - require.ErrorContains(t, err, "utxo block height") -} diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/wallet_pg.go index d0f2277b82..8d3f212c77 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/wallet_pg.go @@ -178,7 +178,7 @@ func (s *PostgresStore) IterWallets(ctx context.Context, query ListWalletsQuery) iter.Seq2[WalletInfo, error] { return page.Iter( - ctx, query, s.ListWallets, nextListWalletsQuery, + ctx, query, s.ListWallets, NextListWalletsQuery, ) } @@ -337,7 +337,7 @@ func pgListWalletsParams( // buildPgWalletInfo constructs a WalletInfo from the given wallet row // parameters. func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { - walletID, err := int64ToUint32(row.id) + walletID, err := Int64ToUint32(row.id) if err != nil { return nil, err } @@ -393,7 +393,7 @@ func buildUpdateSyncParamsPg(params UpdateWalletParams) ( } if params.SyncedTo != nil { - syncedHeight, err := uint32ToNullInt32(params.SyncedTo.Height) + syncedHeight, err := Uint32ToNullInt32(params.SyncedTo.Height) if err != nil { return syncParams, err } @@ -409,7 +409,7 @@ func buildUpdateSyncParamsPg(params UpdateWalletParams) ( } if params.BirthdayBlock != nil { - birthdayHeight, err := uint32ToNullInt32( + birthdayHeight, err := Uint32ToNullInt32( params.BirthdayBlock.Height, ) if err != nil { diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/wallet_sqlite.go index 6ce787db31..e0821b4b2f 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/wallet_sqlite.go @@ -180,7 +180,7 @@ func (s *SqliteStore) IterWallets(ctx context.Context, query ListWalletsQuery) iter.Seq2[WalletInfo, error] { return page.Iter( - ctx, query, s.ListWallets, nextListWalletsQuery, + ctx, query, s.ListWallets, NextListWalletsQuery, ) } @@ -337,12 +337,12 @@ func sqliteListWalletsParams( // buildSqliteWalletInfo constructs a WalletInfo from the given wallet // row parameters. func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { - walletID, err := int64ToUint32(row.id) + walletID, err := Int64ToUint32(row.id) if err != nil { return nil, err } - mgrVer, err := int64ToInt32(row.managerVersion) + mgrVer, err := Int64ToInt32(row.managerVersion) if err != nil { return nil, err } diff --git a/wallet/internal/db/wallets_common.go b/wallet/internal/db/wallets_common.go index cb3ee35be1..cfeb7b5f3d 100644 --- a/wallet/internal/db/wallets_common.go +++ b/wallet/internal/db/wallets_common.go @@ -1,8 +1,8 @@ package db -// nextListWalletsQuery returns a query with its pagination cursor advanced to +// NextListWalletsQuery returns a query with its pagination cursor advanced to // the provided value. -func nextListWalletsQuery(q ListWalletsQuery, cursor uint32) ListWalletsQuery { +func NextListWalletsQuery(q ListWalletsQuery, cursor uint32) ListWalletsQuery { q.Page = q.Page.WithAfter(cursor) return q From 51a9a4c0ea7b29da26d74b7838f68dd332380976 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:14:16 +0800 Subject: [PATCH 531/691] db/sqlite: move backend files Move the handwritten SQLite store implementation into wallet/internal/db/sqlite while keeping shared contracts and helper logic in wallet/internal/db. This keeps the split mostly mechanical and updates the sqlite-specific tests and itest helpers to import the new package. The move also shifts sqlite-only backend tests out of the root db package so the root tests no longer depend on sqlite-private symbols. --- wallet/internal/db/accounts_common.go | 16 +- wallet/internal/db/accounts_pg.go | 2 +- wallet/internal/db/addresses_common.go | 18 +- wallet/internal/db/addresses_pg.go | 2 +- wallet/internal/db/db_connectors_test.go | 40 +++-- wallet/internal/db/db_itest.go | 11 -- .../internal/db/itest/fixtures_sqlite_test.go | 5 +- wallet/internal/db/itest/sqlite_test.go | 17 +- .../db/itest/tx_corruption_sqlite_test.go | 11 +- .../accounts.go} | 103 +++++------ .../address_types.go} | 19 +- .../addresses.go} | 87 +++++----- .../internal/db/sqlite/backend_error_test.go | 105 +++++++++++ .../internal/db/sqlite/backend_rows_test.go | 122 +++++++++++++ .../db/{block_sqlite.go => sqlite/block.go} | 19 +- wallet/internal/db/sqlite/itest.go | 19 ++ .../db/{sqlite.go => sqlite/store.go} | 33 ++-- wallet/internal/db/sqlite/testhelpers_test.go | 163 ++++++++++++++++++ .../txstore_createtx.go} | 57 +++--- .../txstore_deletetx.go} | 23 +-- .../txstore_gettx.go} | 13 +- .../txstore_invalidateunmined.go} | 31 ++-- .../txstore_listtxns.go} | 15 +- .../txstore_rollback.go} | 19 +- .../txstore_updatetx.go} | 19 +- wallet/internal/db/sqlite/utxo_extra_test.go | 34 ++++ .../utxostore_balance.go} | 17 +- .../utxostore_getutxo.go} | 15 +- .../utxostore_leaseoutput.go} | 19 +- .../utxostore_listleasedoutputs.go} | 11 +- .../utxostore_listutxos.go} | 13 +- .../utxostore_releaseoutput.go} | 21 +-- .../db/{wallet_sqlite.go => sqlite/wallet.go} | 51 +++--- .../db/tx_store_backend_error_test.go | 103 ----------- .../internal/db/tx_store_backend_rows_test.go | 117 ------------- 35 files changed, 809 insertions(+), 561 deletions(-) rename wallet/internal/db/{accounts_sqlite.go => sqlite/accounts.go} (80%) rename wallet/internal/db/{address_types_sqlite.go => sqlite/address_types.go} (68%) rename wallet/internal/db/{addresses_sqlite.go => sqlite/addresses.go} (79%) create mode 100644 wallet/internal/db/sqlite/backend_error_test.go create mode 100644 wallet/internal/db/sqlite/backend_rows_test.go rename wallet/internal/db/{block_sqlite.go => sqlite/block.go} (81%) create mode 100644 wallet/internal/db/sqlite/itest.go rename wallet/internal/db/{sqlite.go => sqlite/store.go} (73%) create mode 100644 wallet/internal/db/sqlite/testhelpers_test.go rename wallet/internal/db/{sqlite_txstore_createtx.go => sqlite/txstore_createtx.go} (90%) rename wallet/internal/db/{sqlite_txstore_deletetx.go => sqlite/txstore_deletetx.go} (89%) rename wallet/internal/db/{sqlite_txstore_gettx.go => sqlite/txstore_gettx.go} (81%) rename wallet/internal/db/{sqlite_txstore_invalidateunmined.go => sqlite/txstore_invalidateunmined.go} (77%) rename wallet/internal/db/{sqlite_txstore_listtxns.go => sqlite/txstore_listtxns.go} (87%) rename wallet/internal/db/{sqlite_txstore_rollback.go => sqlite/txstore_rollback.go} (91%) rename wallet/internal/db/{sqlite_txstore_updatetx.go => sqlite/txstore_updatetx.go} (86%) create mode 100644 wallet/internal/db/sqlite/utxo_extra_test.go rename wallet/internal/db/{sqlite_utxostore_balance.go => sqlite/utxostore_balance.go} (54%) rename wallet/internal/db/{sqlite_utxostore_getutxo.go => sqlite/utxostore_getutxo.go} (81%) rename wallet/internal/db/{sqlite_utxostore_leaseoutput.go => sqlite/utxostore_leaseoutput.go} (81%) rename wallet/internal/db/{sqlite_utxostore_listleasedoutputs.go => sqlite/utxostore_listleasedoutputs.go} (75%) rename wallet/internal/db/{sqlite_utxostore_listutxos.go => sqlite/utxostore_listutxos.go} (70%) rename wallet/internal/db/{sqlite_utxostore_releaseoutput.go => sqlite/utxostore_releaseoutput.go} (81%) rename wallet/internal/db/{wallet_sqlite.go => sqlite/wallet.go} (89%) diff --git a/wallet/internal/db/accounts_common.go b/wallet/internal/db/accounts_common.go index 2bb6317261..85c76b4d66 100644 --- a/wallet/internal/db/accounts_common.go +++ b/wallet/internal/db/accounts_common.go @@ -21,8 +21,8 @@ var ( errInvalidAccountOrigin = errors.New("invalid account origin") ) -// validate validates required fields for creating a derived account. -func (params *CreateDerivedAccountParams) validate() error { +// Validate validates required fields for creating a derived account. +func (params *CreateDerivedAccountParams) Validate() error { if params.Name == "" { return ErrMissingAccountName } @@ -30,8 +30,8 @@ func (params *CreateDerivedAccountParams) validate() error { return nil } -// validate validates required fields for creating an imported account. -func (params *CreateImportedAccountParams) validate() error { +// Validate validates required fields for creating an imported account. +func (params *CreateImportedAccountParams) Validate() error { if params.Name == "" { return ErrMissingAccountName } @@ -43,8 +43,8 @@ func (params *CreateImportedAccountParams) validate() error { return nil } -// isWatchOnly returns true if the account is watch-only. -func (params *CreateImportedAccountParams) isWatchOnly() bool { +// IsWatchOnly returns true if the account is watch-only. +func (params *CreateImportedAccountParams) IsWatchOnly() bool { return len(params.EncryptedPrivateKey) == 0 } @@ -511,12 +511,12 @@ func CreateImportedAccount[CreateArgs any, CreateRow any, SecretArgs any]( getProps func(accountID int64) (*AccountProperties, error), ) (*AccountProperties, error) { - err := params.validate() + err := params.Validate() if err != nil { return nil, err } - isWatchOnly := params.isWatchOnly() + isWatchOnly := params.IsWatchOnly() scopeID, err := ensureScope() if err != nil { diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/accounts_pg.go index 72c2f54860..257be1d3e8 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/accounts_pg.go @@ -51,7 +51,7 @@ func (s *PostgresStore) RenameAccount(ctx context.Context, func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, params CreateDerivedAccountParams) (*AccountInfo, error) { - paramsErr := params.validate() + paramsErr := params.Validate() if paramsErr != nil { return nil, paramsErr } diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index db022a33a1..74238117b6 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -78,9 +78,9 @@ func GetAddressSecret[Row any](ctx context.Context, return nil, fmt.Errorf("get address secret: %w", err) } -// validate validates the required fields for creating an imported address. +// Validate validates the required fields for creating an imported address. // Returns sentinel errors on failure. -func (p NewImportedAddressParams) validate() error { +func (p NewImportedAddressParams) Validate() error { if len(p.ScriptPubKey) == 0 { return ErrMissingScriptPubKey } @@ -88,9 +88,9 @@ func (p NewImportedAddressParams) validate() error { return nil } -// isWatchOnly returns true if the params include neither a private key nor +// IsWatchOnly returns true if the params include neither a private key nor // a redeem or witness script. -func (p NewImportedAddressParams) isWatchOnly() bool { +func (p NewImportedAddressParams) IsWatchOnly() bool { noPrivKey := len(p.EncryptedPrivateKey) == 0 noScript := len(p.EncryptedScript) == 0 @@ -227,7 +227,7 @@ func newImportedAddressTx[QTX any, Row any, CreateArgs any, InsertArgs any]( } addrID := rowID(addrRow) - if !params.isWatchOnly() { + if !params.IsWatchOnly() { err = insertFn(qtx)(ctx, insertArgs(addrID, params)) if err != nil { return nil, fmt.Errorf("insert address secret: %w", err) @@ -247,7 +247,7 @@ func newImportedAddressTx[QTX any, Row any, CreateArgs any, InsertArgs any]( Origin: ImportedAccount, ScriptPubKey: params.ScriptPubKey, PubKey: params.PubKey, - IsWatchOnly: params.isWatchOnly(), + IsWatchOnly: params.IsWatchOnly(), }, nil } @@ -301,9 +301,9 @@ func convertAddressPath(origin AccountOrigin, branch, return addrBranch, addrIndex, nil } -// addressRowToInfo converts raw database field values into an AddressInfo +// AddressRowToInfo converts raw database field values into an AddressInfo // struct. It handles type conversion and validation for each field. -func addressRowToInfo[TypeID, OriginIDType any]( +func AddressRowToInfo[TypeID, OriginIDType any]( row AddressInfoRow[TypeID, OriginIDType]) (*AddressInfo, error) { id, accountID, err := convertAddressIDs(row.ID, row.AccountID) @@ -611,7 +611,7 @@ func NewImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, adapters ImportedAddressAdapters[QTX, AccountRow, AccountParams, CreateArgs, AddrRow, SecretParams]) (*AddressInfo, error) { - validationErr := params.validate() + validationErr := params.Validate() if validationErr != nil { return nil, validationErr } diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/addresses_pg.go index 0d01de2b03..8aba77a7a1 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/addresses_pg.go @@ -290,7 +290,7 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAddressByScriptPubKeyRow(row) - info, err := addressRowToInfo(AddressInfoRow[int16, int16]{ + info, err := AddressRowToInfo(AddressInfoRow[int16, int16]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index 8c9ec46d55..a8b37a7d2d 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -1,9 +1,11 @@ -package db +package db_test import ( "path/filepath" "testing" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -12,23 +14,23 @@ func TestNewPostgresStoreValidateConfig(t *testing.T) { tests := []struct { name string - cfg PostgresConfig + cfg db.PostgresConfig wantErr error }{ { name: "empty DSN", - cfg: PostgresConfig{ + cfg: db.PostgresConfig{ Dsn: "", }, - wantErr: ErrEmptyDSN, + wantErr: db.ErrEmptyDSN, }, { name: "negative max connections", - cfg: PostgresConfig{ + cfg: db.PostgresConfig{ Dsn: "postgres://test", MaxConnections: -1, }, - wantErr: ErrNegativeMaxConns, + wantErr: db.ErrNegativeMaxConns, }, } @@ -36,7 +38,7 @@ func TestNewPostgresStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := NewPostgresStore(t.Context(), tc.cfg) + store, err := db.NewPostgresStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -47,15 +49,15 @@ func TestNewPostgresStoreConnectionFailure(t *testing.T) { t.Parallel() // Valid config, but hits a connection failure. - cfg := PostgresConfig{ + cfg := db.PostgresConfig{ Dsn: "postgres://localhost:1/testdb", } - store, err := NewPostgresStore(t.Context(), cfg) + store, err := db.NewPostgresStore(t.Context(), cfg) require.Error(t, err) require.ErrorContains(t, err, "ping database") - require.NotErrorIs(t, err, ErrEmptyDSN) - require.NotErrorIs(t, err, ErrNegativeMaxConns) + require.NotErrorIs(t, err, db.ErrEmptyDSN) + require.NotErrorIs(t, err, db.ErrNegativeMaxConns) // We are asserting nil here because it's not an integration test, so we // are not able to create a postgres database and connect to it. @@ -67,23 +69,23 @@ func TestNewSqliteStoreValidateConfig(t *testing.T) { tests := []struct { name string - cfg SqliteConfig + cfg db.SqliteConfig wantErr error }{ { name: "empty DB path", - cfg: SqliteConfig{ + cfg: db.SqliteConfig{ DBPath: "", }, - wantErr: ErrEmptyDBPath, + wantErr: db.ErrEmptyDBPath, }, { name: "negative max connections", - cfg: SqliteConfig{ + cfg: db.SqliteConfig{ DBPath: "/tmp/test.db", MaxConnections: -1, }, - wantErr: ErrNegativeMaxConns, + wantErr: db.ErrNegativeMaxConns, }, } @@ -91,7 +93,7 @@ func TestNewSqliteStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := NewSqliteStore(t.Context(), tc.cfg) + store, err := dbsqlite.NewSqliteStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -101,11 +103,11 @@ func TestNewSqliteStoreValidateConfig(t *testing.T) { func TestNewSqliteStoreSuccess(t *testing.T) { t.Parallel() - cfg := SqliteConfig{ + cfg := db.SqliteConfig{ DBPath: filepath.Join(t.TempDir(), "wallet.db"), } - store, err := NewSqliteStore(t.Context(), cfg) + store, err := dbsqlite.NewSqliteStore(t.Context(), cfg) require.NoError(t, err) require.NotNil(t, store) diff --git a/wallet/internal/db/db_itest.go b/wallet/internal/db/db_itest.go index 85ca82369f..fb53c7271a 100644 --- a/wallet/internal/db/db_itest.go +++ b/wallet/internal/db/db_itest.go @@ -6,19 +6,8 @@ import ( "database/sql" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -// DB returns the underlying *sql.DB connection for integration testing. -func (s *SqliteStore) DB() *sql.DB { - return s.db -} - -// Queries returns the underlying sqlc queries for integration testing. -func (s *SqliteStore) Queries() *sqlcsqlite.Queries { - return s.queries -} - // DB returns the underlying *sql.DB connection for integration testing. func (s *PostgresStore) DB() *sql.DB { return s.db diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index de5a4783bd..a3a4cae69c 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -189,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &db.SqliteStore{}, store) + require.IsType(t, &dbsqlite.SqliteStore{}, store) - sqliteStore := store.(*db.SqliteStore) + sqliteStore := store.(*dbsqlite.SqliteStore) queries := sqliteStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 9d1e3c3fa1..6f7712c648 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -12,12 +12,13 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) // NewTestStore creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewTestStore(t *testing.T) *db.SqliteStore { +func NewTestStore(t *testing.T) *dbsqlite.SqliteStore { t.Helper() tmpDir := t.TempDir() @@ -28,7 +29,7 @@ func NewTestStore(t *testing.T) *db.SqliteStore { MaxConnections: 0, } - store, err := db.NewSqliteStore(t.Context(), cfg) + store, err := dbsqlite.NewSqliteStore(t.Context(), cfg) require.NoError(t, err, "failed to create sqlite store") t.Cleanup(func() { @@ -40,7 +41,8 @@ func NewTestStore(t *testing.T) *db.SqliteStore { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *db.SqliteStore, walletID uint32, +func childSpendingTxIDs(t *testing.T, store *dbsqlite.SqliteStore, + walletID uint32, txHash chainhash.Hash) []int64 { t.Helper() @@ -72,7 +74,7 @@ func childSpendingTxIDs(t *testing.T, store *db.SqliteStore, walletID uint32, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *db.SqliteStore, walletID uint32, +func txIDByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() @@ -96,7 +98,7 @@ func txIDByHash(t *testing.T, store *db.SqliteStore, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *db.SqliteStore, walletID uint32, +func rawTxByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() @@ -114,7 +116,7 @@ func rawTxByHash(t *testing.T, store *db.SqliteStore, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *db.SqliteStore, walletID uint32, +func setTxStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -135,7 +137,8 @@ func setTxStatus(t *testing.T, store *db.SqliteStore, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *db.SqliteStore, walletID uint32, +func walletUtxoExists(t *testing.T, store *dbsqlite.SqliteStore, + walletID uint32, outPoint wire.OutPoint) bool { t.Helper() diff --git a/wallet/internal/db/itest/tx_corruption_sqlite_test.go b/wallet/internal/db/itest/tx_corruption_sqlite_test.go index ad9090fe7c..2d6e147067 100644 --- a/wallet/internal/db/itest/tx_corruption_sqlite_test.go +++ b/wallet/internal/db/itest/tx_corruption_sqlite_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -19,7 +20,7 @@ import ( // corruptTransactionStatus writes an invalid tx status into one stored row while // sqlite check constraints are disabled inside the surrounding transaction. The // corruption itests use this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *db.SqliteStore, +func corruptTransactionStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -54,7 +55,7 @@ func corruptTransactionStatus(t *testing.T, store *db.SqliteStore, // corruptTransactionHash writes malformed tx-hash bytes into one stored row // while sqlite check constraints are disabled. The corruption itests then // verify that hash decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *db.SqliteStore, +func corruptTransactionHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -89,7 +90,7 @@ func corruptTransactionHash(t *testing.T, store *db.SqliteStore, // corruptTransactionBlockHeight writes an invalid block height after first // creating a matching block row in sqlite. The corruption itests use this to // verify that reads reject impossible confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *db.SqliteStore, +func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -134,7 +135,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *db.SqliteStore, // corruptUtxoOutputIndex writes an invalid output index into one stored UTXO // while sqlite check constraints are disabled. The corruption itests then // verify that UTXO decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *db.SqliteStore, +func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -170,7 +171,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *db.SqliteStore, // corruptActiveLeaseLockID writes an invalid lease lock ID into one active // lease row while sqlite check constraints are disabled. The corruption itests // use this to verify that lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *db.SqliteStore, +func corruptActiveLeaseLockID(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/accounts_sqlite.go b/wallet/internal/db/sqlite/accounts.go similarity index 80% rename from wallet/internal/db/accounts_sqlite.go rename to wallet/internal/db/sqlite/accounts.go index d61a46111d..50a656dad8 100644 --- a/wallet/internal/db/accounts_sqlite.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -1,34 +1,37 @@ -package db +package sqlite import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) // Ensure SqliteStore satisfies the AccountStore interface. -var _ AccountStore = (*SqliteStore)(nil) +var _ db.AccountStore = (*SqliteStore)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. func (s *SqliteStore) GetAccount(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { getQueries := sqliteAccountGetQueries{q: s.queries} - return GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return db.GetAccountByQuery( + ctx, query, getQueries.byNumber, getQueries.byName, + ) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. func (s *SqliteStore) ListAccounts(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { listQueries := sqliteAccountListQueries{q: s.queries} - return ListAccountsByQuery( + return db.ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, ) } @@ -36,11 +39,11 @@ func (s *SqliteStore) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. func (s *SqliteStore) RenameAccount(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { renameQueries := sqliteAccountRenameQueries{q: s.queries} - return RenameAccountByQuery( + return db.RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, ) } @@ -49,14 +52,14 @@ func (s *SqliteStore) RenameAccount(ctx context.Context, // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, - params CreateDerivedAccountParams) (*AccountInfo, error) { + params db.CreateDerivedAccountParams) (*db.AccountInfo, error) { - paramsErr := params.validate() + paramsErr := params.Validate() if paramsErr != nil { return nil, paramsErr } - var info *AccountInfo + var info *db.AccountInfo err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { scopeID, err := sqliteEnsureKeyScope( @@ -70,7 +73,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, ctx, sqlcsqlite.CreateDerivedAccountParams{ ScopeID: scopeID, AccountName: params.Name, - OriginID: int64(DerivedAccount), + OriginID: int64(db.DerivedAccount), IsWatchOnly: false, }, ) @@ -81,16 +84,16 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, if !row.AccountNumber.Valid { // This should never happen unless the query is modified // incorrectly. - return ErrNilDBAccountNumber + return db.ErrNilDBAccountNumber } - accNumber, err := Int64ToUint32(row.AccountNumber.Int64) + accNumber, err := db.Int64ToUint32(row.AccountNumber.Int64) if err != nil { - return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) + return fmt.Errorf("%w: %w", db.ErrMaxAccountNumberReached, err) } - info = BuildAccountInfo( - accNumber, params.Name, DerivedAccount, 0, 0, 0, false, + info = db.BuildAccountInfo( + accNumber, params.Name, db.DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -106,16 +109,16 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, // sqliteEnsureKeyScope retrieves an existing key scope or creates it if missing // for SQLite. It returns the scope ID once available. func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, - walletID uint32, scope KeyScope) (int64, error) { + walletID uint32, scope db.KeyScope) (int64, error) { - return EnsureKeyScope( + return db.EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), }, qtx.CreateKeyScope, - func(addrSchema ScopeAddrSchema) sqlcsqlite.CreateKeyScopeParams { + func(addrSchema db.ScopeAddrSchema) sqlcsqlite.CreateKeyScopeParams { return sqlcsqlite.CreateKeyScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), @@ -138,14 +141,14 @@ func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. func (s *SqliteStore) CreateImportedAccount(ctx context.Context, - params CreateImportedAccountParams) (*AccountProperties, error) { + params db.CreateImportedAccountParams) (*db.AccountProperties, error) { - var props *AccountProperties + var props *db.AccountProperties err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { var err error - props, err = CreateImportedAccount( + props, err = db.CreateImportedAccount( ctx, params, func() (int64, error) { return sqliteEnsureKeyScope( @@ -158,7 +161,7 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, return row.ID }, qtx.CreateAccountSecret, sqliteBuildCreateAccountSecretArgs(params), - func(accountID int64) (*AccountProperties, error) { + func(accountID int64) (*db.AccountProperties, error) { return sqliteGetAccountProps(ctx, qtx, accountID) }, ) @@ -175,7 +178,7 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, // sqliteBuildCreateImportedAccountArgs returns a function that builds the // CreateImportedAccountParams for SQLite. func sqliteBuildCreateImportedAccountArgs( - params CreateImportedAccountParams, + params db.CreateImportedAccountParams, ) func(int64, bool) sqlcsqlite.CreateImportedAccountParams { return func(scopeID int64, @@ -184,7 +187,7 @@ func sqliteBuildCreateImportedAccountArgs( return sqlcsqlite.CreateImportedAccountParams{ ScopeID: scopeID, AccountName: params.Name, - OriginID: int64(ImportedAccount), + OriginID: int64(db.ImportedAccount), EncryptedPublicKey: params.EncryptedPublicKey, MasterFingerprint: sql.NullInt64{ Int64: int64(params.MasterFingerprint), @@ -198,7 +201,7 @@ func sqliteBuildCreateImportedAccountArgs( // sqliteBuildCreateAccountSecretArgs returns a function that builds the // CreateAccountSecretParams for SQLite. func sqliteBuildCreateAccountSecretArgs( - params CreateImportedAccountParams, + params db.CreateImportedAccountParams, ) func(int64) sqlcsqlite.CreateAccountSecretParams { return func(accountID int64) sqlcsqlite.CreateAccountSecretParams { @@ -212,14 +215,14 @@ func sqliteBuildCreateAccountSecretArgs( // sqliteGetAccountProps fetches full account properties from the database and // converts the row to AccountProperties. func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, - accountID int64) (*AccountProperties, error) { + accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) if err != nil { return nil, fmt.Errorf("get account props: %w", err) } - return AccountPropsRowToProps(AccountPropsRow[int64, int64]{ + return db.AccountPropsRowToProps(db.AccountPropsRow[int64, int64]{ AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, @@ -234,8 +237,8 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, CoinType: row.CoinType, InternalTypeID: row.InternalTypeID, ExternalTypeID: row.ExternalTypeID, - IDToAddrType: IDToAddressType[int64], - IDToOriginType: IDToAccountOrigin[int64], + IDToAddrType: db.IDToAddressType[int64], + IDToOriginType: db.IDToAccountOrigin[int64], }) } @@ -255,14 +258,14 @@ type sqliteAccountInfoRow interface { // sqliteAccountRowToInfo converts a SQLite account row to an AccountInfo // struct. It uses type conversion since all sqliteAccountInfoRow types have // identical fields. -func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, +func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcsqlite.GetAccountByScopeAndNameRow(row) - return AccountRowToInfo(AccountInfoRow[int64]{ + return db.AccountRowToInfo(db.AccountInfoRow[int64]{ AccountNumber: base.AccountNumber, AccountName: base.AccountName, OriginID: base.OriginID, @@ -273,7 +276,7 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*AccountInfo, CreatedAt: base.CreatedAt, Purpose: base.Purpose, CoinType: base.CoinType, - IDToOriginType: IDToAccountOrigin[int64], + IDToOriginType: db.IDToAccountOrigin[int64], }) } @@ -284,9 +287,9 @@ type sqliteAccountListQueries struct { // byScope lists accounts filtered by wallet ID and key scope. func (s sqliteAccountListQueries) byScope(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, s.q.ListAccountsByWalletScope, sqlcsqlite.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), @@ -298,9 +301,9 @@ func (s sqliteAccountListQueries) byScope(ctx context.Context, // byName lists accounts filtered by wallet ID and account name. func (s sqliteAccountListQueries) byName(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, s.q.ListAccountsByWalletAndName, sqlcsqlite.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), @@ -311,9 +314,9 @@ func (s sqliteAccountListQueries) byName(ctx context.Context, // all lists all accounts for a wallet. func (s sqliteAccountListQueries) all(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, s.q.ListAccountsByWallet, int64(query.WalletID), sqliteAccountRowToInfo, ) @@ -326,24 +329,24 @@ type sqliteAccountGetQueries struct { // byNumber retrieves an account by wallet ID, scope, and account number. func (s sqliteAccountGetQueries) byNumber(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { - return GetAccount( + return db.GetAccount( ctx, s.q.GetAccountByWalletScopeAndNumber, sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - AccountNumber: NullableUint32ToSQLInt64(query.AccountNumber), + AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), }, query, sqliteAccountRowToInfo, ) } // byName retrieves an account by wallet ID, scope, and account name. func (s sqliteAccountGetQueries) byName(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { - return GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, + return db.GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), @@ -361,16 +364,16 @@ type sqliteAccountRenameQueries struct { // byNumber renames an account identified by wallet ID, scope, and account // number. func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { - return RenameAccount( + return db.RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), CoinType: int64(params.Scope.Coin), - AccountNumber: NullableUint32ToSQLInt64(params.AccountNumber), + AccountNumber: db.NullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } @@ -378,9 +381,9 @@ func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, // byName renames an account identified by wallet ID, scope, and old account // name. func (s sqliteAccountRenameQueries) byName(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { - return RenameAccount( + return db.RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndName, sqlcsqlite.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, diff --git a/wallet/internal/db/address_types_sqlite.go b/wallet/internal/db/sqlite/address_types.go similarity index 68% rename from wallet/internal/db/address_types_sqlite.go rename to wallet/internal/db/sqlite/address_types.go index 28aaaf0433..d95b24593b 100644 --- a/wallet/internal/db/address_types_sqlite.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -1,22 +1,23 @@ -package db +package sqlite import ( "context" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) // sqliteAddressTypeRowToInfo converts a SQLite address type row to an // AddressTypeInfo struct. -func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, +func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, error) { - addrType, err := IDToAddressType(row.ID) + addrType, err := db.IDToAddressType(row.ID) if err != nil { - return AddressTypeInfo{}, err + return db.AddressTypeInfo{}, err } - return AddressTypeInfo{ + return db.AddressTypeInfo{ Type: addrType, Description: row.Description, }, nil @@ -25,9 +26,9 @@ func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (AddressTypeInfo, // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( - []AddressTypeInfo, error) { + []db.AddressTypeInfo, error) { - return ListAddressTypes( + return db.ListAddressTypes( ctx, s.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, ) } @@ -35,9 +36,9 @@ func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. func (s *SqliteStore) GetAddressType(ctx context.Context, - id AddressType) (AddressTypeInfo, error) { + id db.AddressType) (db.AddressTypeInfo, error) { - return GetAddressTypeByID( + return db.GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int64(id), id, sqliteAddressTypeRowToInfo, ) diff --git a/wallet/internal/db/addresses_sqlite.go b/wallet/internal/db/sqlite/addresses.go similarity index 79% rename from wallet/internal/db/addresses_sqlite.go rename to wallet/internal/db/sqlite/addresses.go index 5645297197..a99357e37d 100644 --- a/wallet/internal/db/addresses_sqlite.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -1,4 +1,4 @@ -package db +package sqlite import ( "context" @@ -7,21 +7,22 @@ import ( "iter" "time" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) -var _ AddressStore = (*SqliteStore)(nil) +var _ db.AddressStore = (*SqliteStore)(nil) // GetAddress retrieves information about a specific address, identified by // its script pubkey. func (s *SqliteStore) GetAddress(ctx context.Context, - query GetAddressQuery) (*AddressInfo, error) { + query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, - error) { + getByScript := func(ctx context.Context, + q db.GetAddressQuery) (*db.AddressInfo, error) { - return GetAddress( + return db.GetAddress( ctx, s.queries.GetAddressByScriptPubKey, sqlcsqlite.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), @@ -30,21 +31,21 @@ func (s *SqliteStore) GetAddress(ctx context.Context, ) } - return GetAddressByQuery(ctx, query, getByScript) + return db.GetAddressByQuery(ctx, query, getByScript) } // ListAddresses returns a page of addresses matching the given query. func (s *SqliteStore) ListAddresses(ctx context.Context, - query ListAddressesQuery) (page.Result[AddressInfo, uint32], error) { + query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { items, err := sqliteListAddressesByAccount(ctx, s.queries, query) if err != nil { - return page.Result[AddressInfo, uint32]{}, err + return page.Result[db.AddressInfo, uint32]{}, err } result := page.BuildResult( query.Page, items, - func(item AddressInfo) uint32 { + func(item db.AddressInfo) uint32 { return item.ID }, ) @@ -54,18 +55,18 @@ func (s *SqliteStore) ListAddresses(ctx context.Context, // IterAddresses returns an iterator over paginated address results. func (s *SqliteStore) IterAddresses(ctx context.Context, - query ListAddressesQuery) iter.Seq2[AddressInfo, error] { + query db.ListAddressesQuery) iter.Seq2[db.AddressInfo, error] { return page.Iter( - ctx, query, s.ListAddresses, NextListAddressesQuery, + ctx, query, s.ListAddresses, db.NextListAddressesQuery, ) } // GetAddressSecret retrieves the encrypted secret information for an address. func (s *SqliteStore) GetAddressSecret(ctx context.Context, - addressID uint32) (*AddressSecret, error) { + addressID uint32) (*db.AddressSecret, error) { - return GetAddressSecret( + return db.GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, sqliteAddressSecretRowToSecret, ) @@ -74,16 +75,16 @@ func (s *SqliteStore) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. func (s *SqliteStore) NewDerivedAddress(ctx context.Context, - params NewDerivedAddressParams, - deriveFn AddressDerivationFunc) (*AddressInfo, error) { + params db.NewDerivedAddressParams, + deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { - adapters := DerivedAddressAdapters[ + adapters := db.DerivedAddressAdapters[ *sqlcsqlite.Queries, sqlcsqlite.GetAccountByWalletScopeAndNameRow, - AccountLookupKey, + db.AccountLookupKey, sqlcsqlite.CreateDerivedAddressRow]{ GetAccount: sqliteGetAccountFromKey(s.queries), - AccountParams: AccountKeyFromParams, + AccountParams: db.AccountKeyFromParams, GetAccountID: newDerivedAddressGetAccountIDSQLite, GetExtIndex: newDerivedAddressGetExtIndexSQLite, GetIntIndex: newDerivedAddressGetIntIndexSQLite, @@ -92,22 +93,24 @@ func (s *SqliteStore) NewDerivedAddress(ctx context.Context, RowCreatedAt: newDerivedAddressRowCreatedAtSQLite, } - return NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return db.NewDerivedAddressWithTx( + ctx, params, s.ExecuteTx, adapters, deriveFn, + ) } // NewImportedAddress imports a new address, script, or private key. func (s *SqliteStore) NewImportedAddress(ctx context.Context, - params NewImportedAddressParams) (*AddressInfo, error) { + params db.NewImportedAddressParams) (*db.AddressInfo, error) { - adapters := ImportedAddressAdapters[ + adapters := db.ImportedAddressAdapters[ *sqlcsqlite.Queries, sqlcsqlite.GetAccountByWalletScopeAndNameRow, - AccountLookupKey, + db.AccountLookupKey, sqlcsqlite.CreateImportedAddressParams, sqlcsqlite.CreateImportedAddressRow, sqlcsqlite.InsertAddressSecretParams]{ GetAccount: sqliteGetAccountFromKey(s.queries), - AccountParams: AccountKeyFromImportedParams, + AccountParams: db.AccountKeyFromImportedParams, GetAccountID: newImportedAddressGetAccountIDSQLite, CreateAddr: sqliteCreateImportedAddress, CreateParams: createImportedAddressParamsSQLite, @@ -117,15 +120,15 @@ func (s *SqliteStore) NewImportedAddress(ctx context.Context, RowCreatedAt: importedAddressRowCreatedAtSQLite, } - return NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // sqliteGetAccountFromKey returns a helper to look up accounts by key. func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, - AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { + db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, + key db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( @@ -162,10 +165,12 @@ func newDerivedAddressGetIntIndexSQLite( // newDerivedAddressCreateAddrSQLite returns the derived address insert helper. func newDerivedAddressCreateAddrSQLite( - qtx *sqlcsqlite.Queries) func(context.Context, int64, AddressType, uint32, - uint32, []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { + qtx *sqlcsqlite.Queries, +) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) ( + sqlcsqlite.CreateDerivedAddressRow, error, +) { - return func(ctx context.Context, accountID int64, addrType AddressType, + return func(ctx context.Context, accountID int64, addrType db.AddressType, branch uint32, index uint32, scriptPubKey []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { @@ -226,7 +231,7 @@ func sqliteInsertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, // createImportedAddressParamsSQLite maps imported params to sqlc params. func createImportedAddressParamsSQLite(accountID int64, - params NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { + params db.NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { return sqlcsqlite.CreateImportedAddressParams{ AccountID: accountID, @@ -250,7 +255,7 @@ func importedAddressRowCreatedAtSQLite( // insertAddressSecretParamsSQLite maps imported params to secret params. func insertAddressSecretParamsSQLite(addressID int64, - params NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { + params db.NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { return sqlcsqlite.InsertAddressSecretParams{ AddressID: addressID, @@ -262,9 +267,9 @@ func insertAddressSecretParamsSQLite(addressID int64, // sqliteAddressSecretRowToSecret converts a SQLite address secret row to an // AddressSecret struct. func sqliteAddressSecretRowToSecret( - row sqlcsqlite.GetAddressSecretRow) (*AddressSecret, error) { + row sqlcsqlite.GetAddressSecretRow) (*db.AddressSecret, error) { - return AddressSecretRowToSecret(AddressSecretRow{ + return db.AddressSecretRowToSecret(db.AddressSecretRow{ AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, @@ -281,13 +286,13 @@ type sqliteAddressInfoRow interface { // sqliteAddressRowToInfo converts a SQLite address row to an AddressInfo // struct. -func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, +func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcsqlite.GetAddressByScriptPubKeyRow(row) - info, err := addressRowToInfo(AddressInfoRow[int64, int64]{ + info, err := db.AddressRowToInfo(db.AddressInfoRow[int64, int64]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, @@ -299,8 +304,8 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, - IDToAddrType: IDToAddressType[int64], - IDToOrigin: IDToOrigin[int64], + IDToAddrType: db.IDToAddressType[int64], + IDToOrigin: db.IDToOrigin[int64], }) if err != nil { return nil, err @@ -312,7 +317,7 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*AddressInfo, // sqliteListAddressesByAccount lists addresses filtered by wallet ID, key // scope, and account name, with pagination support. func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, - query ListAddressesQuery) ([]AddressInfo, error) { + query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( ctx, sqliteBuildAddressPageParams(query), @@ -321,7 +326,7 @@ func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, return nil, fmt.Errorf("list addresses by account: %w", err) } - items := make([]AddressInfo, len(rows)) + items := make([]db.AddressInfo, len(rows)) for i, row := range rows { item, err := sqliteAddressRowToInfo(row) if err != nil { @@ -339,7 +344,7 @@ func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, // sqliteBuildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. func sqliteBuildAddressPageParams( - q ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { + q db.ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { params := sqlcsqlite.ListAddressesByAccountParams{ WalletID: int64(q.WalletID), diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go new file mode 100644 index 0000000000..dc7b34839c --- /dev/null +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -0,0 +1,105 @@ +package sqlite + +import ( + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +// TestDeleteAndRollbackOpsWrapBackendErrors verifies sqlite delete and rollback +// error wrapping. +func TestDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + deleteOps := sqliteDeleteTxOps{qtx: qtx} + rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + + err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxo rows") + + err = deleteOps.DeleteCreatedUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "delete created utxo rows") + + _, err = deleteOps.DeleteUnminedTransaction( + t.Context(), 1, chainhash.Hash{1}, + ) + require.ErrorContains(t, err, "delete unmined tx row") + + _, err = rollbackOps.ListUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = rollbackOps.ClearDescendantSpends(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear descendant spends") + + err = rollbackOps.MarkDescendantsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark descendants failed") +} + +// TestTxStoreOpsWrapBackendErrors verifies sqlite helper error wrapping. +func TestTxStoreOpsWrapBackendErrors(t *testing.T) { + t.Parallel() + + qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + createOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{qtx: qtx}, + } + invalidateOps := sqliteInvalidateUnminedTxOps{qtx: qtx} + rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + updateOps := &sqliteUpdateTxOps{qtx: qtx} + releaseOps := sqliteReleaseOutputOps{qtx: qtx} + + err := createOps.MarkTxnsReplaced(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark txns replaced") + + err = createOps.InsertReplacementEdges(t.Context(), 1, []int64{2}, 3) + require.ErrorContains(t, err, "insert replacement edge") + + err = markInputsSpentSqlite(t.Context(), qtx, db.CreateTxParams{ + WalletID: 1, + Tx: testRegularMsgTx(), + Received: time.Unix(1, 0), + Status: db.TxStatusPending, + }, 7) + require.ErrorContains(t, err, "mark spent input 0") + + _, err = invalidateOps.ListUnminedTxRecords(t.Context(), 1) + require.ErrorContains(t, err, "list unmined txns") + + err = invalidateOps.ClearSpentUtxos(t.Context(), 1, 2) + require.ErrorContains(t, err, "clear spent utxos") + + err = invalidateOps.MarkTxnsFailed(t.Context(), 1, []int64{2}) + require.ErrorContains(t, err, "mark txns failed") + + _, err = rollbackOps.ListRollbackRootHashes(t.Context(), 1) + require.ErrorContains(t, err, "query rollback coinbase roots") + + err = rollbackOps.RewindWalletSyncStateHeights(t.Context(), 1) + require.ErrorContains(t, err, "rewind wallet sync state heights query") + + err = rollbackOps.DeleteBlocksAtOrAboveHeight(t.Context(), 1) + require.ErrorContains(t, err, "delete blocks at or above height query") + + err = rollbackOps.MarkTxRootsOrphaned(t.Context(), 1, []chainhash.Hash{{1}}) + require.ErrorContains(t, err, "update rollback coinbase state query") + + updateOps.blockHeight = sql.NullInt64{} + updateOps.status = int64(db.TxStatusPublished) + err = updateOps.UpdateState( + t.Context(), 1, chainhash.Hash{1}, + db.UpdateTxState{Status: db.TxStatusPublished}, + ) + require.ErrorContains(t, err, "update tx state query") + + err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") + require.ErrorContains(t, err, "update tx label query") + + _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) + require.ErrorContains(t, err, "release lease row") +} diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go new file mode 100644 index 0000000000..59afe85d32 --- /dev/null +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -0,0 +1,122 @@ +package sqlite + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + "github.com/stretchr/testify/require" +) + +// TestCreateTxOpsAdditionalBranches covers remaining sqlite CreateTx branches. +func TestCreateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + req := testCreateTxRequest(t) + ctx := context.Background() + loadOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + }), + }, + } + + _, err := loadOps.LoadExisting(ctx, req) + require.ErrorContains(t, err, "get tx metadata") + + block := testBlock(8) + confirmOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow( + t, + "SELECT ?, ?, ?", + int64(block.Height), + block.Hash[:], + block.Timestamp.Unix(), + ), + rows: 0, + }), + }, + } + err = confirmOps.ConfirmExisting(ctx, db.CreateTxRequest{ + Params: db.CreateTxParams{WalletID: 1, Block: block}, + TxHash: chainhash.Hash{9}, + }, db.CreateTxExistingTarget{}) + require.ErrorIs(t, err, db.ErrTxNotFound) + + prepareOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + }), + }, + } + err = prepareOps.PrepareBlock(ctx, db.CreateTxRequest{ + Params: db.CreateTxParams{WalletID: 1, Block: block}, + }) + require.ErrorContains(t, err, "get block by height") + + conflictOps := &sqliteCreateTxOps{ + sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT ?", int64(5)), + queryErr: errDummy, + }), + }, + } + _, _, err = conflictOps.ListConflictTxns(ctx, req) + require.ErrorContains(t, err, "list unmined txns") +} + +// TestReleaseOutputOpsAdditionalBranches covers remaining sqlite Release paths. +func TestReleaseOutputOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + })} + + _, err := ops.LookupUtxoID(context.Background(), db.ReleaseOutputParams{ + WalletID: 1, + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, + }) + require.ErrorContains(t, err, "lookup utxo row") + + _, err = ops.ActiveLockID(context.Background(), 1, 2, time.Now()) + require.ErrorContains(t, err, "lookup active lease row") +} + +// TestUpdateTxOpsAdditionalBranches covers remaining sqlite UpdateTx branches. +func TestUpdateTxOpsAdditionalBranches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + txHash := chainhash.Hash{9} + loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + })} + stateOps := &sqliteUpdateTxOps{ + qtx: sqlcsqlite.New(rowDBTX{rows: 0}), + blockHeight: sql.NullInt64{}, + status: int64(db.TxStatusPublished), + } + labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} + + _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) + require.ErrorContains(t, err, "get tx metadata") + + err = stateOps.UpdateState( + ctx, 1, txHash, db.UpdateTxState{Status: db.TxStatusPublished}, + ) + require.ErrorIs(t, err, db.ErrTxNotFound) + + err = labelOps.UpdateLabel(ctx, 1, txHash, "note") + require.ErrorIs(t, err, db.ErrTxNotFound) +} diff --git a/wallet/internal/db/block_sqlite.go b/wallet/internal/db/sqlite/block.go similarity index 81% rename from wallet/internal/db/block_sqlite.go rename to wallet/internal/db/sqlite/block.go index 39bbf9916c..abf6c938b3 100644 --- a/wallet/internal/db/block_sqlite.go +++ b/wallet/internal/db/sqlite/block.go @@ -1,4 +1,4 @@ -package db +package sqlite import ( "bytes" @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -13,20 +14,20 @@ import ( // buildSqliteBlock constructs a Block from the given SQLite block // fields. func buildSqliteBlock(height sql.NullInt64, hash []byte, - timestamp sql.NullInt64) (*Block, error) { + timestamp sql.NullInt64) (*db.Block, error) { - height32, err := Int64ToUint32(height.Int64) + height32, err := db.Int64ToUint32(height.Int64) if err != nil { return nil, fmt.Errorf("block height: %w", err) } - return BuildBlock(hash, height32, timestamp.Int64) + return db.BuildBlock(hash, height32, timestamp.Int64) } // ensureBlockExistsSqlite ensures that a block exists in the database. If it // doesn't exist, it inserts it. func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, - block *Block) error { + block *db.Block) error { height := int64(block.Height) @@ -47,7 +48,7 @@ func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, // requireBlockMatchesSqlite loads the shared block row for the provided height // and verifies that its stored metadata matches the supplied block reference. func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, - block *Block) (int64, error) { + block *db.Block) (int64, error) { height := int64(block.Height) @@ -55,7 +56,7 @@ func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, fmt.Errorf("block %d: %w", block.Height, - ErrBlockNotFound) + db.ErrBlockNotFound) } return 0, fmt.Errorf("get block by height: %w", err) @@ -63,12 +64,12 @@ func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, if !bytes.Equal(storedBlock.HeaderHash, block.Hash[:]) { return 0, fmt.Errorf("block %d header hash: %w", block.Height, - ErrBlockMismatch) + db.ErrBlockMismatch) } if storedBlock.BlockTimestamp != block.Timestamp.Unix() { return 0, fmt.Errorf("block %d timestamp: %w", block.Height, - ErrBlockMismatch) + db.ErrBlockMismatch) } return height, nil diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go new file mode 100644 index 0000000000..f5204d8dc4 --- /dev/null +++ b/wallet/internal/db/sqlite/itest.go @@ -0,0 +1,19 @@ +//go:build itest + +package sqlite + +import ( + "database/sql" + + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" +) + +// DB returns the underlying *sql.DB connection for integration testing. +func (s *SqliteStore) DB() *sql.DB { + return s.db +} + +// Queries returns the underlying sqlc queries for integration testing. +func (s *SqliteStore) Queries() *sqlcsqlite.Queries { + return s.queries +} diff --git a/wallet/internal/db/sqlite.go b/wallet/internal/db/sqlite/store.go similarity index 73% rename from wallet/internal/db/sqlite.go rename to wallet/internal/db/sqlite/store.go index e12ec6dc13..a06ab5b188 100644 --- a/wallet/internal/db/sqlite.go +++ b/wallet/internal/db/sqlite/store.go @@ -1,15 +1,18 @@ -package db +package sqlite import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) // SqliteStore is the SQLite implementation of the WalletStore interface. + type SqliteStore struct { db *sql.DB queries *sqlcsqlite.Queries @@ -19,7 +22,7 @@ type SqliteStore struct { // connection setup including DSN construction with pragmas, connection // opening, health checks, connection pool configuration, and migration // application. -func NewSqliteStore(ctx context.Context, cfg SqliteConfig) (*SqliteStore, +func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, error) { err := cfg.Validate() @@ -33,39 +36,39 @@ func NewSqliteStore(ctx context.Context, cfg SqliteConfig) (*SqliteStore, dsn += "&_pragma=busy_timeout=5000" dsn += "&_time_format=sqlite" - db, err := sql.Open("sqlite", dsn) + dbConn, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } - connCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout) + connCtx, cancel := context.WithTimeout(ctx, db.DefaultConnectionTimeout) defer cancel() - err = db.PingContext(connCtx) + err = dbConn.PingContext(connCtx) if err != nil { - _ = db.Close() + _ = dbConn.Close() return nil, fmt.Errorf("ping database: %w", err) } - maxConns := DefaultMaxConnections + maxConns := db.DefaultMaxConnections if cfg.MaxConnections > 0 { maxConns = cfg.MaxConnections } - db.SetMaxOpenConns(maxConns) - db.SetMaxIdleConns(maxConns) - db.SetConnMaxIdleTime(DefaultConnIdleLifetime) + dbConn.SetMaxOpenConns(maxConns) + dbConn.SetMaxIdleConns(maxConns) + dbConn.SetConnMaxIdleTime(db.DefaultConnIdleLifetime) - queries := sqlcsqlite.New(db) + queries := sqlcsqlite.New(dbConn) - err = ApplySQLiteMigrations(db) + err = db.ApplySQLiteMigrations(dbConn) if err != nil { - _ = db.Close() + _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) } return &SqliteStore{ - db: db, + db: dbConn, queries: queries, }, nil } @@ -87,7 +90,7 @@ func (s *SqliteStore) Close() error { func (s *SqliteStore) ExecuteTx(ctx context.Context, fn func(*sqlcsqlite.Queries) error) error { - return ExecInTx(ctx, s.db, func(tx *sql.Tx) error { + return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) return fn(qtx) }) diff --git a/wallet/internal/db/sqlite/testhelpers_test.go b/wallet/internal/db/sqlite/testhelpers_test.go new file mode 100644 index 0000000000..a3526039db --- /dev/null +++ b/wallet/internal/db/sqlite/testhelpers_test.go @@ -0,0 +1,163 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +var errDummy = errors.New("dummy") + +// errorDBTX forces sqlc exec/query calls down their wrapped error paths. +type errorDBTX struct { + execErr error + queryErr error +} + +// ExecContext implements the sqlc DBTX interface. +func (e errorDBTX) ExecContext(context.Context, string, + ...any) (sql.Result, error) { + + return nil, e.execErr +} + +// PrepareContext implements the sqlc DBTX interface. +func (e errorDBTX) PrepareContext(context.Context, + string) (*sql.Stmt, error) { + + return nil, errDummy +} + +// QueryContext implements the sqlc DBTX interface. +func (e errorDBTX) QueryContext(context.Context, string, + ...any) (*sql.Rows, error) { + + return nil, e.queryErr +} + +// QueryRowContext implements the sqlc DBTX interface. +func (e errorDBTX) QueryRowContext(context.Context, string, + ...any) *sql.Row { + + return &sql.Row{} +} + +// staticResult is a minimal sql.Result stub with a caller-controlled row count. +type staticResult struct { + rows int64 +} + +// LastInsertId implements sql.Result. +func (r staticResult) LastInsertId() (int64, error) { + return 0, nil +} + +// RowsAffected implements sql.Result. +func (r staticResult) RowsAffected() (int64, error) { + return r.rows, nil +} + +// rowDBTX is a sqlc DBTX stub that mixes fixed exec counts with query-row +// scan failures from a temporary sqlite handle. +type rowDBTX struct { + row *sql.Row + queryErr error + execErr error + rows int64 +} + +// ExecContext implements the sqlc DBTX interface. +func (r rowDBTX) ExecContext(context.Context, string, + ...any) (sql.Result, error) { + + if r.execErr != nil { + return nil, r.execErr + } + + return staticResult{rows: r.rows}, nil +} + +// PrepareContext implements the sqlc DBTX interface. +func (r rowDBTX) PrepareContext(context.Context, string) (*sql.Stmt, error) { + return nil, errDummy +} + +// QueryContext implements the sqlc DBTX interface. +func (r rowDBTX) QueryContext(context.Context, string, + ...any) (*sql.Rows, error) { + + return nil, r.queryErr +} + +// QueryRowContext implements the sqlc DBTX interface. +func (r rowDBTX) QueryRowContext(context.Context, string, + ...any) *sql.Row { + + if r.row != nil { + return r.row + } + + return &sql.Row{} +} + +// newSQLiteRow creates a query row backed by an in-memory sqlite database. +func newSQLiteRow(t *testing.T, query string, args ...any) *sql.Row { + t.Helper() + + dbConn, err := sql.Open("sqlite", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { + _ = dbConn.Close() + }) + + return dbConn.QueryRowContext(t.Context(), query, args...) +} + +// testBlock builds one deterministic test block. +func testBlock(height uint32) *db.Block { + return &db.Block{ + Hash: chainhash.Hash{byte(height), 1, 2, 3}, + Height: height, + Timestamp: time.Unix(int64(height), 0), + } +} + +// testRegularMsgTx builds one simple non-coinbase transaction fixture. +func testRegularMsgTx() *wire.MsgTx { + return &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1, 2, 3}, + Index: 0, + }, + }}, + TxOut: []*wire.TxOut{{ + Value: int64(btcutil.SatoshiPerBitcoin), + PkScript: []byte{0x51}, + }}, + } +} + +// testCreateTxRequest builds one prepared CreateTx request for sqlite tests. +func testCreateTxRequest(t *testing.T) db.CreateTxRequest { + t.Helper() + + req, err := db.NewCreateTxRequest(db.CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + return req +} diff --git a/wallet/internal/db/sqlite_txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go similarity index 90% rename from wallet/internal/db/sqlite_txstore_createtx.go rename to wallet/internal/db/sqlite/txstore_createtx.go index af0ad7ade8..8336fb5af7 100644 --- a/wallet/internal/db/sqlite_txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -21,15 +22,15 @@ import ( // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. func (s *SqliteStore) CreateTx(ctx context.Context, - params CreateTxParams) error { + params db.CreateTxParams) error { - req, err := NewCreateTxRequest(params) + req, err := db.NewCreateTxRequest(params) if err != nil { return err } return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return CreateTxWithOps(ctx, req, &sqliteCreateTxOps{ + return db.CreateTxWithOps(ctx, req, &sqliteCreateTxOps{ sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ qtx: qtx, }, @@ -44,11 +45,11 @@ type sqliteCreateTxOps struct { blockHeight sql.NullInt64 } -var _ CreateTxOps = (*sqliteCreateTxOps)(nil) +var _ db.CreateTxOps = (*sqliteCreateTxOps)(nil) // LoadExisting loads any existing wallet-scoped row for the requested tx hash. func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, - req CreateTxRequest) (*CreateTxExistingTarget, error) { + req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( ctx, @@ -59,18 +60,18 @@ func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, ErrCreateTxExistingNotFound + return nil, db.ErrCreateTxExistingNotFound } return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(meta.TxStatus) + status, err := db.ParseTxStatus(meta.TxStatus) if err != nil { return nil, err } - return &CreateTxExistingTarget{ + return &db.CreateTxExistingTarget{ ID: meta.ID, Status: status, HasBlock: meta.BlockHeight.Valid, @@ -80,8 +81,8 @@ func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, // ConfirmExisting promotes one existing unmined row to its confirmed state. func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, - req CreateTxRequest, - _ CreateTxExistingTarget) error { + req db.CreateTxRequest, + _ db.CreateTxExistingTarget) error { blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) if err != nil { @@ -91,7 +92,7 @@ func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{Int64: blockHeight, Valid: true}, - Status: int64(TxStatusPublished), + Status: int64(db.TxStatusPublished), WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -101,7 +102,7 @@ func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", req.TxHash, db.ErrTxNotFound) } return nil @@ -110,7 +111,7 @@ func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, // PrepareBlock validates the optional confirming block and caches the sqlite // block-height value that the later Insert query will store. func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, - req CreateTxRequest) error { + req db.CreateTxRequest) error { o.blockHeight = sql.NullInt64{} @@ -131,7 +132,7 @@ func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, // ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, - req CreateTxRequest) ([]int64, []chainhash.Hash, error) { + req db.CreateTxRequest) ([]int64, []chainhash.Hash, error) { rootIDs, err := collectSqliteConflictRootIDs(ctx, o.qtx, req) if err != nil { @@ -154,7 +155,7 @@ func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, // IDs that currently own any wallet-controlled input spent by the incoming tx. func collectSqliteConflictRootIDs(ctx context.Context, qtx *sqlcsqlite.Queries, - req CreateTxRequest) (map[int64]struct{}, error) { + req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { return map[int64]struct{}{}, nil @@ -216,7 +217,7 @@ func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, // Insert stores one new sqlite transaction row for CreateTx. func (o *sqliteCreateTxOps) Insert(ctx context.Context, - req CreateTxRequest) (int64, error) { + req db.CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction( ctx, @@ -232,7 +233,7 @@ func (o *sqliteCreateTxOps) Insert(ctx context.Context, }, ) if err != nil { - return 0, fmt.Errorf("Insert tx row: %w", err) + return 0, fmt.Errorf("insert tx row: %w", err) } return txID, nil @@ -240,14 +241,14 @@ func (o *sqliteCreateTxOps) Insert(ctx context.Context, // InsertCredits stores any wallet-owned outputs created by the transaction. func (o *sqliteCreateTxOps) InsertCredits(ctx context.Context, - req CreateTxRequest, txID int64) error { + req db.CreateTxRequest, txID int64) error { return insertCreditsSqlite(ctx, o.qtx, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. func (o *sqliteCreateTxOps) MarkInputsSpent(ctx context.Context, - req CreateTxRequest, txID int64) error { + req db.CreateTxRequest, txID int64) error { return markInputsSpentSqlite(ctx, o.qtx, req.Params, txID) } @@ -260,7 +261,7 @@ func (o *sqliteCreateTxOps) MarkTxnsReplaced( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int64(TxStatusReplaced), + Status: int64(db.TxStatusReplaced), TxIds: txIDs, }, ) @@ -286,7 +287,7 @@ func (o *sqliteCreateTxOps) InsertReplacementEdges( }, ) if err != nil { - return fmt.Errorf("Insert replacement edge for %d: %w", + return fmt.Errorf("insert replacement edge for %d: %w", replacedTxID, err) } } @@ -297,7 +298,7 @@ func (o *sqliteCreateTxOps) InsertReplacementEdges( // insertCreditsSqlite inserts one wallet-owned UTXO row for each credited // output of the transaction being stored. func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, - params CreateTxParams, txID int64) error { + params db.CreateTxParams, txID int64) error { for index := range params.Credits { creditExists, err := creditExistsSqlite( @@ -322,7 +323,7 @@ func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("credit output %d: %w", index, - ErrAddressNotFound) + db.ErrAddressNotFound) } return fmt.Errorf("resolve credit address %d: %w", index, err) @@ -336,7 +337,7 @@ func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, AddressID: addrRow.ID, }) if err != nil { - return fmt.Errorf("Insert credit output %d: %w", index, err) + return fmt.Errorf("insert credit output %d: %w", index, err) } } @@ -376,7 +377,7 @@ func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, - params CreateTxParams, txID int64) error { + params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { return nil @@ -437,7 +438,7 @@ func ensureSpendConflictSqlite(ctx context.Context, } if spendByTxID.Valid && spendByTxID.Int64 != txID { - return ErrTxInputConflict + return db.ErrTxInputConflict } return nil @@ -462,7 +463,7 @@ func ensureWalletParentValidSqlite(ctx context.Context, } if hasInvalid { - return ErrTxInputInvalidParent + return db.ErrTxInputInvalidParent } return nil diff --git a/wallet/internal/db/sqlite_txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go similarity index 89% rename from wallet/internal/db/sqlite_txstore_deletetx.go rename to wallet/internal/db/sqlite/txstore_deletetx.go index 691e57865f..848ea65d52 100644 --- a/wallet/internal/db/sqlite_txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -18,10 +19,10 @@ import ( // transaction must also be a leaf among the wallet's unmined transactions so // the delete cannot detach child spenders from their parent history. func (s *SqliteStore) DeleteTx(ctx context.Context, - params DeleteTxParams) error { + params db.DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return DeleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) + return db.DeleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) }) } @@ -30,7 +31,7 @@ type sqliteDeleteTxOps struct { qtx *sqlcsqlite.Queries } -var _ DeleteTxOps = (*sqliteDeleteTxOps)(nil) +var _ db.DeleteTxOps = (*sqliteDeleteTxOps)(nil) // LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. @@ -121,7 +122,7 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return fmt.Errorf("list unmined txns: %w", err) } - candidates, err := BuildUnminedTxRecords( + candidates, err := db.BuildUnminedTxRecords( rows, func(row sqlcsqlite.ListUnminedTransactionsRow) (int64, []byte, []byte) { @@ -142,9 +143,9 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, filtered = append(filtered, candidate) } - if len(CollectDirectChildTxIDs(txHash, filtered)) > 0 { + if len(db.CollectDirectChildTxIDs(txHash, filtered)) > 0 { return fmt.Errorf("delete tx %s: %w", txHash, - ErrDeleteRequiresLeaf) + db.ErrDeleteRequiresLeaf) } return nil @@ -165,22 +166,22 @@ func getDeleteTxMetaSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return sqlcsqlite.GetTransactionMetaByHashRow{}, - fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return sqlcsqlite.GetTransactionMetaByHashRow{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(meta.TxStatus) + status, err := db.ParseTxStatus(meta.TxStatus) if err != nil { return sqlcsqlite.GetTransactionMetaByHashRow{}, err } - if meta.BlockHeight.Valid || !IsUnminedStatus(status) { + if meta.BlockHeight.Valid || !db.IsUnminedStatus(status) { return sqlcsqlite.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, - ErrDeleteRequiresUnmined) + db.ErrDeleteRequiresUnmined) } return meta, nil diff --git a/wallet/internal/db/sqlite_txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go similarity index 81% rename from wallet/internal/db/sqlite_txstore_gettx.go rename to wallet/internal/db/sqlite/txstore_gettx.go index 63c3382248..b90fde980a 100644 --- a/wallet/internal/db/sqlite_txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -15,7 +16,7 @@ import ( // The returned TxInfo is rebuilt from normalized SQL columns; missing rows map // to ErrTxNotFound for the requested wallet/hash pair. func (s *SqliteStore) GetTx(ctx context.Context, - query GetTxQuery) (*TxInfo, error) { + query db.GetTxQuery) (*db.TxInfo, error) { row, err := s.queries.GetTransactionByHash( ctx, sqlcsqlite.GetTransactionByHashParams{ @@ -25,7 +26,7 @@ func (s *SqliteStore) GetTx(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("tx %s: %w", query.Txid, ErrTxNotFound) + return nil, fmt.Errorf("tx %s: %w", query.Txid, db.ErrTxNotFound) } return nil, fmt.Errorf("get tx: %w", err) @@ -41,10 +42,10 @@ func (s *SqliteStore) GetTx(ctx context.Context, // TxInfo shape. func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, blockHeight sql.NullInt64, blockHash []byte, blockTimestamp sql.NullInt64, - status int64, label string) (*TxInfo, error) { + status int64, label string) (*db.TxInfo, error) { var ( - block *Block + block *db.Block err error ) @@ -57,5 +58,5 @@ func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, } } - return BuildTxInfo(hash, rawTx, received, block, status, label) + return db.BuildTxInfo(hash, rawTx, received, block, status, label) } diff --git a/wallet/internal/db/sqlite_txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go similarity index 77% rename from wallet/internal/db/sqlite_txstore_invalidateunmined.go rename to wallet/internal/db/sqlite/txstore_invalidateunmined.go index 9d73c11c8e..c8f0522a5d 100644 --- a/wallet/internal/db/sqlite_txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -13,10 +14,10 @@ import ( // InvalidateUnminedTx atomically invalidates one wallet-owned unmined // transaction branch and marks the root plus descendants failed. func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, - params InvalidateUnminedTxParams) error { + params db.InvalidateUnminedTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return InvalidateUnminedTxWithOps( + return db.InvalidateUnminedTxWithOps( ctx, params, sqliteInvalidateUnminedTxOps{qtx: qtx}, ) }) @@ -28,12 +29,13 @@ type sqliteInvalidateUnminedTxOps struct { qtx *sqlcsqlite.Queries } -var _ InvalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) +var _ db.InvalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { + walletID uint32, + txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcsqlite.GetTransactionMetaByHashParams{ @@ -43,20 +45,21 @@ func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, - ErrTxNotFound) + return db.InvalidateUnminedTxTarget{}, fmt.Errorf( + "tx %s: %w", txHash, db.ErrTxNotFound, + ) } - return InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + return db.InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(row.TxStatus) + status, err := db.ParseTxStatus(row.TxStatus) if err != nil { - return InvalidateUnminedTxTarget{}, err + return db.InvalidateUnminedTxTarget{}, err } - return InvalidateUnminedTxTarget{ + return db.InvalidateUnminedTxTarget{ ID: row.ID, TxHash: txHash, Status: status, @@ -68,14 +71,14 @@ func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, // ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. func (o sqliteInvalidateUnminedTxOps) ListUnminedTxRecords( - ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { + ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return BuildUnminedTxRecords( + return db.BuildUnminedTxRecords( rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( int64, []byte, []byte) { @@ -113,7 +116,7 @@ func (o sqliteInvalidateUnminedTxOps) MarkTxnsFailed( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int64(TxStatusFailed), + Status: int64(db.TxStatusFailed), TxIds: txIDs, }, ) diff --git a/wallet/internal/db/sqlite_txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go similarity index 87% rename from wallet/internal/db/sqlite_txstore_listtxns.go rename to wallet/internal/db/sqlite/txstore_listtxns.go index 0a92e1ac66..c332406f39 100644 --- a/wallet/internal/db/sqlite_txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -1,9 +1,10 @@ -package db +package sqlite import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -15,7 +16,7 @@ import ( // including retained invalid history such as orphaned or failed transactions, // while the confirmed path is bounded by the requested height range. func (s *SqliteStore) ListTxns(ctx context.Context, - query ListTxnsQuery) ([]TxInfo, error) { + query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { return s.listTxnsWithoutBlockSqlite(ctx, query.WalletID) @@ -29,14 +30,14 @@ func (s *SqliteStore) ListTxns(ctx context.Context, // retained invalid history that rollback or invalidation flows left without a // confirming block. func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, - walletID uint32) ([]TxInfo, error) { + walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) if err != nil { return nil, fmt.Errorf("list txns without block: %w", err) } - infos := make([]TxInfo, len(rows)) + infos := make([]db.TxInfo, len(rows)) for i, row := range rows { info, err := txInfoFromSqliteRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, @@ -55,7 +56,7 @@ func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, // listConfirmedTxnsSqlite loads the confirmed height-range view used by // ListTxns when callers query mined history. func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, - query ListTxnsQuery) ([]TxInfo, error) { + query db.ListTxnsQuery) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsByHeightRange( ctx, sqlcsqlite.ListTransactionsByHeightRangeParams{ @@ -68,7 +69,7 @@ func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, return nil, fmt.Errorf("list txns by height: %w", err) } - infos := make([]TxInfo, len(rows)) + infos := make([]db.TxInfo, len(rows)) for i, row := range rows { block, err := buildSqliteBlock( row.BlockHeight, @@ -79,7 +80,7 @@ func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, return nil, err } - info, err := BuildTxInfo( + info, err := db.BuildTxInfo( row.TxHash, row.RawTx, row.ReceivedTime, block, row.TxStatus, row.TxLabel, ) diff --git a/wallet/internal/db/sqlite_txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go similarity index 91% rename from wallet/internal/db/sqlite_txstore_rollback.go rename to wallet/internal/db/sqlite/txstore_rollback.go index 0f0e0b4829..cb5d98bafd 100644 --- a/wallet/internal/db/sqlite_txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -1,9 +1,10 @@ -package db +package sqlite import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -16,7 +17,7 @@ func (s *SqliteStore) RollbackToBlock(ctx context.Context, height uint32) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return RollbackToBlockWithOps(ctx, height, + return db.RollbackToBlockWithOps(ctx, height, sqliteRollbackToBlockOps{qtx: qtx}) }) } @@ -27,7 +28,7 @@ type sqliteRollbackToBlockOps struct { qtx *sqlcsqlite.Queries } -var _ RollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) +var _ db.RollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) // ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. @@ -92,7 +93,7 @@ func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( rows, err := o.qtx.UpdateTransactionStateByHash( ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{}, - Status: int64(TxStatusOrphaned), + Status: int64(db.TxStatusOrphaned), WalletID: int64(walletID), TxHash: txHash[:], }, @@ -102,7 +103,7 @@ func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } } @@ -112,14 +113,14 @@ func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( // ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. func (o sqliteRollbackToBlockOps) ListUnminedTxRecords( - ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { + ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return BuildUnminedTxRecords(rows, + return db.BuildUnminedTxRecords(rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( int64, []byte, []byte) { @@ -157,7 +158,7 @@ func (o sqliteRollbackToBlockOps) MarkDescendantsFailed( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int64(TxStatusFailed), + Status: int64(db.TxStatusFailed), TxIds: descendantIDs, }, ) @@ -178,7 +179,7 @@ func groupRollbackCoinbaseRootsSqlite( map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { - walletID, err := Int64ToUint32(row.WalletID) + walletID, err := db.Int64ToUint32(row.WalletID) if err != nil { return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) } diff --git a/wallet/internal/db/sqlite_txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go similarity index 86% rename from wallet/internal/db/sqlite_txstore_updatetx.go rename to wallet/internal/db/sqlite/txstore_updatetx.go index d6a79f91bb..63a9d1b6b0 100644 --- a/wallet/internal/db/sqlite_txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -17,10 +18,10 @@ import ( // spent-input edges stay owned by CreateTx and the internal rollback/delete // flows. func (s *SqliteStore) UpdateTx(ctx context.Context, - params UpdateTxParams) error { + params db.UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return UpdateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) + return db.UpdateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) }) } @@ -38,7 +39,7 @@ type sqliteUpdateTxOps struct { status int64 } -var _ UpdateTxOps = (*sqliteUpdateTxOps)(nil) +var _ db.UpdateTxOps = (*sqliteUpdateTxOps)(nil) // LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. @@ -54,7 +55,7 @@ func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return false, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return false, fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return false, fmt.Errorf("get tx metadata: %w", err) @@ -66,7 +67,7 @@ func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, // PrepareState validates any referenced confirming block and captures the // sqlite-specific state params for the later row update. func (o *sqliteUpdateTxOps) PrepareState(ctx context.Context, - state UpdateTxState) error { + state db.UpdateTxState) error { blockHeight := sql.NullInt64{} @@ -102,7 +103,7 @@ func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return nil @@ -111,7 +112,7 @@ func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, // UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. func (o *sqliteUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, - txHash chainhash.Hash, _ UpdateTxState) error { + txHash chainhash.Hash, _ db.UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( ctx, @@ -127,7 +128,7 @@ func (o *sqliteUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return nil diff --git a/wallet/internal/db/sqlite/utxo_extra_test.go b/wallet/internal/db/sqlite/utxo_extra_test.go new file mode 100644 index 0000000000..f2dd783578 --- /dev/null +++ b/wallet/internal/db/sqlite/utxo_extra_test.go @@ -0,0 +1,34 @@ +package sqlite + +import ( + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/stretchr/testify/require" +) + +// TestUtxoInfoFromSqliteRowInvalidOutputIndex verifies sqlite row decoding. +func TestUtxoInfoFromSqliteRowInvalidOutputIndex(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{13} + _, err := utxoInfoFromSqliteRow( + hash[:], -1, 1000, []byte{0x57}, time.Unix(888, 0), false, + sql.NullInt64{}, + ) + require.ErrorContains(t, err, "utxo output index") +} + +// TestUtxoInfoFromSqliteRowInvalidBlockHeight verifies sqlite row decoding. +func TestUtxoInfoFromSqliteRowInvalidBlockHeight(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{14} + _, err := utxoInfoFromSqliteRow( + hash[:], 0, 1000, []byte{0x58}, time.Unix(999, 0), false, + sql.NullInt64{Int64: -1, Valid: true}, + ) + require.ErrorContains(t, err, "utxo block height") +} diff --git a/wallet/internal/db/sqlite_utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go similarity index 54% rename from wallet/internal/db/sqlite_utxostore_balance.go rename to wallet/internal/db/sqlite/utxostore_balance.go index 306c3126c0..a90351d662 100644 --- a/wallet/internal/db/sqlite_utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -1,8 +1,9 @@ -package db +package sqlite import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" "github.com/btcsuite/btcd/btcutil" @@ -11,23 +12,23 @@ import ( // Balance returns the sum of wallet-owned current UTXOs after optional filters. func (s *SqliteStore) Balance(ctx context.Context, - params BalanceParams) (BalanceResult, error) { + params db.BalanceParams) (db.BalanceResult, error) { nowUTC := time.Now().UTC() balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: NullableUint32ToSQLInt64(params.Account), - MinConfirms: NullableInt32ToSQLInt64(params.MinConfs), - MaxConfirms: NullableInt32ToSQLInt64(params.MaxConfs), - CoinbaseMaturity: NullableInt32ToSQLInt64(params.CoinbaseMaturity), + AccountNumber: db.NullableUint32ToSQLInt64(params.Account), + MinConfirms: db.NullableInt32ToSQLInt64(params.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt64(params.MaxConfs), + CoinbaseMaturity: db.NullableInt32ToSQLInt64(params.CoinbaseMaturity), }) if err != nil { - return BalanceResult{}, fmt.Errorf("balance: %w", err) + return db.BalanceResult{}, fmt.Errorf("balance: %w", err) } - return BalanceResult{ + return db.BalanceResult{ Total: btcutil.Amount(balance.TotalBalance), Locked: btcutil.Amount(balance.LockedBalance), }, nil diff --git a/wallet/internal/db/sqlite_utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go similarity index 81% rename from wallet/internal/db/sqlite_utxostore_getutxo.go rename to wallet/internal/db/sqlite/utxostore_getutxo.go index c5efca3f26..30a3bf3adb 100644 --- a/wallet/internal/db/sqlite_utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -15,7 +16,7 @@ import ( // The output must still be unspent and its creating transaction must still be // in `pending` or `published` status. func (s *SqliteStore) GetUtxo(ctx context.Context, - query GetUtxoQuery) (*UtxoInfo, error) { + query db.GetUtxoQuery) (*db.UtxoInfo, error) { row, err := s.queries.GetUtxoByOutpoint( ctx, sqlcsqlite.GetUtxoByOutpointParams{ @@ -27,7 +28,7 @@ func (s *SqliteStore) GetUtxo(ctx context.Context, if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, - ErrUtxoNotFound) + db.ErrUtxoNotFound) } return nil, fmt.Errorf("get utxo: %w", err) @@ -43,16 +44,16 @@ func (s *SqliteStore) GetUtxo(ctx context.Context, // public UtxoInfo shape. func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, pkScript []byte, received time.Time, isCoinbase bool, - blockHeight sql.NullInt64) (*UtxoInfo, error) { + blockHeight sql.NullInt64) (*db.UtxoInfo, error) { - index, err := Int64ToUint32(outputIndex) + index, err := db.Int64ToUint32(outputIndex) if err != nil { return nil, fmt.Errorf("utxo output index: %w", err) } var height *uint32 if blockHeight.Valid { - heightValue, err := Int64ToUint32(blockHeight.Int64) + heightValue, err := db.Int64ToUint32(blockHeight.Int64) if err != nil { return nil, fmt.Errorf("utxo block height: %w", err) } @@ -60,7 +61,7 @@ func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, height = &heightValue } - return BuildUtxoInfo( + return db.BuildUtxoInfo( hash, index, amount, pkScript, received, isCoinbase, height, ) } diff --git a/wallet/internal/db/sqlite_utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go similarity index 81% rename from wallet/internal/db/sqlite_utxostore_leaseoutput.go rename to wallet/internal/db/sqlite/utxostore_leaseoutput.go index 202de6f4f9..2874ca98f7 100644 --- a/wallet/internal/db/sqlite_utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -16,12 +17,12 @@ import ( // cannot observe a partially-written lease. Expiration timestamps are // normalized to UTC before Insert. func (s *SqliteStore) LeaseOutput(ctx context.Context, - params LeaseOutputParams) (*LeasedOutput, error) { + params db.LeaseOutputParams) (*db.LeasedOutput, error) { - var lease *LeasedOutput + var lease *db.LeasedOutput err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - acquiredLease, err := LeaseOutputWithOps( + acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &sqliteLeaseOutputOps{qtx: qtx}, ) if err != nil { @@ -45,12 +46,12 @@ type sqliteLeaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ LeaseOutputOps = (*sqliteLeaseOutputOps)(nil) +var _ db.LeaseOutputOps = (*sqliteLeaseOutputOps)(nil) // Acquire attempts to write or renew one sqlite lease row for the requested // outpoint. func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, - params LeaseOutputParams, nowUTC time.Time, + params db.LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { expiration, err := o.qtx.AcquireUtxoLease( @@ -65,10 +66,10 @@ func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return time.Time{}, ErrLeaseOutputNoRow + return time.Time{}, db.ErrLeaseOutputNoRow } - return time.Time{}, fmt.Errorf("Acquire lease row: %w", err) + return time.Time{}, fmt.Errorf("acquire lease row: %w", err) } return expiration, nil @@ -77,7 +78,7 @@ func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. func (o *sqliteLeaseOutputOps) HasUtxo(ctx context.Context, - params LeaseOutputParams) (bool, error) { + params db.LeaseOutputParams) (bool, error) { _, err := o.qtx.GetUtxoIDByOutpoint( ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ diff --git a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go similarity index 75% rename from wallet/internal/db/sqlite_utxostore_listleasedoutputs.go rename to wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index 63c4000470..52d6a03f93 100644 --- a/wallet/internal/db/sqlite_utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -1,8 +1,9 @@ -package db +package sqlite import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -10,7 +11,7 @@ import ( // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, - walletID uint32) ([]LeasedOutput, error) { + walletID uint32) ([]db.LeasedOutput, error) { nowUTC := time.Now().UTC() @@ -24,14 +25,14 @@ func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, return nil, fmt.Errorf("list active utxo leases: %w", err) } - leases := make([]LeasedOutput, len(rows)) + leases := make([]db.LeasedOutput, len(rows)) for i, row := range rows { - outputIndex, err := Int64ToUint32(row.OutputIndex) + outputIndex, err := db.Int64ToUint32(row.OutputIndex) if err != nil { return nil, fmt.Errorf("lease output index: %w", err) } - lease, err := BuildLeasedOutput( + lease, err := db.BuildLeasedOutput( row.TxHash, outputIndex, row.LockID, row.ExpiresAt, ) if err != nil { diff --git a/wallet/internal/db/sqlite_utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go similarity index 70% rename from wallet/internal/db/sqlite_utxostore_listutxos.go rename to wallet/internal/db/sqlite/utxostore_listutxos.go index 20119197a6..1efb716c5b 100644 --- a/wallet/internal/db/sqlite_utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -1,8 +1,9 @@ -package db +package sqlite import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) @@ -12,19 +13,19 @@ import ( // The result set is already constrained to outputs whose creating // transactions are still in `pending` or `published` status. func (s *SqliteStore) ListUTXOs(ctx context.Context, - query ListUtxosQuery) ([]UtxoInfo, error) { + query db.ListUtxosQuery) ([]db.UtxoInfo, error) { rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: NullableUint32ToSQLInt64(query.Account), - MinConfirms: NullableInt32ToSQLInt64(query.MinConfs), - MaxConfirms: NullableInt32ToSQLInt64(query.MaxConfs), + AccountNumber: db.NullableUint32ToSQLInt64(query.Account), + MinConfirms: db.NullableInt32ToSQLInt64(query.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt64(query.MaxConfs), }) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) } - utxos := make([]UtxoInfo, len(rows)) + utxos := make([]db.UtxoInfo, len(rows)) for i, row := range rows { utxo, err := utxoInfoFromSqliteRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, diff --git a/wallet/internal/db/sqlite_utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go similarity index 81% rename from wallet/internal/db/sqlite_utxostore_releaseoutput.go rename to wallet/internal/db/sqlite/utxostore_releaseoutput.go index 968ea65229..461fe4e1cb 100644 --- a/wallet/internal/db/sqlite_utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -1,10 +1,11 @@ -package db +package sqlite import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" @@ -16,10 +17,10 @@ import ( // The ownership check and lease deletion run in one transaction so callers // cannot unlock a UTXO using stale state from a separate read. func (s *SqliteStore) ReleaseOutput(ctx context.Context, - params ReleaseOutputParams) error { + params db.ReleaseOutputParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return ReleaseOutputWithOps( + return db.ReleaseOutputWithOps( ctx, params, &sqliteReleaseOutputOps{qtx: qtx}, ) }) @@ -31,12 +32,12 @@ type sqliteReleaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ ReleaseOutputOps = (*sqliteReleaseOutputOps)(nil) +var _ db.ReleaseOutputOps = (*sqliteReleaseOutputOps)(nil) // LookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row // ID. func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, - params ReleaseOutputParams) (int64, error) { + params db.ReleaseOutputParams) (int64, error) { utxoID, err := o.qtx.GetUtxoIDByOutpoint( ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ @@ -47,7 +48,7 @@ func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return 0, ErrReleaseOutputUtxoNotFound + return 0, db.ErrReleaseOutputUtxoNotFound } return 0, fmt.Errorf("lookup utxo row: %w", err) @@ -69,7 +70,7 @@ func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, }, ) if err != nil { - return 0, fmt.Errorf("Release lease row: %w", err) + return 0, fmt.Errorf("release lease row: %w", err) } return rows, nil @@ -80,7 +81,7 @@ func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, func (o *sqliteReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { - ActiveLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( ctx, sqlcsqlite.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, @@ -89,11 +90,11 @@ func (o *sqliteReleaseOutputOps) ActiveLockID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, ErrReleaseOutputNoActiveLease + return nil, db.ErrReleaseOutputNoActiveLease } return nil, fmt.Errorf("lookup active lease row: %w", err) } - return ActiveLockID, nil + return activeLockID, nil } diff --git a/wallet/internal/db/wallet_sqlite.go b/wallet/internal/db/sqlite/wallet.go similarity index 89% rename from wallet/internal/db/wallet_sqlite.go rename to wallet/internal/db/sqlite/wallet.go index e0821b4b2f..f58cf5a500 100644 --- a/wallet/internal/db/wallet_sqlite.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -1,4 +1,4 @@ -package db +package sqlite import ( "context" @@ -7,20 +7,21 @@ import ( "fmt" "iter" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" ) // Ensure SqliteStore satisfies the WalletStore interface. -var _ WalletStore = (*SqliteStore)(nil) +var _ db.WalletStore = (*SqliteStore)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. func (s *SqliteStore) CreateWallet(ctx context.Context, - params CreateWalletParams) (*WalletInfo, error) { + params db.CreateWalletParams) (*db.WalletInfo, error) { - var info *WalletInfo + var info *db.WalletInfo err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { walletParams := sqlcsqlite.CreateWalletParams{ @@ -114,13 +115,13 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. func (s *SqliteStore) GetWallet(ctx context.Context, - name string) (*WalletInfo, error) { + name string) (*db.WalletInfo, error) { row, err := s.queries.GetWalletByName(ctx, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("wallet %q: %w", name, - ErrWalletNotFound) + db.ErrWalletNotFound) } return nil, fmt.Errorf("get wallet: %w", err) @@ -144,21 +145,21 @@ func (s *SqliteStore) GetWallet(ctx context.Context, // ListWallets returns a page of wallets matching the given query. func (s *SqliteStore) ListWallets(ctx context.Context, - query ListWalletsQuery) (page.Result[WalletInfo, uint32], error) { + query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets( ctx, sqliteListWalletsParams(query.Page), ) if err != nil { - return page.Result[WalletInfo, uint32]{}, + return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: %w", err) } - items := make([]WalletInfo, len(rows)) + items := make([]db.WalletInfo, len(rows)) for i, row := range rows { item, errMap := sqliteListWalletRowToInfo(row) if errMap != nil { - return page.Result[WalletInfo, uint32]{}, + return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: map row: %w", errMap) } @@ -167,7 +168,7 @@ func (s *SqliteStore) ListWallets(ctx context.Context, result := page.BuildResult( query.Page, items, - func(item WalletInfo) uint32 { + func(item db.WalletInfo) uint32 { return item.ID }, ) @@ -177,10 +178,10 @@ func (s *SqliteStore) ListWallets(ctx context.Context, // IterWallets returns an iterator over paginated wallet results. func (s *SqliteStore) IterWallets(ctx context.Context, - query ListWalletsQuery) iter.Seq2[WalletInfo, error] { + query db.ListWalletsQuery) iter.Seq2[db.WalletInfo, error] { return page.Iter( - ctx, query, s.ListWallets, NextListWalletsQuery, + ctx, query, s.ListWallets, db.NextListWalletsQuery, ) } @@ -189,7 +190,7 @@ func (s *SqliteStore) IterWallets(ctx context.Context, // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. func (s *SqliteStore) UpdateWallet(ctx context.Context, - params UpdateWalletParams) error { + params db.UpdateWalletParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { // Insert blocks if needed. @@ -222,7 +223,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, if rowsAffected == 0 { return fmt.Errorf("wallet sync state for wallet %d: %w", - params.WalletID, ErrWalletNotFound) + params.WalletID, db.ErrWalletNotFound) } return nil @@ -240,7 +241,7 @@ func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("secrets for wallet %d: %w", - walletID, ErrWalletNotFound) + walletID, db.ErrWalletNotFound) } return nil, fmt.Errorf("get wallet secrets: %w", err) @@ -249,7 +250,7 @@ func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, if len(secrets.EncryptedMasterHdPrivKey) == 0 { return nil, fmt.Errorf( "encrypted master privkey for wallet %d: %w", walletID, - ErrSecretNotFound) + db.ErrSecretNotFound) } return secrets.EncryptedMasterHdPrivKey, nil @@ -257,7 +258,7 @@ func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, // UpdateWalletSecrets updates the secrets for the wallet. func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, - params UpdateWalletSecretsParams) error { + params db.UpdateWalletSecretsParams) error { secretsParams := sqlcsqlite.UpdateWalletSecretsParams{ MasterPrivParams: params.MasterPrivParams, @@ -274,7 +275,7 @@ func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, if rowsAffected == 0 { return fmt.Errorf("wallet secrets for wallet %d: %w", - params.WalletID, ErrWalletNotFound) + params.WalletID, db.ErrWalletNotFound) } return nil @@ -300,7 +301,7 @@ type sqliteWalletRowParams struct { // sqliteListWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. func sqliteListWalletRowToInfo( - row sqlcsqlite.ListWalletsRow) (*WalletInfo, error) { + row sqlcsqlite.ListWalletsRow) (*db.WalletInfo, error) { return buildSqliteWalletInfo(sqliteWalletRowParams{ id: row.ID, @@ -336,18 +337,18 @@ func sqliteListWalletsParams( // buildSqliteWalletInfo constructs a WalletInfo from the given wallet // row parameters. -func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { - walletID, err := Int64ToUint32(row.id) +func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { + walletID, err := db.Int64ToUint32(row.id) if err != nil { return nil, err } - mgrVer, err := Int64ToInt32(row.managerVersion) + mgrVer, err := db.Int64ToInt32(row.managerVersion) if err != nil { return nil, err } - info := &WalletInfo{ + info := &db.WalletInfo{ ID: walletID, Name: row.name, IsImported: row.isImported, @@ -391,7 +392,7 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*WalletInfo, error) { // buildUpdateSyncParamsSqlite constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. func buildUpdateSyncParamsSqlite( - params UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { + params db.UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ WalletID: int64(params.WalletID), diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/tx_store_backend_error_test.go index fe887e0ca9..7f972e55f3 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/tx_store_backend_error_test.go @@ -10,7 +10,6 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" ) @@ -84,37 +83,6 @@ func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { require.ErrorContains(t, err, "mark descendants failed") } -// TestSqliteDeleteAndRollbackOpsWrapBackendErrors verifies that the sqlite -// delete and rollback adapters preserve their step-specific error context when -// sqlc exec and query calls fail. -func TestSqliteDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { - t.Parallel() - - qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - deleteOps := sqliteDeleteTxOps{qtx: qtx} - rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} - - err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) - require.ErrorContains(t, err, "clear spent utxo rows") - - err = deleteOps.DeleteCreatedUtxos(t.Context(), 1, 2) - require.ErrorContains(t, err, "delete created utxo rows") - - _, err = deleteOps.DeleteUnminedTransaction( - t.Context(), 1, chainhash.Hash{1}, - ) - require.ErrorContains(t, err, "delete unmined tx row") - - _, err = rollbackOps.ListUnminedTxRecords(t.Context(), 1) - require.ErrorContains(t, err, "list unmined txns") - - err = rollbackOps.ClearDescendantSpends(t.Context(), 1, 2) - require.ErrorContains(t, err, "clear descendant spends") - - err = rollbackOps.MarkDescendantsFailed(t.Context(), 1, []int64{2}) - require.ErrorContains(t, err, "mark descendants failed") -} - // TestPgTxStoreOpsWrapBackendErrors verifies that the postgres tx-store helper // adapters preserve step-specific error context for create, invalidate, // rollback, update, and Release workflows. @@ -184,77 +152,6 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { require.ErrorContains(t, err, "Release lease row") } -// TestSqliteTxStoreOpsWrapBackendErrors verifies that the sqlite tx-store -// helper adapters preserve step-specific error context for create, invalidate, -// rollback, update, and Release workflows. -func TestSqliteTxStoreOpsWrapBackendErrors(t *testing.T) { - t.Parallel() - - qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - createOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: qtx, - }, - } - invalidateOps := sqliteInvalidateUnminedTxOps{qtx: qtx} - rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} - updateOps := &sqliteUpdateTxOps{qtx: qtx} - releaseOps := sqliteReleaseOutputOps{qtx: qtx} - - err := createOps.MarkTxnsReplaced( - t.Context(), 1, []int64{2}, - ) - require.ErrorContains(t, err, "mark txns replaced") - - err = createOps.InsertReplacementEdges( - t.Context(), 1, []int64{2}, 3, - ) - require.ErrorContains(t, err, "Insert replacement edge") - - err = markInputsSpentSqlite(t.Context(), qtx, CreateTxParams{ - WalletID: 1, - Tx: testRegularMsgTx(), - Received: time.Unix(1, 0), - Status: TxStatusPending, - }, 7) - require.ErrorContains(t, err, "mark spent input 0") - - _, err = invalidateOps.ListUnminedTxRecords(t.Context(), 1) - require.ErrorContains(t, err, "list unmined txns") - - err = invalidateOps.ClearSpentUtxos(t.Context(), 1, 2) - require.ErrorContains(t, err, "clear spent utxos") - - err = invalidateOps.MarkTxnsFailed(t.Context(), 1, []int64{2}) - require.ErrorContains(t, err, "mark txns failed") - - _, err = rollbackOps.ListRollbackRootHashes(t.Context(), 1) - require.ErrorContains(t, err, "query rollback coinbase roots") - - err = rollbackOps.RewindWalletSyncStateHeights(t.Context(), 1) - require.ErrorContains(t, err, "rewind wallet sync state heights query") - - err = rollbackOps.DeleteBlocksAtOrAboveHeight(t.Context(), 1) - require.ErrorContains(t, err, "delete blocks at or above height query") - - err = rollbackOps.MarkTxRootsOrphaned( - t.Context(), 1, []chainhash.Hash{{1}}, - ) - require.ErrorContains(t, err, "update rollback coinbase state query") - - updateOps.blockHeight = sql.NullInt64{} - updateOps.status = int64(TxStatusPublished) - err = updateOps.UpdateState(t.Context(), 1, chainhash.Hash{1}, - UpdateTxState{Status: TxStatusPublished}) - require.ErrorContains(t, err, "update tx state query") - - err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") - require.ErrorContains(t, err, "update tx label query") - - _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) - require.ErrorContains(t, err, "Release lease row") -} - // TestPgBackendHelpersRejectOverflow verifies the remaining postgres helper // branches that fail before issuing any SQL query. func TestPgBackendHelpersRejectOverflow(t *testing.T) { diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/tx_store_backend_rows_test.go index 1370d0275d..6e85c3d01c 100644 --- a/wallet/internal/db/tx_store_backend_rows_test.go +++ b/wallet/internal/db/tx_store_backend_rows_test.go @@ -7,9 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/wire" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" "github.com/stretchr/testify/require" ) @@ -138,91 +136,6 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { require.ErrorContains(t, err, "list unmined txns") } -// TestSqliteCreateTxOpsAdditionalBranches covers remaining sqlite CreateTx -// helper branches that are hard to reach through public integration tests -// alone. -func TestSqliteCreateTxOpsAdditionalBranches(t *testing.T) { - t.Parallel() - - req := testCreateTxRequest(t) - ctx := context.Background() - loadOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), - }), - }, - } - - _, err := loadOps.LoadExisting(ctx, req) - require.ErrorContains(t, err, "get tx metadata") - - block := &Block{ - Hash: chainhash.Hash{4}, - Height: 8, - Timestamp: time.Unix(88, 0), - } - confirmOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow( - t, "SELECT ?, ?, ?", - int64(block.Height), block.Hash[:], - block.Timestamp.Unix(), - ), - rows: 0, - }), - }, - } - err = confirmOps.ConfirmExisting(ctx, CreateTxRequest{ - Params: CreateTxParams{WalletID: 1, Block: block}, - TxHash: chainhash.Hash{9}, - }, CreateTxExistingTarget{}) - require.ErrorIs(t, err, ErrTxNotFound) - - prepareOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), - }), - }, - } - err = prepareOps.PrepareBlock(ctx, CreateTxRequest{ - Params: CreateTxParams{WalletID: 1, Block: block}, - }) - require.ErrorContains(t, err, "get block by height") - - conflictOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT ?", int64(5)), - queryErr: errDummy, - }), - }, - } - _, _, err = conflictOps.ListConflictTxns(ctx, req) - require.ErrorContains(t, err, "list unmined txns") -} - -// TestSqliteReleaseOutputOpsAdditionalBranches covers the remaining sqlite -// Release-helper query-row error wrappers. -func TestSqliteReleaseOutputOpsAdditionalBranches(t *testing.T) { - t.Parallel() - - ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), - })} - - _, err := ops.LookupUtxoID(context.Background(), ReleaseOutputParams{ - WalletID: 1, - OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0}, - }) - require.ErrorContains(t, err, "lookup utxo row") - - _, err = ops.ActiveLockID(context.Background(), 1, 2, time.Now()) - require.ErrorContains(t, err, "lookup active lease row") -} - // TestPgUpdateTxOpsAdditionalBranches covers the remaining postgres UpdateTx // helper branches that are hard to reach through public integration tests // alone. @@ -252,33 +165,3 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { err = labelOps.UpdateLabel(ctx, 1, txHash, "note") require.ErrorIs(t, err, ErrTxNotFound) } - -// TestSqliteUpdateTxOpsAdditionalBranches covers the remaining sqlite UpdateTx -// helper branches that are hard to reach through public integration tests -// alone. -func TestSqliteUpdateTxOpsAdditionalBranches(t *testing.T) { - t.Parallel() - - ctx := context.Background() - txHash := chainhash.Hash{9} - loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), - })} - stateOps := &sqliteUpdateTxOps{ - qtx: sqlcsqlite.New(rowDBTX{rows: 0}), - blockHeight: sql.NullInt64{}, - status: int64(TxStatusPublished), - } - labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} - - _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) - require.ErrorContains(t, err, "get tx metadata") - - err = stateOps.UpdateState(ctx, 1, txHash, UpdateTxState{ - Status: TxStatusPublished, - }) - require.ErrorIs(t, err, ErrTxNotFound) - - err = labelOps.UpdateLabel(ctx, 1, txHash, "note") - require.ErrorIs(t, err, ErrTxNotFound) -} From 396fe2477ba3fe82ceec154401d28b6104cb41de Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:25:02 +0800 Subject: [PATCH 532/691] db/pg: move backend files Move the handwritten PostgreSQL store implementation into wallet/internal/db/pg while keeping shared contracts and helper logic in wallet/internal/db. This keeps the split mostly mechanical and updates the postgres-specific tests and itest helpers to import the new package. The move also shifts postgres-only backend tests out of the root db package so the root tests no longer depend on postgres-private symbols. --- wallet/internal/db/db_connectors_test.go | 5 +- wallet/internal/db/itest/fixtures_pg_test.go | 5 +- wallet/internal/db/itest/pg_test.go | 17 ++-- .../db/itest/tx_corruption_pg_test.go | 11 ++- .../db/itest/tx_store_corruption_test.go | 3 +- .../db/itest/utxo_store_edge_cases_test.go | 3 +- .../db/{accounts_pg.go => pg/accounts.go} | 99 ++++++++++--------- .../address_types.go} | 19 ++-- .../db/{addresses_pg.go => pg/addresses.go} | 81 +++++++-------- .../backend_error_test.go} | 27 ++--- .../backend_rows_test.go} | 23 ++--- .../internal/db/{block_pg.go => pg/block.go} | 23 ++--- .../internal/db/{db_itest.go => pg/itest.go} | 1 + wallet/internal/db/{pg.go => pg/store.go} | 32 +++--- wallet/internal/db/pg/testhelpers_test.go | 53 ++++++++++ .../txstore_createtx.go} | 67 ++++++------- .../txstore_deletetx.go} | 23 ++--- .../txstore_gettx.go} | 13 +-- .../txstore_invalidateunmined.go} | 29 +++--- .../txstore_listtxns.go} | 19 ++-- .../txstore_rollback.go} | 27 ++--- .../txstore_updatetx.go} | 19 ++-- wallet/internal/db/pg/utxo_extra_test.go | 34 +++++++ .../utxostore_balance.go} | 17 ++-- .../utxostore_getutxo.go} | 17 ++-- .../utxostore_leaseoutput.go} | 23 ++--- .../utxostore_listleasedoutputs.go} | 11 ++- .../utxostore_listutxos.go} | 15 +-- .../utxostore_releaseoutput.go} | 23 ++--- .../db/{wallet_pg.go => pg/wallet.go} | 53 +++++----- 30 files changed, 455 insertions(+), 337 deletions(-) rename wallet/internal/db/{accounts_pg.go => pg/accounts.go} (81%) rename wallet/internal/db/{address_types_pg.go => pg/address_types.go} (67%) rename wallet/internal/db/{addresses_pg.go => pg/addresses.go} (79%) rename wallet/internal/db/{tx_store_backend_error_test.go => pg/backend_error_test.go} (90%) rename wallet/internal/db/{tx_store_backend_rows_test.go => pg/backend_rows_test.go} (88%) rename wallet/internal/db/{block_pg.go => pg/block.go} (78%) rename wallet/internal/db/{db_itest.go => pg/itest.go} (88%) rename wallet/internal/db/{pg.go => pg/store.go} (71%) create mode 100644 wallet/internal/db/pg/testhelpers_test.go rename wallet/internal/db/{postgres_txstore_createtx.go => pg/txstore_createtx.go} (88%) rename wallet/internal/db/{postgres_txstore_deletetx.go => pg/txstore_deletetx.go} (89%) rename wallet/internal/db/{postgres_txstore_gettx.go => pg/txstore_gettx.go} (81%) rename wallet/internal/db/{postgres_txstore_invalidateunmined.go => pg/txstore_invalidateunmined.go} (77%) rename wallet/internal/db/{postgres_txstore_listtxns.go => pg/txstore_listtxns.go} (85%) rename wallet/internal/db/{postgres_txstore_rollback.go => pg/txstore_rollback.go} (89%) rename wallet/internal/db/{postgres_txstore_updatetx.go => pg/txstore_updatetx.go} (86%) create mode 100644 wallet/internal/db/pg/utxo_extra_test.go rename wallet/internal/db/{postgres_utxostore_balance.go => pg/utxostore_balance.go} (54%) rename wallet/internal/db/{postgres_utxostore_getutxo.go => pg/utxostore_getutxo.go} (79%) rename wallet/internal/db/{postgres_utxostore_leaseoutput.go => pg/utxostore_leaseoutput.go} (78%) rename wallet/internal/db/{postgres_utxostore_listleasedoutputs.go => pg/utxostore_listleasedoutputs.go} (74%) rename wallet/internal/db/{postgres_utxostore_listutxos.go => pg/utxostore_listutxos.go} (69%) rename wallet/internal/db/{postgres_utxostore_releaseoutput.go => pg/utxostore_releaseoutput.go} (80%) rename wallet/internal/db/{wallet_pg.go => pg/wallet.go} (89%) diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index a8b37a7d2d..f0c24649d2 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -5,6 +5,7 @@ import ( "testing" db "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -38,7 +39,7 @@ func TestNewPostgresStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := db.NewPostgresStore(t.Context(), tc.cfg) + store, err := dbpg.NewPostgresStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -53,7 +54,7 @@ func TestNewPostgresStoreConnectionFailure(t *testing.T) { Dsn: "postgres://localhost:1/testdb", } - store, err := db.NewPostgresStore(t.Context(), cfg) + store, err := dbpg.NewPostgresStore(t.Context(), cfg) require.Error(t, err) require.ErrorContains(t, err, "ping database") require.NotErrorIs(t, err, db.ErrEmptyDSN) diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index b7d0a902f2..4b33892cee 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" "github.com/stretchr/testify/require" ) @@ -189,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &db.PostgresStore{}, store) + require.IsType(t, &dbpg.PostgresStore{}, store) - pgStore := store.(*db.PostgresStore) + pgStore := store.(*dbpg.PostgresStore) queries := pgStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 1e4a4eb3ca..997ff64d5e 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -20,6 +20,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" @@ -192,7 +193,7 @@ func sanitizedPgDBName(t *testing.T) string { // limit allows, exhausting the PostgreSQL connection pool. Avoid this by // creating NewTestStore inside each parallel subtest so its lifecycle is tied // to the subtest's parallel slot. -func NewTestStore(t *testing.T) *db.PostgresStore { +func NewTestStore(t *testing.T) *dbpg.PostgresStore { t.Helper() ctx := t.Context() @@ -227,7 +228,7 @@ func NewTestStore(t *testing.T) *db.PostgresStore { MaxConnections: 0, } - store, err := db.NewPostgresStore(t.Context(), cfg) + store, err := dbpg.NewPostgresStore(t.Context(), cfg) require.NoError(t, err, "failed to create postgres store") t.Cleanup(func() { @@ -239,7 +240,8 @@ func NewTestStore(t *testing.T) *db.PostgresStore { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *db.PostgresStore, walletID uint32, +func childSpendingTxIDs(t *testing.T, store *dbpg.PostgresStore, + walletID uint32, txHash chainhash.Hash) []int64 { t.Helper() @@ -271,7 +273,7 @@ func childSpendingTxIDs(t *testing.T, store *db.PostgresStore, walletID uint32, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *db.PostgresStore, walletID uint32, +func txIDByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() @@ -295,7 +297,7 @@ func txIDByHash(t *testing.T, store *db.PostgresStore, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *db.PostgresStore, walletID uint32, +func rawTxByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() @@ -313,7 +315,7 @@ func rawTxByHash(t *testing.T, store *db.PostgresStore, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *db.PostgresStore, walletID uint32, +func setTxStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -334,7 +336,8 @@ func setTxStatus(t *testing.T, store *db.PostgresStore, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *db.PostgresStore, walletID uint32, +func walletUtxoExists(t *testing.T, store *dbpg.PostgresStore, + walletID uint32, outPoint wire.OutPoint) bool { t.Helper() diff --git a/wallet/internal/db/itest/tx_corruption_pg_test.go b/wallet/internal/db/itest/tx_corruption_pg_test.go index f69010c1fd..4654452ec8 100644 --- a/wallet/internal/db/itest/tx_corruption_pg_test.go +++ b/wallet/internal/db/itest/tx_corruption_pg_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -19,7 +20,7 @@ import ( // corruptTransactionStatus writes an invalid tx status after dropping the // validating constraints that normally reject it. The corruption itests use // this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *db.PostgresStore, +func corruptTransactionStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -49,7 +50,7 @@ func corruptTransactionStatus(t *testing.T, store *db.PostgresStore, // corruptTransactionHash writes malformed tx-hash bytes after dropping the // fixed-length hash check. The corruption itests then verify that hash // decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *db.PostgresStore, +func corruptTransactionHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -75,7 +76,7 @@ func corruptTransactionHash(t *testing.T, store *db.PostgresStore, // the non-negative height check and creating a matching block row. The // corruption itests use this to verify that reads reject impossible // confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *db.PostgresStore, +func corruptTransactionBlockHeight(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -110,7 +111,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *db.PostgresStore, // corruptUtxoOutputIndex writes an invalid output index after dropping the // non-negative output-index check. The corruption itests then verify that UTXO // decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *db.PostgresStore, +func corruptUtxoOutputIndex(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -136,7 +137,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *db.PostgresStore, // corruptActiveLeaseLockID writes an invalid lease lock ID after dropping the // fixed-length lock-id check. The corruption itests use this to verify that // lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *db.PostgresStore, +func corruptActiveLeaseLockID(t *testing.T, store *dbpg.PostgresStore, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go index 5494f31d3c..f25d28de23 100644 --- a/wallet/internal/db/itest/tx_store_corruption_test.go +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -21,7 +22,7 @@ func dropTableForCorruption(t *testing.T, store interface{ DB() *sql.DB }, t.Helper() stmt := fmt.Sprintf("DROP TABLE %s", table) - if _, ok := any(store).(*db.PostgresStore); ok { + if _, ok := any(store).(*dbpg.PostgresStore); ok { stmt += " CASCADE" } diff --git a/wallet/internal/db/itest/utxo_store_edge_cases_test.go b/wallet/internal/db/itest/utxo_store_edge_cases_test.go index 1b72df0855..0d40418875 100644 --- a/wallet/internal/db/itest/utxo_store_edge_cases_test.go +++ b/wallet/internal/db/itest/utxo_store_edge_cases_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -257,7 +258,7 @@ func TestGetUtxoAndLeaseRejectLargeOutputIndex(t *testing.T) { }, ) - if _, ok := any(store).(*db.PostgresStore); ok { + if _, ok := any(store).(*dbpg.PostgresStore); ok { require.ErrorContains(t, err, "convert output index") require.ErrorContains(t, leaseErr, "convert output index") require.ErrorContains(t, releaseErr, "could not cast") diff --git a/wallet/internal/db/accounts_pg.go b/wallet/internal/db/pg/accounts.go similarity index 81% rename from wallet/internal/db/accounts_pg.go rename to wallet/internal/db/pg/accounts.go index 257be1d3e8..fb9bdbafb7 100644 --- a/wallet/internal/db/accounts_pg.go +++ b/wallet/internal/db/pg/accounts.go @@ -1,34 +1,35 @@ -package db +package pg import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) // Ensure PostgresStore satisfies the AccountStore interface. -var _ AccountStore = (*PostgresStore)(nil) +var _ db.AccountStore = (*PostgresStore)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. func (s *PostgresStore) GetAccount(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { getQueries := pgAccountGetQueries{q: s.queries} - return GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return db.GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. func (s *PostgresStore) ListAccounts(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { listQueries := pgAccountListQueries{q: s.queries} - return ListAccountsByQuery( + return db.ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, ) } @@ -36,11 +37,11 @@ func (s *PostgresStore) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. func (s *PostgresStore) RenameAccount(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { renameQueries := pgAccountRenameQueries{q: s.queries} - return RenameAccountByQuery( + return db.RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, ) } @@ -49,14 +50,14 @@ func (s *PostgresStore) RenameAccount(ctx context.Context, // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, - params CreateDerivedAccountParams) (*AccountInfo, error) { + params db.CreateDerivedAccountParams) (*db.AccountInfo, error) { paramsErr := params.Validate() if paramsErr != nil { return nil, paramsErr } - var info *AccountInfo + var info *db.AccountInfo err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { scopeID, err := pgEnsureKeyScope( @@ -80,7 +81,7 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, ctx, sqlcpg.CreateDerivedAccountParams{ ScopeID: scopeID, AccountName: params.Name, - OriginID: int16(DerivedAccount), + OriginID: int16(db.DerivedAccount), IsWatchOnly: false, }, ) @@ -91,16 +92,16 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, if !row.AccountNumber.Valid { // This should never happen unless the query is modified // incorrectly. - return ErrNilDBAccountNumber + return db.ErrNilDBAccountNumber } - accNumber, err := Int64ToUint32(row.AccountNumber.Int64) + accNumber, err := db.Int64ToUint32(row.AccountNumber.Int64) if err != nil { - return fmt.Errorf("%w: %w", ErrMaxAccountNumberReached, err) + return fmt.Errorf("%w: %w", db.ErrMaxAccountNumberReached, err) } - info = BuildAccountInfo( - accNumber, params.Name, DerivedAccount, 0, 0, 0, false, + info = db.BuildAccountInfo( + accNumber, params.Name, db.DerivedAccount, 0, 0, 0, false, row.CreatedAt, params.Scope, ) @@ -118,21 +119,21 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. func (s *PostgresStore) CreateImportedAccount(ctx context.Context, - params CreateImportedAccountParams) (*AccountProperties, error) { + params db.CreateImportedAccountParams) (*db.AccountProperties, error) { - var props *AccountProperties + var props *db.AccountProperties err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { var err error - props, err = CreateImportedAccount( + props, err = db.CreateImportedAccount( ctx, params, func() (int64, error) { return pgEnsureKeyScope(ctx, qtx, params.WalletID, params.Scope) }, qtx.CreateImportedAccount, pgBuildCreateImportedAccountArgs(params), func(row sqlcpg.CreateImportedAccountRow) int64 { return row.ID }, qtx.CreateAccountSecret, pgBuildCreateAccountSecretArgs(params), - func(accountID int64) (*AccountProperties, error) { + func(accountID int64) (*db.AccountProperties, error) { return pgGetAccountProps(ctx, qtx, accountID) }, ) @@ -149,7 +150,7 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, // pgBuildCreateImportedAccountArgs returns a function that builds the // CreateImportedAccountParams for PostgreSQL. func pgBuildCreateImportedAccountArgs( - params CreateImportedAccountParams, + params db.CreateImportedAccountParams, ) func(int64, bool) sqlcpg.CreateImportedAccountParams { return func(scopeID int64, @@ -158,7 +159,7 @@ func pgBuildCreateImportedAccountArgs( return sqlcpg.CreateImportedAccountParams{ ScopeID: scopeID, AccountName: params.Name, - OriginID: int16(ImportedAccount), + OriginID: int16(db.ImportedAccount), EncryptedPublicKey: params.EncryptedPublicKey, MasterFingerprint: sql.NullInt64{ Int64: int64(params.MasterFingerprint), @@ -172,7 +173,7 @@ func pgBuildCreateImportedAccountArgs( // pgBuildCreateAccountSecretArgs returns a function that builds the // CreateAccountSecretParams for PostgreSQL. func pgBuildCreateAccountSecretArgs( - params CreateImportedAccountParams, + params db.CreateImportedAccountParams, ) func(int64) sqlcpg.CreateAccountSecretParams { return func(accountID int64) sqlcpg.CreateAccountSecretParams { @@ -186,14 +187,14 @@ func pgBuildCreateAccountSecretArgs( // pgGetAccountProps fetches full account properties from the database and // converts the row to AccountProperties. func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, - accountID int64) (*AccountProperties, error) { + accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) if err != nil { return nil, fmt.Errorf("get account props: %w", err) } - return AccountPropsRowToProps(AccountPropsRow[int16, int16]{ + return db.AccountPropsRowToProps(db.AccountPropsRow[int16, int16]{ AccountNumber: row.AccountNumber, AccountName: row.AccountName, OriginID: row.OriginID, @@ -208,24 +209,24 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, CoinType: row.CoinType, InternalTypeID: row.InternalTypeID, ExternalTypeID: row.ExternalTypeID, - IDToAddrType: IDToAddressType[int16], - IDToOriginType: IDToAccountOrigin[int16], + IDToAddrType: db.IDToAddressType[int16], + IDToOriginType: db.IDToAccountOrigin[int16], }) } // pgEnsureKeyScope retrieves an existing key scope or creates it if missing for // PostgreSQL. It returns the scope ID once available. func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, - scope KeyScope) (int64, error) { + scope db.KeyScope) (int64, error) { - return EnsureKeyScope( + return db.EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, sqlcpg.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), }, qtx.CreateKeyScope, - func(addrSchema ScopeAddrSchema) sqlcpg.CreateKeyScopeParams { + func(addrSchema db.ScopeAddrSchema) sqlcpg.CreateKeyScopeParams { return sqlcpg.CreateKeyScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), @@ -261,12 +262,12 @@ type pgAccountInfoRow interface { // pgAccountRowToInfo converts a PostgreSQL account row to an AccountInfo // struct. It uses type conversion since all pgAccountInfoRow types have // identical fields. -func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { +func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAccountByScopeAndNameRow(row) - return AccountRowToInfo(AccountInfoRow[int16]{ + return db.AccountRowToInfo(db.AccountInfoRow[int16]{ AccountNumber: base.AccountNumber, AccountName: base.AccountName, OriginID: base.OriginID, @@ -277,7 +278,7 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*AccountInfo, error) { CreatedAt: base.CreatedAt, Purpose: base.Purpose, CoinType: base.CoinType, - IDToOriginType: IDToAccountOrigin[int16], + IDToOriginType: db.IDToAccountOrigin[int16], }) } @@ -288,9 +289,9 @@ type pgAccountListQueries struct { // byScope lists accounts filtered by wallet ID and key scope. func (p pgAccountListQueries) byScope(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, p.q.ListAccountsByWalletScope, sqlcpg.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), @@ -302,9 +303,9 @@ func (p pgAccountListQueries) byScope(ctx context.Context, // byName lists accounts filtered by wallet ID and account name. func (p pgAccountListQueries) byName(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, p.q.ListAccountsByWalletAndName, sqlcpg.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), @@ -315,9 +316,9 @@ func (p pgAccountListQueries) byName(ctx context.Context, // all lists all accounts for a wallet. func (p pgAccountListQueries) all(ctx context.Context, - query ListAccountsQuery) ([]AccountInfo, error) { + query db.ListAccountsQuery) ([]db.AccountInfo, error) { - return ListAccounts( + return db.ListAccounts( ctx, p.q.ListAccountsByWallet, int64(query.WalletID), pgAccountRowToInfo, ) @@ -330,24 +331,24 @@ type pgAccountGetQueries struct { // byNumber retrieves an account by wallet ID, scope, and account number. func (p pgAccountGetQueries) byNumber(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { - return GetAccount( + return db.GetAccount( ctx, p.q.GetAccountByWalletScopeAndNumber, sqlcpg.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - AccountNumber: NullableUint32ToSQLInt64(query.AccountNumber), + AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), }, query, pgAccountRowToInfo, ) } // byName retrieves an account by wallet ID, scope, and account name. func (p pgAccountGetQueries) byName(ctx context.Context, - query GetAccountQuery) (*AccountInfo, error) { + query db.GetAccountQuery) (*db.AccountInfo, error) { - return GetAccount( + return db.GetAccount( ctx, p.q.GetAccountByWalletScopeAndName, sqlcpg.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), @@ -366,16 +367,16 @@ type pgAccountRenameQueries struct { // byNumber renames an account identified by wallet ID, scope, and account // number. func (p pgAccountRenameQueries) byNumber(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { - return RenameAccount( + return db.RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), CoinType: int64(params.Scope.Coin), - AccountNumber: NullableUint32ToSQLInt64(params.AccountNumber), + AccountNumber: db.NullableUint32ToSQLInt64(params.AccountNumber), }, params, ) } @@ -383,9 +384,9 @@ func (p pgAccountRenameQueries) byNumber(ctx context.Context, // byName renames an account identified by wallet ID, scope, and old account // name. func (p pgAccountRenameQueries) byName(ctx context.Context, - params RenameAccountParams) error { + params db.RenameAccountParams) error { - return RenameAccount( + return db.RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndName, sqlcpg.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, diff --git a/wallet/internal/db/address_types_pg.go b/wallet/internal/db/pg/address_types.go similarity index 67% rename from wallet/internal/db/address_types_pg.go rename to wallet/internal/db/pg/address_types.go index f033ef9241..7aa90025f4 100644 --- a/wallet/internal/db/address_types_pg.go +++ b/wallet/internal/db/pg/address_types.go @@ -1,20 +1,21 @@ -package db +package pg import ( "context" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) // pgAddressTypeRowToInfo converts a PostgreSQL address type row to an // AddressTypeInfo struct. -func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { - addrType, err := IDToAddressType(row.ID) +func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { + addrType, err := db.IDToAddressType(row.ID) if err != nil { - return AddressTypeInfo{}, err + return db.AddressTypeInfo{}, err } - return AddressTypeInfo{ + return db.AddressTypeInfo{ Type: addrType, Description: row.Description, }, nil @@ -23,9 +24,9 @@ func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (AddressTypeInfo, error) { // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( - []AddressTypeInfo, error) { + []db.AddressTypeInfo, error) { - return ListAddressTypes( + return db.ListAddressTypes( ctx, s.queries.ListAddressTypes, pgAddressTypeRowToInfo, ) } @@ -33,9 +34,9 @@ func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. func (s *PostgresStore) GetAddressType(ctx context.Context, - id AddressType) (AddressTypeInfo, error) { + id db.AddressType) (db.AddressTypeInfo, error) { - return GetAddressTypeByID( + return db.GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int16(id), id, pgAddressTypeRowToInfo, ) diff --git a/wallet/internal/db/addresses_pg.go b/wallet/internal/db/pg/addresses.go similarity index 79% rename from wallet/internal/db/addresses_pg.go rename to wallet/internal/db/pg/addresses.go index 8aba77a7a1..41f56871d2 100644 --- a/wallet/internal/db/addresses_pg.go +++ b/wallet/internal/db/pg/addresses.go @@ -1,4 +1,4 @@ -package db +package pg import ( "context" @@ -7,21 +7,22 @@ import ( "iter" "time" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) -var _ AddressStore = (*PostgresStore)(nil) +var _ db.AddressStore = (*PostgresStore)(nil) // GetAddress retrieves information about a specific address, identified by // its script pubkey. func (s *PostgresStore) GetAddress(ctx context.Context, - query GetAddressQuery) (*AddressInfo, error) { + query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, q GetAddressQuery) (*AddressInfo, + getByScript := func(ctx context.Context, q db.GetAddressQuery) (*db.AddressInfo, error) { - return GetAddress( + return db.GetAddress( ctx, s.queries.GetAddressByScriptPubKey, sqlcpg.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, @@ -30,21 +31,21 @@ func (s *PostgresStore) GetAddress(ctx context.Context, ) } - return GetAddressByQuery(ctx, query, getByScript) + return db.GetAddressByQuery(ctx, query, getByScript) } // ListAddresses returns a page of addresses matching the given query. func (s *PostgresStore) ListAddresses(ctx context.Context, - query ListAddressesQuery) (page.Result[AddressInfo, uint32], error) { + query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { items, err := pgListAddressesByAccount(ctx, s.queries, query) if err != nil { - return page.Result[AddressInfo, uint32]{}, err + return page.Result[db.AddressInfo, uint32]{}, err } result := page.BuildResult( query.Page, items, - func(item AddressInfo) uint32 { + func(item db.AddressInfo) uint32 { return item.ID }, ) @@ -54,18 +55,18 @@ func (s *PostgresStore) ListAddresses(ctx context.Context, // IterAddresses returns an iterator over paginated address results. func (s *PostgresStore) IterAddresses(ctx context.Context, - query ListAddressesQuery) iter.Seq2[AddressInfo, error] { + query db.ListAddressesQuery) iter.Seq2[db.AddressInfo, error] { return page.Iter( - ctx, query, s.ListAddresses, NextListAddressesQuery, + ctx, query, s.ListAddresses, db.NextListAddressesQuery, ) } // GetAddressSecret retrieves the encrypted secret information for an address. func (s *PostgresStore) GetAddressSecret(ctx context.Context, - addressID uint32) (*AddressSecret, error) { + addressID uint32) (*db.AddressSecret, error) { - return GetAddressSecret( + return db.GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, ) } @@ -73,16 +74,16 @@ func (s *PostgresStore) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. func (s *PostgresStore) NewDerivedAddress(ctx context.Context, - params NewDerivedAddressParams, - deriveFn AddressDerivationFunc) (*AddressInfo, error) { + params db.NewDerivedAddressParams, + deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { - adapters := DerivedAddressAdapters[ + adapters := db.DerivedAddressAdapters[ *sqlcpg.Queries, sqlcpg.GetAccountByWalletScopeAndNameRow, - AccountLookupKey, + db.AccountLookupKey, sqlcpg.CreateDerivedAddressRow]{ GetAccount: pgGetAccountFromKey(s.queries), - AccountParams: AccountKeyFromParams, + AccountParams: db.AccountKeyFromParams, GetAccountID: newDerivedAddressGetAccountIDPg, GetExtIndex: newDerivedAddressGetExtIndexPg, GetIntIndex: newDerivedAddressGetIntIndexPg, @@ -91,22 +92,22 @@ func (s *PostgresStore) NewDerivedAddress(ctx context.Context, RowCreatedAt: newDerivedAddressRowCreatedAtPg, } - return NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return db.NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) } // NewImportedAddress imports a new address, script, or private key. func (s *PostgresStore) NewImportedAddress(ctx context.Context, - params NewImportedAddressParams) (*AddressInfo, error) { + params db.NewImportedAddressParams) (*db.AddressInfo, error) { - adapters := ImportedAddressAdapters[ + adapters := db.ImportedAddressAdapters[ *sqlcpg.Queries, sqlcpg.GetAccountByWalletScopeAndNameRow, - AccountLookupKey, + db.AccountLookupKey, sqlcpg.CreateImportedAddressParams, sqlcpg.CreateImportedAddressRow, sqlcpg.InsertAddressSecretParams]{ GetAccount: pgGetAccountFromKey(s.queries), - AccountParams: AccountKeyFromImportedParams, + AccountParams: db.AccountKeyFromImportedParams, GetAccountID: newImportedAddressGetAccountIDPg, CreateAddr: pgCreateImportedAddress, CreateParams: createImportedAddressParamsPg, @@ -116,15 +117,15 @@ func (s *PostgresStore) NewImportedAddress(ctx context.Context, RowCreatedAt: importedAddressRowCreatedAtPg, } - return NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } // pgGetAccountFromKey returns a helper to look up accounts by key. func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, - AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { + db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, + key db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( @@ -161,14 +162,14 @@ func newDerivedAddressGetIntIndexPg(qtx *sqlcpg.Queries) func(context.Context, // newDerivedAddressCreateAddrPg returns the derived address insert helper. func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, - int64, AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, + int64, db.AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, error) { - return func(ctx context.Context, accountID int64, addrType AddressType, + return func(ctx context.Context, accountID int64, addrType db.AddressType, branch uint32, index uint32, scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { - branchNum, err := Uint32ToInt16(branch) + branchNum, err := db.Uint32ToInt16(branch) if err != nil { return sqlcpg.CreateDerivedAddressRow{}, fmt.Errorf( "address branch: %w", err, @@ -230,7 +231,7 @@ func pgInsertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, // createImportedAddressParamsPg maps imported params to sqlc params. func createImportedAddressParamsPg(accountID int64, - params NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { + params db.NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { return sqlcpg.CreateImportedAddressParams{ AccountID: accountID, @@ -242,7 +243,7 @@ func createImportedAddressParamsPg(accountID int64, // insertAddressSecretParamsPg maps imported params to secret params. func insertAddressSecretParamsPg(addressID int64, - params NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { + params db.NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { return sqlcpg.InsertAddressSecretParams{ AddressID: addressID, @@ -266,9 +267,9 @@ func importedAddressRowCreatedAtPg( // pgAddressSecretRowToSecret converts a PostgreSQL address secret row to an // AddressSecret struct. func pgAddressSecretRowToSecret( - row sqlcpg.GetAddressSecretRow) (*AddressSecret, error) { + row sqlcpg.GetAddressSecretRow) (*db.AddressSecret, error) { - return AddressSecretRowToSecret(AddressSecretRow{ + return db.AddressSecretRowToSecret(db.AddressSecretRow{ AddressID: row.AddressID, EncryptedPrivKey: row.EncryptedPrivKey, EncryptedScript: row.EncryptedScript, @@ -285,12 +286,12 @@ type pgAddressInfoRow interface { // pgAddressRowToInfo converts a PostgreSQL address row to an AddressInfo // struct. -func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { +func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAddressByScriptPubKeyRow(row) - info, err := AddressRowToInfo(AddressInfoRow[int16, int16]{ + info, err := db.AddressRowToInfo(db.AddressInfoRow[int16, int16]{ ID: base.ID, AccountID: base.AccountID, TypeID: base.TypeID, @@ -305,8 +306,8 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { AddressIndex: base.AddressIndex, ScriptPubKey: base.ScriptPubKey, PubKey: base.PubKey, - IDToAddrType: IDToAddressType[int16], - IDToOrigin: IDToOrigin[int16], + IDToAddrType: db.IDToAddressType[int16], + IDToOrigin: db.IDToOrigin[int16], }) if err != nil { return nil, err @@ -318,7 +319,7 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*AddressInfo, error) { // pgListAddressesByAccount lists addresses filtered by wallet ID, key scope, // and account name, with pagination support. func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, - query ListAddressesQuery) ([]AddressInfo, error) { + query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( ctx, pgBuildAddressPageParams(query), @@ -327,7 +328,7 @@ func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, return nil, fmt.Errorf("list addresses by account: %w", err) } - items := make([]AddressInfo, len(rows)) + items := make([]db.AddressInfo, len(rows)) for i, row := range rows { item, err := pgAddressRowToInfo(row) if err != nil { @@ -345,7 +346,7 @@ func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, // pgBuildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. func pgBuildAddressPageParams( - q ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { + q db.ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { params := sqlcpg.ListAddressesByAccountParams{ WalletID: int64(q.WalletID), diff --git a/wallet/internal/db/tx_store_backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go similarity index 90% rename from wallet/internal/db/tx_store_backend_error_test.go rename to wallet/internal/db/pg/backend_error_test.go index 7f972e55f3..7a5b319501 100644 --- a/wallet/internal/db/tx_store_backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -1,9 +1,10 @@ -package db +package pg import ( "context" "database/sql" "errors" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "testing" "time" @@ -106,13 +107,13 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { err = createOps.InsertReplacementEdges( t.Context(), 1, []int64{2}, 3, ) - require.ErrorContains(t, err, "Insert replacement edge") + require.ErrorContains(t, err, "insert replacement edge") - err = markInputsSpentPg(t.Context(), qtx, CreateTxParams{ + err = markInputsSpentPg(t.Context(), qtx, db.CreateTxParams{ WalletID: 1, Tx: testRegularMsgTx(), Received: time.Unix(1, 0), - Status: TxStatusPending, + Status: db.TxStatusPending, }, 7) require.ErrorContains(t, err, "mark spent input 0") @@ -140,16 +141,16 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { require.ErrorContains(t, err, "update rollback coinbase state query") updateOps.blockHeight = sql.NullInt32{} - updateOps.status = int16(TxStatusPublished) + updateOps.status = int16(db.TxStatusPublished) err = updateOps.UpdateState(t.Context(), 1, chainhash.Hash{1}, - UpdateTxState{Status: TxStatusPublished}) + db.UpdateTxState{Status: db.TxStatusPublished}) require.ErrorContains(t, err, "update tx state query") err = updateOps.UpdateLabel(t.Context(), 1, chainhash.Hash{1}, "note") require.ErrorContains(t, err, "update tx label query") _, err = releaseOps.Release(t.Context(), 1, 2, [32]byte{1}) - require.ErrorContains(t, err, "Release lease row") + require.ErrorContains(t, err, "release lease row") } // TestPgBackendHelpersRejectOverflow verifies the remaining postgres helper @@ -157,7 +158,7 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { func TestPgBackendHelpersRejectOverflow(t *testing.T) { t.Parallel() - req, err := NewCreateTxRequest(CreateTxParams{ + req, err := db.NewCreateTxRequest(db.CreateTxParams{ WalletID: 1, Tx: &wire.MsgTx{ Version: wire.TxVersion, @@ -170,7 +171,7 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { TxOut: []*wire.TxOut{{Value: 1, PkScript: []byte{0x51}}}, }, Received: time.Unix(1, 0), - Status: TxStatusPending, + Status: db.TxStatusPending, }) require.NoError(t, err) @@ -182,7 +183,7 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { _, err = creditExistsPg(t.Context(), nil, 1, chainhash.Hash{1}, ^uint32(0)) require.ErrorContains(t, err, "convert credit index") - err = markInputsSpentPg(t.Context(), nil, CreateTxParams{ + err = markInputsSpentPg(t.Context(), nil, db.CreateTxParams{ WalletID: 1, Tx: &wire.MsgTx{ Version: wire.TxVersion, @@ -193,7 +194,7 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }, }}, }, - Status: TxStatusPending, + Status: db.TxStatusPending, }, 3) require.ErrorContains(t, err, "convert input outpoint index 0") @@ -216,14 +217,14 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { leaseOps := &pgLeaseOutputOps{} - _, err = leaseOps.Acquire(t.Context(), LeaseOutputParams{ + _, err = leaseOps.Acquire(t.Context(), db.LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, }, time.Now(), time.Now().Add(time.Minute)) require.ErrorContains(t, err, "convert output index") - _, err = leaseOps.HasUtxo(t.Context(), LeaseOutputParams{ + _, err = leaseOps.HasUtxo(t.Context(), db.LeaseOutputParams{ WalletID: 1, OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: ^uint32(0)}, ID: [32]byte{1}, diff --git a/wallet/internal/db/tx_store_backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go similarity index 88% rename from wallet/internal/db/tx_store_backend_rows_test.go rename to wallet/internal/db/pg/backend_rows_test.go index 6e85c3d01c..0ffad6e2de 100644 --- a/wallet/internal/db/tx_store_backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -1,8 +1,9 @@ -package db +package pg import ( "context" "database/sql" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "testing" "time" @@ -101,7 +102,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { _, err := loadOps.LoadExisting(ctx, req) require.ErrorContains(t, err, "get tx metadata") - block := &Block{ + block := &db.Block{ Hash: chainhash.Hash{3}, Height: 7, Timestamp: time.Unix(77, 0), @@ -118,11 +119,11 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { }), }, } - err = confirmOps.ConfirmExisting(ctx, CreateTxRequest{ - Params: CreateTxParams{WalletID: 1, Block: block}, + err = confirmOps.ConfirmExisting(ctx, db.CreateTxRequest{ + Params: db.CreateTxParams{WalletID: 1, Block: block}, TxHash: chainhash.Hash{9}, - }, CreateTxExistingTarget{}) - require.ErrorIs(t, err, ErrTxNotFound) + }, db.CreateTxExistingTarget{}) + require.ErrorIs(t, err, db.ErrTxNotFound) conflictOps := &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ @@ -150,18 +151,18 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { stateOps := &pgUpdateTxOps{ qtx: sqlcpg.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt32{}, - status: int16(TxStatusPublished), + status: int16(db.TxStatusPublished), } labelOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") - err = stateOps.UpdateState(ctx, 1, txHash, UpdateTxState{ - Status: TxStatusPublished, + err = stateOps.UpdateState(ctx, 1, txHash, db.UpdateTxState{ + Status: db.TxStatusPublished, }) - require.ErrorIs(t, err, ErrTxNotFound) + require.ErrorIs(t, err, db.ErrTxNotFound) err = labelOps.UpdateLabel(ctx, 1, txHash, "note") - require.ErrorIs(t, err, ErrTxNotFound) + require.ErrorIs(t, err, db.ErrTxNotFound) } diff --git a/wallet/internal/db/block_pg.go b/wallet/internal/db/pg/block.go similarity index 78% rename from wallet/internal/db/block_pg.go rename to wallet/internal/db/pg/block.go index b25aaf0172..fc8559d2f1 100644 --- a/wallet/internal/db/block_pg.go +++ b/wallet/internal/db/pg/block.go @@ -1,4 +1,4 @@ -package db +package pg import ( "bytes" @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -13,21 +14,21 @@ import ( // buildPgBlock constructs a Block from the given PostgreSQL block // fields. func buildPgBlock(height sql.NullInt32, hash []byte, - timestamp sql.NullInt64) (*Block, error) { + timestamp sql.NullInt64) (*db.Block, error) { - height32, err := NullInt32ToUint32(height) + height32, err := db.NullInt32ToUint32(height) if err != nil { return nil, fmt.Errorf("block height: %w", err) } - return BuildBlock(hash, height32, timestamp.Int64) + return db.BuildBlock(hash, height32, timestamp.Int64) } // ensureBlockExistsPg ensures that a block exists in the database. func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, - block *Block) error { + block *db.Block) error { - height, err := Uint32ToInt32(block.Height) + height, err := db.Uint32ToInt32(block.Height) if err != nil { return fmt.Errorf("convert block height: %w", err) } @@ -49,9 +50,9 @@ func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, // requireBlockMatchesPg loads the shared block row for the provided height and // verifies that its stored metadata matches the supplied block reference. func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, - block *Block) (int32, error) { + block *db.Block) (int32, error) { - height, err := Uint32ToInt32(block.Height) + height, err := db.Uint32ToInt32(block.Height) if err != nil { return 0, fmt.Errorf("convert block height: %w", err) } @@ -60,7 +61,7 @@ func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, fmt.Errorf("block %d: %w", block.Height, - ErrBlockNotFound) + db.ErrBlockNotFound) } return 0, fmt.Errorf("get block by height: %w", err) @@ -68,12 +69,12 @@ func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, if !bytes.Equal(storedBlock.HeaderHash, block.Hash[:]) { return 0, fmt.Errorf("block %d header hash: %w", block.Height, - ErrBlockMismatch) + db.ErrBlockMismatch) } if storedBlock.BlockTimestamp != block.Timestamp.Unix() { return 0, fmt.Errorf("block %d timestamp: %w", block.Height, - ErrBlockMismatch) + db.ErrBlockMismatch) } return height, nil diff --git a/wallet/internal/db/db_itest.go b/wallet/internal/db/pg/itest.go similarity index 88% rename from wallet/internal/db/db_itest.go rename to wallet/internal/db/pg/itest.go index fb53c7271a..89dc9d4d87 100644 --- a/wallet/internal/db/db_itest.go +++ b/wallet/internal/db/pg/itest.go @@ -4,6 +4,7 @@ package db import ( "database/sql" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) diff --git a/wallet/internal/db/pg.go b/wallet/internal/db/pg/store.go similarity index 71% rename from wallet/internal/db/pg.go rename to wallet/internal/db/pg/store.go index 2441ba1265..c351bd45f8 100644 --- a/wallet/internal/db/pg.go +++ b/wallet/internal/db/pg/store.go @@ -1,10 +1,12 @@ -package db +package pg import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) @@ -19,7 +21,7 @@ type PostgresStore struct { // NewPostgresStore creates a new PostgreSQL-based WalletStore. It handles // the full connection setup including config validation, connection opening, // health checks, connection pool configuration, and migration application. -func NewPostgresStore(ctx context.Context, cfg PostgresConfig) (*PostgresStore, +func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStore, error) { err := cfg.Validate() @@ -27,39 +29,39 @@ func NewPostgresStore(ctx context.Context, cfg PostgresConfig) (*PostgresStore, return nil, fmt.Errorf("invalid config: %w", err) } - db, err := sql.Open("pgx", cfg.Dsn) + dbConn, err := sql.Open("pgx", cfg.Dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } - connCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout) + connCtx, cancel := context.WithTimeout(ctx, db.DefaultConnectionTimeout) defer cancel() - err = db.PingContext(connCtx) + err = dbConn.PingContext(connCtx) if err != nil { - _ = db.Close() + _ = dbConn.Close() return nil, fmt.Errorf("ping database: %w", err) } - maxConns := DefaultMaxConnections + maxConns := db.DefaultMaxConnections if cfg.MaxConnections > 0 { maxConns = cfg.MaxConnections } - db.SetMaxOpenConns(maxConns) - db.SetMaxIdleConns(maxConns) - db.SetConnMaxIdleTime(DefaultConnIdleLifetime) + dbConn.SetMaxOpenConns(maxConns) + dbConn.SetMaxIdleConns(maxConns) + dbConn.SetConnMaxIdleTime(db.DefaultConnIdleLifetime) - queries := sqlcpg.New(db) + queries := sqlcpg.New(dbConn) - err = ApplyPostgresMigrations(db) + err = db.ApplyPostgresMigrations(dbConn) if err != nil { - _ = db.Close() + _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) } return &PostgresStore{ - db: db, + db: dbConn, queries: queries, }, nil } @@ -81,7 +83,7 @@ func (s *PostgresStore) Close() error { func (s *PostgresStore) ExecuteTx(ctx context.Context, fn func(*sqlcpg.Queries) error) error { - return ExecInTx(ctx, s.db, func(tx *sql.Tx) error { + return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) return fn(qtx) }) diff --git a/wallet/internal/db/pg/testhelpers_test.go b/wallet/internal/db/pg/testhelpers_test.go new file mode 100644 index 0000000000..7763707193 --- /dev/null +++ b/wallet/internal/db/pg/testhelpers_test.go @@ -0,0 +1,53 @@ +package pg + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// testBlock builds one deterministic test block. +func testBlock(height uint32) *db.Block { + return &db.Block{ + Hash: chainhash.Hash{byte(height), 1, 2, 3}, + Height: height, + Timestamp: time.Unix(int64(height), 0), + } +} + +// testRegularMsgTx builds one simple non-coinbase transaction fixture. +func testRegularMsgTx() *wire.MsgTx { + return &wire.MsgTx{ + Version: wire.TxVersion, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1, 2, 3}, + Index: 0, + }, + }}, + TxOut: []*wire.TxOut{{ + Value: int64(btcutil.SatoshiPerBitcoin), + PkScript: []byte{0x51}, + }}, + } +} + +// testCreateTxRequest builds one prepared CreateTx request for pg tests. +func testCreateTxRequest(t *testing.T) db.CreateTxRequest { + t.Helper() + + req, err := db.NewCreateTxRequest(db.CreateTxParams{ + WalletID: 7, + Tx: testRegularMsgTx(), + Received: time.Unix(456, 0), + Status: db.TxStatusPending, + }) + require.NoError(t, err) + + return req +} diff --git a/wallet/internal/db/postgres_txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go similarity index 88% rename from wallet/internal/db/postgres_txstore_createtx.go rename to wallet/internal/db/pg/txstore_createtx.go index 5e388c9ca1..8c01b1f0f1 100644 --- a/wallet/internal/db/postgres_txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -21,15 +22,15 @@ import ( // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. func (s *PostgresStore) CreateTx(ctx context.Context, - params CreateTxParams) error { + params db.CreateTxParams) error { - req, err := NewCreateTxRequest(params) + req, err := db.NewCreateTxRequest(params) if err != nil { return err } return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return CreateTxWithOps(ctx, req, &pgCreateTxOps{ + return db.CreateTxWithOps(ctx, req, &pgCreateTxOps{ pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ qtx: qtx, }, @@ -44,11 +45,11 @@ type pgCreateTxOps struct { blockHeight sql.NullInt32 } -var _ CreateTxOps = (*pgCreateTxOps)(nil) +var _ db.CreateTxOps = (*pgCreateTxOps)(nil) // LoadExisting loads any existing wallet-scoped row for the requested tx hash. func (o *pgCreateTxOps) LoadExisting(ctx context.Context, - req CreateTxRequest) (*CreateTxExistingTarget, error) { + req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( ctx, @@ -59,18 +60,18 @@ func (o *pgCreateTxOps) LoadExisting(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, ErrCreateTxExistingNotFound + return nil, db.ErrCreateTxExistingNotFound } return nil, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(int64(meta.TxStatus)) + status, err := db.ParseTxStatus(int64(meta.TxStatus)) if err != nil { return nil, err } - return &CreateTxExistingTarget{ + return &db.CreateTxExistingTarget{ ID: meta.ID, Status: status, HasBlock: meta.BlockHeight.Valid, @@ -80,8 +81,8 @@ func (o *pgCreateTxOps) LoadExisting(ctx context.Context, // ConfirmExisting promotes one existing unmined row to its confirmed state. func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, - req CreateTxRequest, - _ CreateTxExistingTarget) error { + req db.CreateTxRequest, + _ db.CreateTxExistingTarget) error { blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) if err != nil { @@ -91,7 +92,7 @@ func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, sqlcpg.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{Int32: blockHeight, Valid: true}, - Status: int16(TxStatusPublished), + Status: int16(db.TxStatusPublished), WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -101,7 +102,7 @@ func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", req.TxHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", req.TxHash, db.ErrTxNotFound) } return nil @@ -110,7 +111,7 @@ func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, // PrepareBlock validates the optional confirming block and caches the postgres // block-height value that the later Insert query will store. func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, - req CreateTxRequest) error { + req db.CreateTxRequest) error { o.blockHeight = sql.NullInt32{} @@ -131,7 +132,7 @@ func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, // ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, - req CreateTxRequest) ([]int64, []chainhash.Hash, error) { + req db.CreateTxRequest) ([]int64, []chainhash.Hash, error) { rootIDs, err := collectPgConflictRootIDs(ctx, o.qtx, req) if err != nil { @@ -153,7 +154,7 @@ func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, // collectPgConflictRootIDs returns the active unmined spender row IDs // that currently own any wallet-controlled input spent by the incoming tx. func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, - req CreateTxRequest) (map[int64]struct{}, error) { + req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { return map[int64]struct{}{}, nil @@ -161,7 +162,7 @@ func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, rootIDs := make(map[int64]struct{}, len(req.Params.Tx.TxIn)) for inputIndex, txIn := range req.Params.Tx.TxIn { - outputIndex, err := Uint32ToInt32(txIn.PreviousOutPoint.Index) + outputIndex, err := db.Uint32ToInt32(txIn.PreviousOutPoint.Index) if err != nil { return nil, fmt.Errorf("convert input outpoint index %d: %w", inputIndex, err) @@ -221,7 +222,7 @@ func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, // Insert stores one new postgres transaction row for CreateTx. func (o *pgCreateTxOps) Insert(ctx context.Context, - req CreateTxRequest) (int64, error) { + req db.CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ WalletID: int64(req.Params.WalletID), @@ -234,7 +235,7 @@ func (o *pgCreateTxOps) Insert(ctx context.Context, TxLabel: req.Params.Label, }) if err != nil { - return 0, fmt.Errorf("Insert tx row: %w", err) + return 0, fmt.Errorf("insert tx row: %w", err) } return txID, nil @@ -242,14 +243,14 @@ func (o *pgCreateTxOps) Insert(ctx context.Context, // InsertCredits stores any wallet-owned outputs created by the transaction. func (o *pgCreateTxOps) InsertCredits(ctx context.Context, - req CreateTxRequest, txID int64) error { + req db.CreateTxRequest, txID int64) error { return insertCreditsPg(ctx, o.qtx, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. func (o *pgCreateTxOps) MarkInputsSpent(ctx context.Context, - req CreateTxRequest, txID int64) error { + req db.CreateTxRequest, txID int64) error { return markInputsSpentPg(ctx, o.qtx, req.Params, txID) } @@ -262,7 +263,7 @@ func (o *pgCreateTxOps) MarkTxnsReplaced( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int16(TxStatusReplaced), + Status: int16(db.TxStatusReplaced), TxIds: txIDs, }, ) @@ -288,7 +289,7 @@ func (o *pgCreateTxOps) InsertReplacementEdges( }, ) if err != nil { - return fmt.Errorf("Insert replacement edge for %d: %w", + return fmt.Errorf("insert replacement edge for %d: %w", replacedTxID, err) } } @@ -299,7 +300,7 @@ func (o *pgCreateTxOps) InsertReplacementEdges( // insertCreditsPg inserts one wallet-owned UTXO row for each credited output of // the transaction being stored. func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, - params CreateTxParams, txID int64) error { + params db.CreateTxParams, txID int64) error { for index := range params.Credits { creditExists, err := creditExistsPg( @@ -324,13 +325,13 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("credit output %d: %w", index, - ErrAddressNotFound) + db.ErrAddressNotFound) } return fmt.Errorf("resolve credit address %d: %w", index, err) } - outputIndex, err := Uint32ToInt32(index) + outputIndex, err := db.Uint32ToInt32(index) if err != nil { return fmt.Errorf("convert credit index %d: %w", index, err) } @@ -343,7 +344,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, AddressID: addrRow.ID, }) if err != nil { - return fmt.Errorf("Insert credit output %d: %w", index, err) + return fmt.Errorf("insert credit output %d: %w", index, err) } } @@ -355,7 +356,7 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { - convertedIndex, err := Uint32ToInt32(outputIndex) + convertedIndex, err := db.Uint32ToInt32(outputIndex) if err != nil { return false, fmt.Errorf("convert credit index %d: %w", outputIndex, err) @@ -389,20 +390,20 @@ func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, - params CreateTxParams, txID int64) error { + params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { return nil } for inputIndex, txIn := range params.Tx.TxIn { - outputIndex, err := Uint32ToInt32(txIn.PreviousOutPoint.Index) + outputIndex, err := db.Uint32ToInt32(txIn.PreviousOutPoint.Index) if err != nil { return fmt.Errorf("convert input outpoint index %d: %w", inputIndex, err) } - spentInputIndex, err := Int64ToInt32(int64(inputIndex)) + spentInputIndex, err := db.Int64ToInt32(int64(inputIndex)) if err != nil { return fmt.Errorf("convert input index %d: %w", inputIndex, err) } @@ -458,7 +459,7 @@ func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, } if spendByTxID.Valid && spendByTxID.Int64 != txID { - return ErrTxInputConflict + return db.ErrTxInputConflict } return nil @@ -481,7 +482,7 @@ func ensureWalletParentValidPg(ctx context.Context, qtx *sqlcpg.Queries, } if hasInvalid { - return ErrTxInputInvalidParent + return db.ErrTxInputInvalidParent } return nil diff --git a/wallet/internal/db/postgres_txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go similarity index 89% rename from wallet/internal/db/postgres_txstore_deletetx.go rename to wallet/internal/db/pg/txstore_deletetx.go index a845fef274..5a4aba797e 100644 --- a/wallet/internal/db/postgres_txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -18,10 +19,10 @@ import ( // transaction must also be a leaf among the wallet's unmined transactions so // the delete cannot detach child spenders from their parent history. func (s *PostgresStore) DeleteTx(ctx context.Context, - params DeleteTxParams) error { + params db.DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return DeleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) + return db.DeleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) }) } @@ -30,7 +31,7 @@ type pgDeleteTxOps struct { qtx *sqlcpg.Queries } -var _ DeleteTxOps = (*pgDeleteTxOps)(nil) +var _ db.DeleteTxOps = (*pgDeleteTxOps)(nil) // LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. @@ -121,7 +122,7 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("list unmined txns: %w", err) } - candidates, err := BuildUnminedTxRecords(rows, + candidates, err := db.BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, @@ -139,9 +140,9 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, filtered = append(filtered, candidate) } - if len(CollectDirectChildTxIDs(txHash, filtered)) > 0 { + if len(db.CollectDirectChildTxIDs(txHash, filtered)) > 0 { return fmt.Errorf("delete tx %s: %w", txHash, - ErrDeleteRequiresLeaf) + db.ErrDeleteRequiresLeaf) } return nil @@ -162,22 +163,22 @@ func getDeleteTxMetaPg(ctx context.Context, qtx *sqlcpg.Queries, if err != nil { if errors.Is(err, sql.ErrNoRows) { return sqlcpg.GetTransactionMetaByHashRow{}, - fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return sqlcpg.GetTransactionMetaByHashRow{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(int64(meta.TxStatus)) + status, err := db.ParseTxStatus(int64(meta.TxStatus)) if err != nil { return sqlcpg.GetTransactionMetaByHashRow{}, err } - if meta.BlockHeight.Valid || !IsUnminedStatus(status) { + if meta.BlockHeight.Valid || !db.IsUnminedStatus(status) { return sqlcpg.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, - ErrDeleteRequiresUnmined) + db.ErrDeleteRequiresUnmined) } return meta, nil diff --git a/wallet/internal/db/postgres_txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go similarity index 81% rename from wallet/internal/db/postgres_txstore_gettx.go rename to wallet/internal/db/pg/txstore_gettx.go index 4e154e0ad8..8e1bbac1bf 100644 --- a/wallet/internal/db/postgres_txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -15,7 +16,7 @@ import ( // The returned TxInfo is rebuilt from normalized SQL columns; missing rows map // to ErrTxNotFound for the requested wallet/hash pair. func (s *PostgresStore) GetTx(ctx context.Context, - query GetTxQuery) (*TxInfo, error) { + query db.GetTxQuery) (*db.TxInfo, error) { row, err := s.queries.GetTransactionByHash( ctx, sqlcpg.GetTransactionByHashParams{ @@ -25,7 +26,7 @@ func (s *PostgresStore) GetTx(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("tx %s: %w", query.Txid, ErrTxNotFound) + return nil, fmt.Errorf("tx %s: %w", query.Txid, db.ErrTxNotFound) } return nil, fmt.Errorf("get tx: %w", err) @@ -41,10 +42,10 @@ func (s *PostgresStore) GetTx(ctx context.Context, // TxInfo shape. func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, blockHeight sql.NullInt32, blockHash []byte, blockTimestamp sql.NullInt64, - status int64, label string) (*TxInfo, error) { + status int64, label string) (*db.TxInfo, error) { var ( - block *Block + block *db.Block err error ) @@ -57,5 +58,5 @@ func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, } } - return BuildTxInfo(hash, rawTx, received, block, status, label) + return db.BuildTxInfo(hash, rawTx, received, block, status, label) } diff --git a/wallet/internal/db/postgres_txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go similarity index 77% rename from wallet/internal/db/postgres_txstore_invalidateunmined.go rename to wallet/internal/db/pg/txstore_invalidateunmined.go index 7eae1db2bf..b99bc24eaa 100644 --- a/wallet/internal/db/postgres_txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -13,10 +14,10 @@ import ( // InvalidateUnminedTx atomically invalidates one wallet-owned unmined // transaction branch and marks the root plus descendants failed. func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, - params InvalidateUnminedTxParams) error { + params db.InvalidateUnminedTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return InvalidateUnminedTxWithOps( + return db.InvalidateUnminedTxWithOps( ctx, params, pgInvalidateUnminedTxOps{qtx: qtx}, ) }) @@ -28,12 +29,12 @@ type pgInvalidateUnminedTxOps struct { qtx *sqlcpg.Queries } -var _ InvalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) +var _ db.InvalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (InvalidateUnminedTxTarget, error) { + walletID uint32, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( ctx, sqlcpg.GetTransactionMetaByHashParams{ @@ -43,20 +44,20 @@ func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, - ErrTxNotFound) + return db.InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, + db.ErrTxNotFound) } - return InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", + return db.InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", err) } - status, err := ParseTxStatus(int64(row.TxStatus)) + status, err := db.ParseTxStatus(int64(row.TxStatus)) if err != nil { - return InvalidateUnminedTxTarget{}, err + return db.InvalidateUnminedTxTarget{}, err } - return InvalidateUnminedTxTarget{ + return db.InvalidateUnminedTxTarget{ ID: row.ID, TxHash: txHash, Status: status, @@ -68,14 +69,14 @@ func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, // ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. func (o pgInvalidateUnminedTxOps) ListUnminedTxRecords( - ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { + ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return BuildUnminedTxRecords(rows, + return db.BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, @@ -111,7 +112,7 @@ func (o pgInvalidateUnminedTxOps) MarkTxnsFailed( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int16(TxStatusFailed), + Status: int16(db.TxStatusFailed), TxIds: txIDs, }, ) diff --git a/wallet/internal/db/postgres_txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go similarity index 85% rename from wallet/internal/db/postgres_txstore_listtxns.go rename to wallet/internal/db/pg/txstore_listtxns.go index 36ed4ed816..8438214d0e 100644 --- a/wallet/internal/db/postgres_txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -1,9 +1,10 @@ -package db +package pg import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -15,7 +16,7 @@ import ( // including retained invalid history such as orphaned or failed transactions, // while the confirmed path is bounded by the requested height range. func (s *PostgresStore) ListTxns(ctx context.Context, - query ListTxnsQuery) ([]TxInfo, error) { + query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { return s.listTxnsWithoutBlockPg(ctx, query.WalletID) @@ -29,14 +30,14 @@ func (s *PostgresStore) ListTxns(ctx context.Context, // retained invalid history that rollback or invalidation flows left without a // confirming block. func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, - walletID uint32) ([]TxInfo, error) { + walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) if err != nil { return nil, fmt.Errorf("list txns without block: %w", err) } - infos := make([]TxInfo, len(rows)) + infos := make([]db.TxInfo, len(rows)) for i, row := range rows { info, err := txInfoFromPgRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, @@ -55,14 +56,14 @@ func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, // listConfirmedTxnsPg loads the confirmed height-range view used by ListTxns // when callers query mined history. func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, - query ListTxnsQuery) ([]TxInfo, error) { + query db.ListTxnsQuery) ([]db.TxInfo, error) { - startHeight, err := Uint32ToInt32(query.StartHeight) + startHeight, err := db.Uint32ToInt32(query.StartHeight) if err != nil { return nil, fmt.Errorf("convert start height: %w", err) } - endHeight, err := Uint32ToInt32(query.EndHeight) + endHeight, err := db.Uint32ToInt32(query.EndHeight) if err != nil { return nil, fmt.Errorf("convert end height: %w", err) } @@ -78,7 +79,7 @@ func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, return nil, fmt.Errorf("list txns by height: %w", err) } - infos := make([]TxInfo, len(rows)) + infos := make([]db.TxInfo, len(rows)) for i, row := range rows { block, err := buildPgBlock( row.BlockHeight, @@ -89,7 +90,7 @@ func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, return nil, err } - info, err := BuildTxInfo( + info, err := db.BuildTxInfo( row.TxHash, row.RawTx, row.ReceivedTime, block, int64(row.TxStatus), row.TxLabel, ) diff --git a/wallet/internal/db/postgres_txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go similarity index 89% rename from wallet/internal/db/postgres_txstore_rollback.go rename to wallet/internal/db/pg/txstore_rollback.go index 626aa9871b..38a33acf23 100644 --- a/wallet/internal/db/postgres_txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -1,9 +1,10 @@ -package db +package pg import ( "context" "database/sql" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -16,7 +17,7 @@ func (s *PostgresStore) RollbackToBlock(ctx context.Context, height uint32) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return RollbackToBlockWithOps(ctx, height, + return db.RollbackToBlockWithOps(ctx, height, pgRollbackToBlockOps{qtx: qtx}) }) } @@ -27,14 +28,14 @@ type pgRollbackToBlockOps struct { qtx *sqlcpg.Queries } -var _ RollbackToBlockOps = (*pgRollbackToBlockOps)(nil) +var _ db.RollbackToBlockOps = (*pgRollbackToBlockOps)(nil) // ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. func (o pgRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { - rollbackHeight, err := Uint32ToInt32(height) + rollbackHeight, err := db.Uint32ToInt32(height) if err != nil { return nil, fmt.Errorf("convert rollback height: %w", err) } @@ -60,14 +61,14 @@ func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( // // TODO(yy): Fix it when we are in year 42000, which will give us 800 years // before it's reached. - rollbackHeight, err := Uint32ToInt32(height) + rollbackHeight, err := db.Uint32ToInt32(height) if err != nil { return fmt.Errorf("convert rollback height: %w", err) } newHeight := sql.NullInt32{} if height > 0 { - newHeight, err = Uint32ToNullInt32(height - 1) + newHeight, err = db.Uint32ToNullInt32(height - 1) if err != nil { return fmt.Errorf("convert new height: %w", err) } @@ -91,7 +92,7 @@ func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( func (o pgRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { - rollbackHeight, err := Uint32ToInt32(height) + rollbackHeight, err := db.Uint32ToInt32(height) if err != nil { return fmt.Errorf("convert rollback height: %w", err) } @@ -117,7 +118,7 @@ func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, sqlcpg.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{}, - Status: int16(TxStatusOrphaned), + Status: int16(db.TxStatusOrphaned), WalletID: int64(walletID), TxHash: txHash[:], }, @@ -127,7 +128,7 @@ func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } } @@ -137,14 +138,14 @@ func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, // ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. func (o pgRollbackToBlockOps) ListUnminedTxRecords( - ctx context.Context, walletID int64) ([]UnminedTxRecord, error) { + ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) if err != nil { return nil, fmt.Errorf("list unmined txns: %w", err) } - return BuildUnminedTxRecords(rows, + return db.BuildUnminedTxRecords(rows, func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, @@ -180,7 +181,7 @@ func (o pgRollbackToBlockOps) MarkDescendantsFailed( _, err := o.qtx.UpdateTransactionStatusByIDs( ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ WalletID: walletID, - Status: int16(TxStatusFailed), + Status: int16(db.TxStatusFailed), TxIds: descendantIDs, }, ) @@ -200,7 +201,7 @@ func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, len(rows), ) for _, row := range rows { - walletID, err := Int64ToUint32(row.WalletID) + walletID, err := db.Int64ToUint32(row.WalletID) if err != nil { return nil, fmt.Errorf("rollback coinbase wallet id: %w", err) } diff --git a/wallet/internal/db/postgres_txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go similarity index 86% rename from wallet/internal/db/postgres_txstore_updatetx.go rename to wallet/internal/db/pg/txstore_updatetx.go index 00ab8fc4b3..34af767a14 100644 --- a/wallet/internal/db/postgres_txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -17,10 +18,10 @@ import ( // spent-input edges stay owned by CreateTx and the internal rollback/delete // flows. func (s *PostgresStore) UpdateTx(ctx context.Context, - params UpdateTxParams) error { + params db.UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return UpdateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) + return db.UpdateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) }) } @@ -38,7 +39,7 @@ type pgUpdateTxOps struct { status int16 } -var _ UpdateTxOps = (*pgUpdateTxOps)(nil) +var _ db.UpdateTxOps = (*pgUpdateTxOps)(nil) // LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. @@ -54,7 +55,7 @@ func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return false, fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return false, fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return false, fmt.Errorf("get tx metadata: %w", err) @@ -66,7 +67,7 @@ func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, // PrepareState validates any referenced confirming block and captures the // postgres-specific state params for the later row update. func (o *pgUpdateTxOps) PrepareState(ctx context.Context, - state UpdateTxState) error { + state db.UpdateTxState) error { blockHeight := sql.NullInt32{} @@ -88,7 +89,7 @@ func (o *pgUpdateTxOps) PrepareState(ctx context.Context, // UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, - txHash chainhash.Hash, _ UpdateTxState) error { + txHash chainhash.Hash, _ db.UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( ctx, @@ -104,7 +105,7 @@ func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return nil @@ -127,7 +128,7 @@ func (o *pgUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, } if rows == 0 { - return fmt.Errorf("tx %s: %w", txHash, ErrTxNotFound) + return fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } return nil diff --git a/wallet/internal/db/pg/utxo_extra_test.go b/wallet/internal/db/pg/utxo_extra_test.go new file mode 100644 index 0000000000..8de30d009d --- /dev/null +++ b/wallet/internal/db/pg/utxo_extra_test.go @@ -0,0 +1,34 @@ +package pg + +import ( + "database/sql" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/stretchr/testify/require" +) + +// TestUtxoInfoFromPgRowInvalidOutputIndex verifies postgres row decoding. +func TestUtxoInfoFromPgRowInvalidOutputIndex(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{15} + _, err := utxoInfoFromPgRow( + hash[:], -1, 1000, []byte{0x59}, time.Unix(1000, 0), false, + sql.NullInt32{}, + ) + require.ErrorContains(t, err, "utxo output index") +} + +// TestUtxoInfoFromPgRowInvalidBlockHeight verifies postgres row decoding. +func TestUtxoInfoFromPgRowInvalidBlockHeight(t *testing.T) { + t.Parallel() + + hash := chainhash.Hash{16} + _, err := utxoInfoFromPgRow( + hash[:], 0, 1000, []byte{0x5a}, time.Unix(1001, 0), false, + sql.NullInt32{Int32: -1, Valid: true}, + ) + require.ErrorContains(t, err, "utxo block height") +} diff --git a/wallet/internal/db/postgres_utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go similarity index 54% rename from wallet/internal/db/postgres_utxostore_balance.go rename to wallet/internal/db/pg/utxostore_balance.go index 22d860a9bd..8c8e6019f2 100644 --- a/wallet/internal/db/postgres_utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -1,8 +1,9 @@ -package db +package pg import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" "github.com/btcsuite/btcd/btcutil" @@ -11,23 +12,23 @@ import ( // Balance returns the sum of wallet-owned current UTXOs after optional filters. func (s *PostgresStore) Balance(ctx context.Context, - params BalanceParams) (BalanceResult, error) { + params db.BalanceParams) (db.BalanceResult, error) { nowUTC := time.Now().UTC() balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), - AccountNumber: NullableUint32ToSQLInt64(params.Account), - MinConfirms: NullableInt32ToSQLInt32(params.MinConfs), - MaxConfirms: NullableInt32ToSQLInt32(params.MaxConfs), - CoinbaseMaturity: NullableInt32ToSQLInt32(params.CoinbaseMaturity), + AccountNumber: db.NullableUint32ToSQLInt64(params.Account), + MinConfirms: db.NullableInt32ToSQLInt32(params.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt32(params.MaxConfs), + CoinbaseMaturity: db.NullableInt32ToSQLInt32(params.CoinbaseMaturity), }) if err != nil { - return BalanceResult{}, fmt.Errorf("balance: %w", err) + return db.BalanceResult{}, fmt.Errorf("balance: %w", err) } - return BalanceResult{ + return db.BalanceResult{ Total: btcutil.Amount(balance.TotalBalance), Locked: btcutil.Amount(balance.LockedBalance), }, nil diff --git a/wallet/internal/db/postgres_utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go similarity index 79% rename from wallet/internal/db/postgres_utxostore_getutxo.go rename to wallet/internal/db/pg/utxostore_getutxo.go index b55ed91759..9e58067f85 100644 --- a/wallet/internal/db/postgres_utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -15,9 +16,9 @@ import ( // The output must still be unspent and its creating transaction must still be // in `pending` or `published` status. func (s *PostgresStore) GetUtxo(ctx context.Context, - query GetUtxoQuery) (*UtxoInfo, error) { + query db.GetUtxoQuery) (*db.UtxoInfo, error) { - outputIndex, err := Uint32ToInt32(query.OutPoint.Index) + outputIndex, err := db.Uint32ToInt32(query.OutPoint.Index) if err != nil { return nil, fmt.Errorf("convert output index: %w", err) } @@ -32,7 +33,7 @@ func (s *PostgresStore) GetUtxo(ctx context.Context, if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, - ErrUtxoNotFound) + db.ErrUtxoNotFound) } return nil, fmt.Errorf("get utxo: %w", err) @@ -48,16 +49,16 @@ func (s *PostgresStore) GetUtxo(ctx context.Context, // UtxoInfo shape. func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, pkScript []byte, received time.Time, isCoinbase bool, - blockHeight sql.NullInt32) (*UtxoInfo, error) { + blockHeight sql.NullInt32) (*db.UtxoInfo, error) { - index, err := Int64ToUint32(int64(outputIndex)) + index, err := db.Int64ToUint32(int64(outputIndex)) if err != nil { return nil, fmt.Errorf("utxo output index: %w", err) } var height *uint32 if blockHeight.Valid { - heightValue, err := NullInt32ToUint32(blockHeight) + heightValue, err := db.NullInt32ToUint32(blockHeight) if err != nil { return nil, fmt.Errorf("utxo block height: %w", err) } @@ -65,7 +66,7 @@ func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, height = &heightValue } - return BuildUtxoInfo( + return db.BuildUtxoInfo( hash, index, amount, pkScript, received, isCoinbase, height, ) } diff --git a/wallet/internal/db/postgres_utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go similarity index 78% rename from wallet/internal/db/postgres_utxostore_leaseoutput.go rename to wallet/internal/db/pg/utxostore_leaseoutput.go index 05e0c7f624..9f17905edf 100644 --- a/wallet/internal/db/postgres_utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -16,12 +17,12 @@ import ( // cannot observe a partially-written lease. Expiration timestamps are // normalized to UTC before Insert. func (s *PostgresStore) LeaseOutput(ctx context.Context, - params LeaseOutputParams) (*LeasedOutput, error) { + params db.LeaseOutputParams) (*db.LeasedOutput, error) { - var lease *LeasedOutput + var lease *db.LeasedOutput err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - acquiredLease, err := LeaseOutputWithOps( + acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &pgLeaseOutputOps{qtx: qtx}, ) if err != nil { @@ -45,15 +46,15 @@ type pgLeaseOutputOps struct { qtx *sqlcpg.Queries } -var _ LeaseOutputOps = (*pgLeaseOutputOps)(nil) +var _ db.LeaseOutputOps = (*pgLeaseOutputOps)(nil) // Acquire attempts to write or renew one postgres lease row for the requested // outpoint. func (o *pgLeaseOutputOps) Acquire(ctx context.Context, - params LeaseOutputParams, nowUTC time.Time, + params db.LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { - outputIndex, err := Uint32ToInt32(params.OutPoint.Index) + outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) if err != nil { return time.Time{}, fmt.Errorf("convert output index: %w", err) } @@ -73,18 +74,18 @@ func (o *pgLeaseOutputOps) Acquire(ctx context.Context, } if errors.Is(err, sql.ErrNoRows) { - return time.Time{}, ErrLeaseOutputNoRow + return time.Time{}, db.ErrLeaseOutputNoRow } - return time.Time{}, fmt.Errorf("Acquire lease row: %w", err) + return time.Time{}, fmt.Errorf("acquire lease row: %w", err) } // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. func (o *pgLeaseOutputOps) HasUtxo(ctx context.Context, - params LeaseOutputParams) (bool, error) { + params db.LeaseOutputParams) (bool, error) { - outputIndex, err := Uint32ToInt32(params.OutPoint.Index) + outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) if err != nil { return false, fmt.Errorf("convert output index: %w", err) } diff --git a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go similarity index 74% rename from wallet/internal/db/postgres_utxostore_listleasedoutputs.go rename to wallet/internal/db/pg/utxostore_listleasedoutputs.go index 79f8d522f2..846b17b9ad 100644 --- a/wallet/internal/db/postgres_utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -1,8 +1,9 @@ -package db +package pg import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -10,7 +11,7 @@ import ( // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, - walletID uint32) ([]LeasedOutput, error) { + walletID uint32) ([]db.LeasedOutput, error) { nowUTC := time.Now().UTC() @@ -24,14 +25,14 @@ func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, return nil, fmt.Errorf("list active utxo leases: %w", err) } - leases := make([]LeasedOutput, len(rows)) + leases := make([]db.LeasedOutput, len(rows)) for i, row := range rows { - outputIndex, err := Int64ToUint32(int64(row.OutputIndex)) + outputIndex, err := db.Int64ToUint32(int64(row.OutputIndex)) if err != nil { return nil, fmt.Errorf("lease output index: %w", err) } - lease, err := BuildLeasedOutput( + lease, err := db.BuildLeasedOutput( row.TxHash, outputIndex, row.LockID, row.ExpiresAt, ) if err != nil { diff --git a/wallet/internal/db/postgres_utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go similarity index 69% rename from wallet/internal/db/postgres_utxostore_listutxos.go rename to wallet/internal/db/pg/utxostore_listutxos.go index fbbb76666b..ccf2376e5a 100644 --- a/wallet/internal/db/postgres_utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -1,8 +1,9 @@ -package db +package pg import ( "context" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) @@ -12,14 +13,14 @@ import ( // The result set is already constrained to outputs whose creating // transactions are still in `pending` or `published` status. func (s *PostgresStore) ListUTXOs(ctx context.Context, - query ListUtxosQuery) ([]UtxoInfo, error) { + query db.ListUtxosQuery) ([]db.UtxoInfo, error) { rows, err := s.queries.ListUtxos(ctx, buildListUtxosParamsPg(query)) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) } - utxos := make([]UtxoInfo, len(rows)) + utxos := make([]db.UtxoInfo, len(rows)) for i, row := range rows { utxo, err := utxoInfoFromPgRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, @@ -37,11 +38,11 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, // buildListUtxosParamsPg prepares the typed nullable filters required by the // postgres ListUtxos query. -func buildListUtxosParamsPg(query ListUtxosQuery) sqlcpg.ListUtxosParams { +func buildListUtxosParamsPg(query db.ListUtxosQuery) sqlcpg.ListUtxosParams { return sqlcpg.ListUtxosParams{ WalletID: int64(query.WalletID), - AccountNumber: NullableUint32ToSQLInt64(query.Account), - MinConfirms: NullableInt32ToSQLInt32(query.MinConfs), - MaxConfirms: NullableInt32ToSQLInt32(query.MaxConfs), + AccountNumber: db.NullableUint32ToSQLInt64(query.Account), + MinConfirms: db.NullableInt32ToSQLInt32(query.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt32(query.MaxConfs), } } diff --git a/wallet/internal/db/postgres_utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go similarity index 80% rename from wallet/internal/db/postgres_utxostore_releaseoutput.go rename to wallet/internal/db/pg/utxostore_releaseoutput.go index 5557e03a5b..ff6b04fc8e 100644 --- a/wallet/internal/db/postgres_utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -1,10 +1,11 @@ -package db +package pg import ( "context" "database/sql" "errors" "fmt" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" @@ -16,10 +17,10 @@ import ( // The ownership check and lease deletion run in one transaction so callers // cannot unlock a UTXO using stale state from a separate read. func (s *PostgresStore) ReleaseOutput(ctx context.Context, - params ReleaseOutputParams) error { + params db.ReleaseOutputParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return ReleaseOutputWithOps( + return db.ReleaseOutputWithOps( ctx, params, &pgReleaseOutputOps{qtx: qtx}, ) }) @@ -31,14 +32,14 @@ type pgReleaseOutputOps struct { qtx *sqlcpg.Queries } -var _ ReleaseOutputOps = (*pgReleaseOutputOps)(nil) +var _ db.ReleaseOutputOps = (*pgReleaseOutputOps)(nil) // LookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO // row ID. func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, - params ReleaseOutputParams) (int64, error) { + params db.ReleaseOutputParams) (int64, error) { - outputIndex, err := Uint32ToInt32(params.OutPoint.Index) + outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) if err != nil { return 0, err } @@ -52,7 +53,7 @@ func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return 0, ErrReleaseOutputUtxoNotFound + return 0, db.ErrReleaseOutputUtxoNotFound } return 0, fmt.Errorf("lookup utxo row: %w", err) @@ -74,7 +75,7 @@ func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, }, ) if err != nil { - return 0, fmt.Errorf("Release lease row: %w", err) + return 0, fmt.Errorf("release lease row: %w", err) } return rows, nil @@ -85,7 +86,7 @@ func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, func (o *pgReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { - ActiveLockID, err := o.qtx.GetActiveUtxoLeaseLockID( + activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( ctx, sqlcpg.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, @@ -94,11 +95,11 @@ func (o *pgReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, ErrReleaseOutputNoActiveLease + return nil, db.ErrReleaseOutputNoActiveLease } return nil, fmt.Errorf("lookup active lease row: %w", err) } - return ActiveLockID, nil + return activeLockID, nil } diff --git a/wallet/internal/db/wallet_pg.go b/wallet/internal/db/pg/wallet.go similarity index 89% rename from wallet/internal/db/wallet_pg.go rename to wallet/internal/db/pg/wallet.go index 8d3f212c77..f9a8242ddf 100644 --- a/wallet/internal/db/wallet_pg.go +++ b/wallet/internal/db/pg/wallet.go @@ -1,4 +1,4 @@ -package db +package pg import ( "context" @@ -7,20 +7,21 @@ import ( "fmt" "iter" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" ) // Ensure PostgresStore satisfies the WalletStore interface. -var _ WalletStore = (*PostgresStore)(nil) +var _ db.WalletStore = (*PostgresStore)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. func (s *PostgresStore) CreateWallet(ctx context.Context, - params CreateWalletParams) (*WalletInfo, error) { + params db.CreateWalletParams) (*db.WalletInfo, error) { - var info *WalletInfo + var info *db.WalletInfo err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { walletParams := sqlcpg.CreateWalletParams{ @@ -114,13 +115,13 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. func (s *PostgresStore) GetWallet(ctx context.Context, - name string) (*WalletInfo, error) { + name string) (*db.WalletInfo, error) { row, err := s.queries.GetWalletByName(ctx, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("wallet %q: %w", name, - ErrWalletNotFound) + db.ErrWalletNotFound) } return nil, fmt.Errorf("get wallet: %w", err) @@ -144,19 +145,19 @@ func (s *PostgresStore) GetWallet(ctx context.Context, // ListWallets returns a page of wallets matching the given query. func (s *PostgresStore) ListWallets(ctx context.Context, - query ListWalletsQuery) (page.Result[WalletInfo, uint32], error) { + query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets(ctx, pgListWalletsParams(query.Page)) if err != nil { - return page.Result[WalletInfo, uint32]{}, + return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: %w", err) } - items := make([]WalletInfo, len(rows)) + items := make([]db.WalletInfo, len(rows)) for i, row := range rows { item, errMap := pgListWalletRowToInfo(row) if errMap != nil { - return page.Result[WalletInfo, uint32]{}, + return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: map row: %w", errMap) } @@ -165,7 +166,7 @@ func (s *PostgresStore) ListWallets(ctx context.Context, result := page.BuildResult( query.Page, items, - func(item WalletInfo) uint32 { + func(item db.WalletInfo) uint32 { return item.ID }, ) @@ -175,10 +176,10 @@ func (s *PostgresStore) ListWallets(ctx context.Context, // IterWallets returns an iterator over paginated wallet results. func (s *PostgresStore) IterWallets(ctx context.Context, - query ListWalletsQuery) iter.Seq2[WalletInfo, error] { + query db.ListWalletsQuery) iter.Seq2[db.WalletInfo, error] { return page.Iter( - ctx, query, s.ListWallets, NextListWalletsQuery, + ctx, query, s.ListWallets, db.NextListWalletsQuery, ) } @@ -187,7 +188,7 @@ func (s *PostgresStore) IterWallets(ctx context.Context, // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. func (s *PostgresStore) UpdateWallet(ctx context.Context, - params UpdateWalletParams) error { + params db.UpdateWalletParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { // Insert blocks if needed. @@ -221,7 +222,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, if rowsAffected == 0 { return fmt.Errorf("wallet sync state for wallet %d: %w", - params.WalletID, ErrWalletNotFound) + params.WalletID, db.ErrWalletNotFound) } return nil @@ -239,7 +240,7 @@ func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("secrets for wallet %d: %w", - walletID, ErrWalletNotFound) + walletID, db.ErrWalletNotFound) } return nil, fmt.Errorf("get wallet secrets: %w", err) @@ -248,7 +249,7 @@ func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, if len(secrets.EncryptedMasterHdPrivKey) == 0 { return nil, fmt.Errorf( "encrypted master privkey for wallet %d: %w", walletID, - ErrSecretNotFound) + db.ErrSecretNotFound) } return secrets.EncryptedMasterHdPrivKey, nil @@ -256,7 +257,7 @@ func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, // UpdateWalletSecrets updates the secrets for the wallet. func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, - params UpdateWalletSecretsParams) error { + params db.UpdateWalletSecretsParams) error { secretsParams := sqlcpg.UpdateWalletSecretsParams{ MasterPrivParams: params.MasterPrivParams, @@ -273,7 +274,7 @@ func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, if rowsAffected == 0 { return fmt.Errorf("wallet secrets for wallet %d: %w", - params.WalletID, ErrWalletNotFound) + params.WalletID, db.ErrWalletNotFound) } return nil @@ -298,7 +299,7 @@ type pgWalletRowParams struct { // pgListWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. -func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*WalletInfo, error) { +func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { return buildPgWalletInfo(pgWalletRowParams{ id: row.ID, name: row.WalletName, @@ -336,13 +337,13 @@ func pgListWalletsParams( // buildPgWalletInfo constructs a WalletInfo from the given wallet row // parameters. -func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { - walletID, err := Int64ToUint32(row.id) +func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { + walletID, err := db.Int64ToUint32(row.id) if err != nil { return nil, err } - info := &WalletInfo{ + info := &db.WalletInfo{ ID: walletID, Name: row.name, IsImported: row.isImported, @@ -385,7 +386,7 @@ func buildPgWalletInfo(row pgWalletRowParams) (*WalletInfo, error) { // buildUpdateSyncParamsPg constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. -func buildUpdateSyncParamsPg(params UpdateWalletParams) ( +func buildUpdateSyncParamsPg(params db.UpdateWalletParams) ( sqlcpg.UpdateWalletSyncStateParams, error) { syncParams := sqlcpg.UpdateWalletSyncStateParams{ @@ -393,7 +394,7 @@ func buildUpdateSyncParamsPg(params UpdateWalletParams) ( } if params.SyncedTo != nil { - syncedHeight, err := Uint32ToNullInt32(params.SyncedTo.Height) + syncedHeight, err := db.Uint32ToNullInt32(params.SyncedTo.Height) if err != nil { return syncParams, err } @@ -409,7 +410,7 @@ func buildUpdateSyncParamsPg(params UpdateWalletParams) ( } if params.BirthdayBlock != nil { - birthdayHeight, err := Uint32ToNullInt32( + birthdayHeight, err := db.Uint32ToNullInt32( params.BirthdayBlock.Height, ) if err != nil { From d0321be0c23a17d5facab15cac5c4442fbfbe6dc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:28:51 +0800 Subject: [PATCH 533/691] sql/sqlite: move sql assets Move the SQLite migrations, handwritten queries, and generated sqlc output into wallet/internal/sql/sqlite. The SQLite store and sqlite itest helpers now import the moved sqlc package, and migration loading comes from the new sqlite SQL asset package. This keeps the asset move backend-scoped so the later postgres SQL asset split can follow the same path independently. --- sqlc.yaml | 6 +-- .../internal/db/itest/fixtures_sqlite_test.go | 2 +- wallet/internal/db/itest/sqlite_test.go | 2 +- wallet/internal/db/migrations.go | 15 ------ wallet/internal/db/pg/backend_rows_test.go | 1 + wallet/internal/db/sqlite/accounts.go | 2 +- wallet/internal/db/sqlite/address_types.go | 2 +- wallet/internal/db/sqlite/addresses.go | 2 +- .../internal/db/sqlite/backend_error_test.go | 2 +- .../internal/db/sqlite/backend_rows_test.go | 2 +- wallet/internal/db/sqlite/block.go | 2 +- wallet/internal/db/sqlite/itest.go | 2 +- wallet/internal/db/sqlite/store.go | 6 +-- wallet/internal/db/sqlite/txstore_createtx.go | 2 +- wallet/internal/db/sqlite/txstore_deletetx.go | 2 +- wallet/internal/db/sqlite/txstore_gettx.go | 2 +- .../db/sqlite/txstore_invalidateunmined.go | 2 +- wallet/internal/db/sqlite/txstore_listtxns.go | 2 +- wallet/internal/db/sqlite/txstore_rollback.go | 2 +- wallet/internal/db/sqlite/txstore_updatetx.go | 2 +- .../internal/db/sqlite/utxostore_balance.go | 2 +- .../internal/db/sqlite/utxostore_getutxo.go | 2 +- .../db/sqlite/utxostore_leaseoutput.go | 2 +- .../db/sqlite/utxostore_listleasedoutputs.go | 2 +- .../internal/db/sqlite/utxostore_listutxos.go | 2 +- .../db/sqlite/utxostore_releaseoutput.go | 2 +- wallet/internal/db/sqlite/wallet.go | 2 +- wallet/internal/sql/sqlite/doc.go | 2 + wallet/internal/sql/sqlite/migrations.go | 50 +++++++++++++++++++ .../sqlite/migrations}/000001_blocks.down.sql | 0 .../sqlite/migrations}/000001_blocks.up.sql | 0 .../migrations}/000002_wallets.down.sql | 0 .../sqlite/migrations}/000002_wallets.up.sql | 0 .../migrations}/000003_address_types.down.sql | 0 .../migrations}/000003_address_types.up.sql | 0 .../migrations}/000004_key_scopes.down.sql | 0 .../migrations}/000004_key_scopes.up.sql | 0 .../migrations}/000005_accounts.down.sql | 0 .../sqlite/migrations}/000005_accounts.up.sql | 0 .../migrations}/000006_addresses.down.sql | 0 .../migrations}/000006_addresses.up.sql | 0 .../migrations}/000007_transactions.down.sql | 0 .../migrations}/000007_transactions.up.sql | 0 .../sqlite/migrations}/000008_utxos.down.sql | 0 .../sqlite/migrations}/000008_utxos.up.sql | 0 .../000009_tx_replacements.down.sql | 0 .../migrations}/000009_tx_replacements.up.sql | 0 .../migrations}/000010_utxo_leases.down.sql | 0 .../migrations}/000010_utxo_leases.up.sql | 0 .../sqlite/queries}/accounts.sql | 0 .../sqlite/queries}/address_types.sql | 0 .../sqlite/queries}/addresses.sql | 0 .../sqlite => sql/sqlite/queries}/blocks.sql | 0 .../sqlite/queries}/key_scopes.sql | 0 .../sqlite/queries}/transactions.sql | 0 .../sqlite/queries}/tx_replacements.sql | 0 .../sqlite/queries}/utxo_leases.sql | 0 .../sqlite => sql/sqlite/queries}/utxos.sql | 0 .../sqlite => sql/sqlite/queries}/wallets.sql | 0 .../sqlite/sqlc}/accounts.sql.go | 0 .../sqlite/sqlc}/address_types.sql.go | 0 .../sqlite/sqlc}/addresses.sql.go | 0 .../sqlite => sql/sqlite/sqlc}/blocks.sql.go | 0 .../{db/sqlc/sqlite => sql/sqlite/sqlc}/db.go | 0 .../sqlite/sqlc}/key_scopes.sql.go | 0 .../sqlc/sqlite => sql/sqlite/sqlc}/models.go | 0 .../sqlite => sql/sqlite/sqlc}/querier.go | 0 .../sqlite/sqlc}/transactions.sql.go | 0 .../sqlite/sqlc}/tx_replacements.sql.go | 0 .../sqlite/sqlc}/utxo_leases.sql.go | 0 .../sqlite => sql/sqlite/sqlc}/utxos.sql.go | 0 .../sqlite => sql/sqlite/sqlc}/wallets.sql.go | 0 72 files changed, 82 insertions(+), 44 deletions(-) create mode 100644 wallet/internal/sql/sqlite/doc.go create mode 100644 wallet/internal/sql/sqlite/migrations.go rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000001_blocks.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000001_blocks.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000002_wallets.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000002_wallets.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000003_address_types.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000003_address_types.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000004_key_scopes.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000004_key_scopes.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000005_accounts.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000005_accounts.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000006_addresses.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000006_addresses.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000007_transactions.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000007_transactions.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000008_utxos.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000008_utxos.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000009_tx_replacements.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000009_tx_replacements.up.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000010_utxo_leases.down.sql (100%) rename wallet/internal/{db/migrations/sqlite => sql/sqlite/migrations}/000010_utxo_leases.up.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/accounts.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/address_types.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/addresses.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/blocks.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/key_scopes.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/transactions.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/tx_replacements.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/utxo_leases.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/utxos.sql (100%) rename wallet/internal/{db/queries/sqlite => sql/sqlite/queries}/wallets.sql (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/accounts.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/address_types.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/addresses.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/blocks.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/db.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/key_scopes.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/models.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/querier.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/transactions.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/tx_replacements.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/utxo_leases.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/utxos.sql.go (100%) rename wallet/internal/{db/sqlc/sqlite => sql/sqlite/sqlc}/wallets.sql.go (100%) diff --git a/sqlc.yaml b/sqlc.yaml index 3bed14788a..9b322becd0 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -24,11 +24,11 @@ sql: emit_prepared_queries: true - engine: "sqlite" - schema: "wallet/internal/db/migrations/sqlite" - queries: "wallet/internal/db/queries/sqlite" + schema: "wallet/internal/sql/sqlite/migrations" + queries: "wallet/internal/sql/sqlite/queries" gen: go: - out: "wallet/internal/db/sqlc/sqlite" + out: "wallet/internal/sql/sqlite/sqlc" package: "sqlcsqlite" # This is the driver package that sqlc will use in the generated code. diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index a3a4cae69c..cb0a8d5d6c 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -10,7 +10,7 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index 6f7712c648..cd066cfc7d 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -11,7 +11,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/migrations.go b/wallet/internal/db/migrations.go index cc929d1f35..f6a419d7ea 100644 --- a/wallet/internal/db/migrations.go +++ b/wallet/internal/db/migrations.go @@ -10,13 +10,9 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database" "github.com/golang-migrate/migrate/v4/database/postgres" - "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" ) -//go:embed migrations/sqlite/*.sql -var sqliteFS embed.FS - //go:embed migrations/postgres/*.sql var postgresFS embed.FS @@ -61,17 +57,6 @@ func applyMigrations(db *sql.DB, migrationFS fs.FS, path string, dbName string, return nil } -// ApplySQLiteMigrations applies all SQLite migrations to the database. -// -// NOTE: not ready for production use. -func ApplySQLiteMigrations(db *sql.DB) error { - return applyMigrations(db, sqliteFS, "migrations/sqlite", "sqlite", - func(db *sql.DB) (database.Driver, error) { - return sqlite.WithInstance(db, &sqlite.Config{}) - }, - ) -} - // ApplyPostgresMigrations applies all PostgreSQL migrations to the database. // // NOTE: not ready for production use. diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index 0ffad6e2de..f30fc3866d 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" ) // staticResult is a minimal sql.Result stub with a caller-controlled row count. diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index 50a656dad8..66c96b48b5 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -6,7 +6,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Ensure SqliteStore satisfies the AccountStore interface. diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index d95b24593b..bdea9bb339 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -4,7 +4,7 @@ import ( "context" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // sqliteAddressTypeRowToInfo converts a SQLite address type row to an diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index a99357e37d..c98845a245 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -9,7 +9,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) var _ db.AddressStore = (*SqliteStore)(nil) diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go index dc7b34839c..e3a911c4a5 100644 --- a/wallet/internal/db/sqlite/backend_error_test.go +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -7,7 +7,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go index 59afe85d32..cad0e7989e 100644 --- a/wallet/internal/db/sqlite/backend_rows_test.go +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/block.go b/wallet/internal/db/sqlite/block.go index abf6c938b3..5306caeeca 100644 --- a/wallet/internal/db/sqlite/block.go +++ b/wallet/internal/db/sqlite/block.go @@ -8,7 +8,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // buildSqliteBlock constructs a Block from the given SQLite block diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go index f5204d8dc4..16144c37a7 100644 --- a/wallet/internal/db/sqlite/itest.go +++ b/wallet/internal/db/sqlite/itest.go @@ -5,7 +5,7 @@ package sqlite import ( "database/sql" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index a06ab5b188..5fce98f1e7 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -6,8 +6,8 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) @@ -61,7 +61,7 @@ func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, queries := sqlcsqlite.New(dbConn) - err = db.ApplySQLiteMigrations(dbConn) + err = sqlassetsqlite.ApplyMigrations(dbConn) if err != nil { _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 8336fb5af7..0d4b24c19e 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its wallet-owned diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index 848ea65d52..7f27135124 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index b90fde980a..fc8048fa28 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index c8f0522a5d..7c4d10745a 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index c332406f39..673f953171 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -6,7 +6,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index cb5d98bafd..aee979fd70 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -7,7 +7,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index 63a9d1b6b0..4e0e514947 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. diff --git a/wallet/internal/db/sqlite/utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go index a90351d662..a4f094c892 100644 --- a/wallet/internal/db/sqlite/utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -7,7 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index 30a3bf3adb..a63615b790 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index 2874ca98f7..177644f847 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. diff --git a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index 52d6a03f93..b4f1b70d68 100644 --- a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -6,7 +6,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index 1efb716c5b..dd47d02cfb 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -5,7 +5,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index 461fe4e1cb..dd716d9228 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index f58cf5a500..8e687e253b 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -9,7 +9,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/sqlite" + sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Ensure SqliteStore satisfies the WalletStore interface. diff --git a/wallet/internal/sql/sqlite/doc.go b/wallet/internal/sql/sqlite/doc.go new file mode 100644 index 0000000000..8293bddf01 --- /dev/null +++ b/wallet/internal/sql/sqlite/doc.go @@ -0,0 +1,2 @@ +// Package sqlite contains SQLite SQL assets and migration helpers. +package sqlite diff --git a/wallet/internal/sql/sqlite/migrations.go b/wallet/internal/sql/sqlite/migrations.go new file mode 100644 index 0000000000..9bea555e9c --- /dev/null +++ b/wallet/internal/sql/sqlite/migrations.go @@ -0,0 +1,50 @@ +package sqlite + +import ( + "database/sql" + "embed" + "errors" + "fmt" + + gomigrate "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database" + migrate "github.com/golang-migrate/migrate/v4/database/sqlite" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationFS embed.FS + +type driverFactory func(*sql.DB) (database.Driver, error) + +// applyMigrations applies all embedded sqlite migrations to one database. +func applyMigrations(db *sql.DB, newDriver driverFactory) error { + sourceDriver, err := iofs.New(migrationFS, "migrations") + if err != nil { + return fmt.Errorf("create source driver: %w", err) + } + + driver, err := newDriver(db) + if err != nil { + return fmt.Errorf("create sqlite driver: %w", err) + } + + m, err := gomigrate.NewWithInstance("iofs", sourceDriver, "sqlite", driver) + if err != nil { + return fmt.Errorf("create migrate instance: %w", err) + } + + err = m.Up() + if err != nil && !errors.Is(err, gomigrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + + return nil +} + +// ApplyMigrations applies all SQLite migrations to the database. +func ApplyMigrations(db *sql.DB) error { + return applyMigrations(db, func(db *sql.DB) (database.Driver, error) { + return migrate.WithInstance(db, &migrate.Config{}) + }) +} diff --git a/wallet/internal/db/migrations/sqlite/000001_blocks.down.sql b/wallet/internal/sql/sqlite/migrations/000001_blocks.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000001_blocks.down.sql rename to wallet/internal/sql/sqlite/migrations/000001_blocks.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000001_blocks.up.sql b/wallet/internal/sql/sqlite/migrations/000001_blocks.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000001_blocks.up.sql rename to wallet/internal/sql/sqlite/migrations/000001_blocks.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.down.sql b/wallet/internal/sql/sqlite/migrations/000002_wallets.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000002_wallets.down.sql rename to wallet/internal/sql/sqlite/migrations/000002_wallets.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000002_wallets.up.sql b/wallet/internal/sql/sqlite/migrations/000002_wallets.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000002_wallets.up.sql rename to wallet/internal/sql/sqlite/migrations/000002_wallets.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.down.sql b/wallet/internal/sql/sqlite/migrations/000003_address_types.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000003_address_types.down.sql rename to wallet/internal/sql/sqlite/migrations/000003_address_types.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000003_address_types.up.sql b/wallet/internal/sql/sqlite/migrations/000003_address_types.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000003_address_types.up.sql rename to wallet/internal/sql/sqlite/migrations/000003_address_types.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql b/wallet/internal/sql/sqlite/migrations/000004_key_scopes.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000004_key_scopes.down.sql rename to wallet/internal/sql/sqlite/migrations/000004_key_scopes.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql b/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000004_key_scopes.up.sql rename to wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.down.sql b/wallet/internal/sql/sqlite/migrations/000005_accounts.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000005_accounts.down.sql rename to wallet/internal/sql/sqlite/migrations/000005_accounts.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000005_accounts.up.sql b/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000005_accounts.up.sql rename to wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.down.sql b/wallet/internal/sql/sqlite/migrations/000006_addresses.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000006_addresses.down.sql rename to wallet/internal/sql/sqlite/migrations/000006_addresses.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000006_addresses.up.sql b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000006_addresses.up.sql rename to wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.down.sql b/wallet/internal/sql/sqlite/migrations/000007_transactions.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000007_transactions.down.sql rename to wallet/internal/sql/sqlite/migrations/000007_transactions.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000007_transactions.up.sql b/wallet/internal/sql/sqlite/migrations/000007_transactions.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000007_transactions.up.sql rename to wallet/internal/sql/sqlite/migrations/000007_transactions.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000008_utxos.down.sql b/wallet/internal/sql/sqlite/migrations/000008_utxos.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000008_utxos.down.sql rename to wallet/internal/sql/sqlite/migrations/000008_utxos.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000008_utxos.up.sql b/wallet/internal/sql/sqlite/migrations/000008_utxos.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000008_utxos.up.sql rename to wallet/internal/sql/sqlite/migrations/000008_utxos.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql b/wallet/internal/sql/sqlite/migrations/000009_tx_replacements.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000009_tx_replacements.down.sql rename to wallet/internal/sql/sqlite/migrations/000009_tx_replacements.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql b/wallet/internal/sql/sqlite/migrations/000009_tx_replacements.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000009_tx_replacements.up.sql rename to wallet/internal/sql/sqlite/migrations/000009_tx_replacements.up.sql diff --git a/wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql b/wallet/internal/sql/sqlite/migrations/000010_utxo_leases.down.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000010_utxo_leases.down.sql rename to wallet/internal/sql/sqlite/migrations/000010_utxo_leases.down.sql diff --git a/wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql b/wallet/internal/sql/sqlite/migrations/000010_utxo_leases.up.sql similarity index 100% rename from wallet/internal/db/migrations/sqlite/000010_utxo_leases.up.sql rename to wallet/internal/sql/sqlite/migrations/000010_utxo_leases.up.sql diff --git a/wallet/internal/db/queries/sqlite/accounts.sql b/wallet/internal/sql/sqlite/queries/accounts.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/accounts.sql rename to wallet/internal/sql/sqlite/queries/accounts.sql diff --git a/wallet/internal/db/queries/sqlite/address_types.sql b/wallet/internal/sql/sqlite/queries/address_types.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/address_types.sql rename to wallet/internal/sql/sqlite/queries/address_types.sql diff --git a/wallet/internal/db/queries/sqlite/addresses.sql b/wallet/internal/sql/sqlite/queries/addresses.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/addresses.sql rename to wallet/internal/sql/sqlite/queries/addresses.sql diff --git a/wallet/internal/db/queries/sqlite/blocks.sql b/wallet/internal/sql/sqlite/queries/blocks.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/blocks.sql rename to wallet/internal/sql/sqlite/queries/blocks.sql diff --git a/wallet/internal/db/queries/sqlite/key_scopes.sql b/wallet/internal/sql/sqlite/queries/key_scopes.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/key_scopes.sql rename to wallet/internal/sql/sqlite/queries/key_scopes.sql diff --git a/wallet/internal/db/queries/sqlite/transactions.sql b/wallet/internal/sql/sqlite/queries/transactions.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/transactions.sql rename to wallet/internal/sql/sqlite/queries/transactions.sql diff --git a/wallet/internal/db/queries/sqlite/tx_replacements.sql b/wallet/internal/sql/sqlite/queries/tx_replacements.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/tx_replacements.sql rename to wallet/internal/sql/sqlite/queries/tx_replacements.sql diff --git a/wallet/internal/db/queries/sqlite/utxo_leases.sql b/wallet/internal/sql/sqlite/queries/utxo_leases.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/utxo_leases.sql rename to wallet/internal/sql/sqlite/queries/utxo_leases.sql diff --git a/wallet/internal/db/queries/sqlite/utxos.sql b/wallet/internal/sql/sqlite/queries/utxos.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/utxos.sql rename to wallet/internal/sql/sqlite/queries/utxos.sql diff --git a/wallet/internal/db/queries/sqlite/wallets.sql b/wallet/internal/sql/sqlite/queries/wallets.sql similarity index 100% rename from wallet/internal/db/queries/sqlite/wallets.sql rename to wallet/internal/sql/sqlite/queries/wallets.sql diff --git a/wallet/internal/db/sqlc/sqlite/accounts.sql.go b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/accounts.sql.go rename to wallet/internal/sql/sqlite/sqlc/accounts.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/address_types.sql.go b/wallet/internal/sql/sqlite/sqlc/address_types.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/address_types.sql.go rename to wallet/internal/sql/sqlite/sqlc/address_types.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/addresses.sql.go b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/addresses.sql.go rename to wallet/internal/sql/sqlite/sqlc/addresses.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/blocks.sql.go b/wallet/internal/sql/sqlite/sqlc/blocks.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/blocks.sql.go rename to wallet/internal/sql/sqlite/sqlc/blocks.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/db.go b/wallet/internal/sql/sqlite/sqlc/db.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/db.go rename to wallet/internal/sql/sqlite/sqlc/db.go diff --git a/wallet/internal/db/sqlc/sqlite/key_scopes.sql.go b/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/key_scopes.sql.go rename to wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/models.go b/wallet/internal/sql/sqlite/sqlc/models.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/models.go rename to wallet/internal/sql/sqlite/sqlc/models.go diff --git a/wallet/internal/db/sqlc/sqlite/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/querier.go rename to wallet/internal/sql/sqlite/sqlc/querier.go diff --git a/wallet/internal/db/sqlc/sqlite/transactions.sql.go b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/transactions.sql.go rename to wallet/internal/sql/sqlite/sqlc/transactions.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go b/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/tx_replacements.sql.go rename to wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go b/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/utxo_leases.sql.go rename to wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/utxos.sql.go b/wallet/internal/sql/sqlite/sqlc/utxos.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/utxos.sql.go rename to wallet/internal/sql/sqlite/sqlc/utxos.sql.go diff --git a/wallet/internal/db/sqlc/sqlite/wallets.sql.go b/wallet/internal/sql/sqlite/sqlc/wallets.sql.go similarity index 100% rename from wallet/internal/db/sqlc/sqlite/wallets.sql.go rename to wallet/internal/sql/sqlite/sqlc/wallets.sql.go From 29419d448040c054041acd1cc8da8e5e978be103 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:31:06 +0800 Subject: [PATCH 534/691] sql/pg: move sql assets Move the PostgreSQL migrations, handwritten queries, and generated sqlc output into wallet/internal/sql/pg. The PostgreSQL store and pg itest helpers now import the moved sqlc package, and migration loading comes from the new pg SQL asset package. This completes the backend-scoped SQL asset split so both backends now follow the same internal/sql layout. --- Makefile | 15 ++-- sqlc.yaml | 6 +- wallet/internal/db/itest/fixtures_pg_test.go | 2 +- wallet/internal/db/itest/pg_test.go | 2 +- wallet/internal/db/migrations.go | 69 ------------------- wallet/internal/db/pg/accounts.go | 2 +- wallet/internal/db/pg/address_types.go | 2 +- wallet/internal/db/pg/addresses.go | 2 +- wallet/internal/db/pg/backend_error_test.go | 2 +- wallet/internal/db/pg/backend_rows_test.go | 2 +- wallet/internal/db/pg/block.go | 2 +- wallet/internal/db/pg/itest.go | 2 +- wallet/internal/db/pg/store.go | 5 +- wallet/internal/db/pg/txstore_createtx.go | 2 +- wallet/internal/db/pg/txstore_deletetx.go | 2 +- wallet/internal/db/pg/txstore_gettx.go | 2 +- .../db/pg/txstore_invalidateunmined.go | 2 +- wallet/internal/db/pg/txstore_listtxns.go | 2 +- wallet/internal/db/pg/txstore_rollback.go | 2 +- wallet/internal/db/pg/txstore_updatetx.go | 2 +- wallet/internal/db/pg/utxostore_balance.go | 2 +- wallet/internal/db/pg/utxostore_getutxo.go | 2 +- .../internal/db/pg/utxostore_leaseoutput.go | 2 +- .../db/pg/utxostore_listleasedoutputs.go | 2 +- wallet/internal/db/pg/utxostore_listutxos.go | 2 +- .../internal/db/pg/utxostore_releaseoutput.go | 2 +- wallet/internal/db/pg/wallet.go | 2 +- wallet/internal/sql/pg/doc.go | 2 + wallet/internal/sql/pg/migrations.go | 52 ++++++++++++++ .../pg/migrations}/000001_blocks.down.sql | 0 .../pg/migrations}/000001_blocks.up.sql | 0 .../pg/migrations}/000002_wallets.down.sql | 0 .../pg/migrations}/000002_wallets.up.sql | 0 .../migrations}/000003_address_types.down.sql | 0 .../migrations}/000003_address_types.up.sql | 0 .../pg/migrations}/000004_key_scopes.down.sql | 0 .../pg/migrations}/000004_key_scopes.up.sql | 0 .../pg/migrations}/000005_accounts.down.sql | 0 .../pg/migrations}/000005_accounts.up.sql | 0 .../pg/migrations}/000006_addresses.down.sql | 0 .../pg/migrations}/000006_addresses.up.sql | 0 .../migrations}/000007_transactions.down.sql | 0 .../pg/migrations}/000007_transactions.up.sql | 0 .../pg/migrations}/000008_utxos.down.sql | 0 .../pg/migrations}/000008_utxos.up.sql | 0 .../000009_tx_replacements.down.sql | 0 .../migrations}/000009_tx_replacements.up.sql | 0 .../migrations}/000010_utxo_leases.down.sql | 0 .../pg/migrations}/000010_utxo_leases.up.sql | 0 .../postgres => sql/pg/queries}/accounts.sql | 0 .../pg/queries}/address_types.sql | 0 .../postgres => sql/pg/queries}/addresses.sql | 0 .../postgres => sql/pg/queries}/blocks.sql | 0 .../pg/queries}/key_scopes.sql | 0 .../pg/queries}/transactions.sql | 0 .../pg/queries}/tx_replacements.sql | 0 .../pg/queries}/utxo_leases.sql | 0 .../postgres => sql/pg/queries}/utxos.sql | 0 .../postgres => sql/pg/queries}/wallets.sql | 0 .../postgres => sql/pg/sqlc}/accounts.sql.go | 0 .../pg/sqlc}/address_types.sql.go | 0 .../postgres => sql/pg/sqlc}/addresses.sql.go | 0 .../postgres => sql/pg/sqlc}/blocks.sql.go | 0 .../{db/sqlc/postgres => sql/pg/sqlc}/db.go | 0 .../pg/sqlc}/key_scopes.sql.go | 0 .../sqlc/postgres => sql/pg/sqlc}/models.go | 0 .../sqlc/postgres => sql/pg/sqlc}/querier.go | 0 .../pg/sqlc}/transactions.sql.go | 0 .../pg/sqlc}/tx_replacements.sql.go | 0 .../pg/sqlc}/utxo_leases.sql.go | 0 .../postgres => sql/pg/sqlc}/utxos.sql.go | 0 .../postgres => sql/pg/sqlc}/wallets.sql.go | 0 72 files changed, 91 insertions(+), 104 deletions(-) delete mode 100644 wallet/internal/db/migrations.go create mode 100644 wallet/internal/sql/pg/doc.go create mode 100644 wallet/internal/sql/pg/migrations.go rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000001_blocks.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000001_blocks.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000002_wallets.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000002_wallets.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000003_address_types.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000003_address_types.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000004_key_scopes.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000004_key_scopes.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000005_accounts.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000005_accounts.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000006_addresses.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000006_addresses.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000007_transactions.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000007_transactions.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000008_utxos.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000008_utxos.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000009_tx_replacements.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000009_tx_replacements.up.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000010_utxo_leases.down.sql (100%) rename wallet/internal/{db/migrations/postgres => sql/pg/migrations}/000010_utxo_leases.up.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/accounts.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/address_types.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/addresses.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/blocks.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/key_scopes.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/transactions.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/tx_replacements.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/utxo_leases.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/utxos.sql (100%) rename wallet/internal/{db/queries/postgres => sql/pg/queries}/wallets.sql (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/accounts.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/address_types.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/addresses.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/blocks.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/db.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/key_scopes.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/models.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/querier.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/transactions.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/tx_replacements.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/utxo_leases.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/utxos.sql.go (100%) rename wallet/internal/{db/sqlc/postgres => sql/pg/sqlc}/wallets.sql.go (100%) diff --git a/Makefile b/Makefile index f8ebe01f54..e4e70258dd 100644 --- a/Makefile +++ b/Makefile @@ -8,14 +8,15 @@ GOFILES = $(shell find . -type f -name '*.go' -not -name "*.pb.go") # SQL directories. -SQL_MIGRATIONS_DIR := wallet/internal/db/migrations -SQL_QUERIES_DIR := wallet/internal/db/queries +SQL_DIR := wallet/internal/sql # SQL file paths. -SQL_POSTGRES_MIGRATIONS := $(SQL_MIGRATIONS_DIR)/postgres -SQL_POSTGRES_QUERIES := $(SQL_QUERIES_DIR)/postgres -SQL_SQLITE_MIGRATIONS := $(SQL_MIGRATIONS_DIR)/sqlite -SQL_SQLITE_QUERIES := $(SQL_QUERIES_DIR)/sqlite +SQL_POSTGRES_DIR := $(SQL_DIR)/pg +SQL_POSTGRES_MIGRATIONS := $(SQL_POSTGRES_DIR)/migrations +SQL_POSTGRES_QUERIES := $(SQL_POSTGRES_DIR)/queries +SQL_SQLITE_DIR := $(SQL_DIR)/sqlite +SQL_SQLITE_MIGRATIONS := $(SQL_SQLITE_DIR)/migrations +SQL_SQLITE_QUERIES := $(SQL_SQLITE_DIR)/queries RM := rm -f CP := cp @@ -262,7 +263,7 @@ sql-format: #? sql-check: Verify SQL migration and query files are formatted correctly (like 'make fmt-check') sql-format-check: sql-format @$(call print, "Checking SQL formatting.") - if test -n "$$(git status --porcelain '$(SQL_MIGRATIONS_DIR)/**/*.sql' '$(SQL_QUERIES_DIR)/**/*.sql')"; then echo "SQL files not formatted correctly, please run 'make sql-format' again!"; git status; git diff; exit 1; fi + if test -n "$$(git status --porcelain '$(SQL_DIR)/**/*.sql')"; then echo "SQL files not formatted correctly, please run 'make sql-format' again!"; git status; git diff; exit 1; fi #? sql-lint: Lint SQL migration and query files and fix issues (like 'make lint') sql-lint: diff --git a/sqlc.yaml b/sqlc.yaml index 9b322becd0..1ee7f931f1 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -3,11 +3,11 @@ version: "2" sql: - engine: "postgresql" - schema: "wallet/internal/db/migrations/postgres" - queries: "wallet/internal/db/queries/postgres" + schema: "wallet/internal/sql/pg/migrations" + queries: "wallet/internal/sql/pg/queries" gen: go: - out: "wallet/internal/db/sqlc/postgres" + out: "wallet/internal/sql/pg/sqlc" package: "sqlcpg" # This is the driver package that sqlc will use in the generated code. diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 4b33892cee..540d1443ac 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -11,7 +11,7 @@ import ( "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index 997ff64d5e..b7e1f8b0aa 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -21,7 +21,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" diff --git a/wallet/internal/db/migrations.go b/wallet/internal/db/migrations.go deleted file mode 100644 index f6a419d7ea..0000000000 --- a/wallet/internal/db/migrations.go +++ /dev/null @@ -1,69 +0,0 @@ -package db - -import ( - "database/sql" - "embed" - "errors" - "fmt" - "io/fs" - - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database" - "github.com/golang-migrate/migrate/v4/database/postgres" - "github.com/golang-migrate/migrate/v4/source/iofs" -) - -//go:embed migrations/postgres/*.sql -var postgresFS embed.FS - -type driverFactory func(*sql.DB) (database.Driver, error) - -// applyMigrations is a simple function that applies all migrations found in the -// given migrationFS at the given path to the provided database using the given -// driver factory. -// -// TODO(gustavostingelin): enhance migrations to be like sqldb/v2 before -// production use. This is a simplified migration system suitable for -// integration tests but lacks features required for production: -// - No migration version tracking or status checks -// - No migration history table or audit trail -// - No protection against concurrent migrations -// -// For production use, this should be enhanced to match the patterns in -// lnd/sqldb/v2, which provides a more robust migration framework. -func applyMigrations(db *sql.DB, migrationFS fs.FS, path string, dbName string, - newDriver driverFactory) error { - - sourceDriver, err := iofs.New(migrationFS, path) - if err != nil { - return fmt.Errorf("create source driver: %w", err) - } - - driver, err := newDriver(db) - if err != nil { - return fmt.Errorf("create %s driver: %w", dbName, err) - } - - m, err := migrate.NewWithInstance("iofs", sourceDriver, dbName, driver) - if err != nil { - return fmt.Errorf("create migrate instance: %w", err) - } - - err = m.Up() - if err != nil && !errors.Is(err, migrate.ErrNoChange) { - return fmt.Errorf("run migrations: %w", err) - } - - return nil -} - -// ApplyPostgresMigrations applies all PostgreSQL migrations to the database. -// -// NOTE: not ready for production use. -func ApplyPostgresMigrations(db *sql.DB) error { - return applyMigrations(db, postgresFS, "migrations/postgres", - "postgres", func(db *sql.DB) (database.Driver, error) { - return postgres.WithInstance(db, &postgres.Config{}) - }, - ) -} diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index fb9bdbafb7..2ee775c5c1 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -6,7 +6,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Ensure PostgresStore satisfies the AccountStore interface. diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index 7aa90025f4..46388b9d09 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -4,7 +4,7 @@ import ( "context" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // pgAddressTypeRowToInfo converts a PostgreSQL address type row to an diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 41f56871d2..3a35ad3c88 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -9,7 +9,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) var _ db.AddressStore = (*PostgresStore)(nil) diff --git a/wallet/internal/db/pg/backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go index 7a5b319501..7643d87983 100644 --- a/wallet/internal/db/pg/backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -10,7 +10,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index f30fc3866d..e1abc6277d 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -8,7 +8,7 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) diff --git a/wallet/internal/db/pg/block.go b/wallet/internal/db/pg/block.go index fc8559d2f1..4cd4881d40 100644 --- a/wallet/internal/db/pg/block.go +++ b/wallet/internal/db/pg/block.go @@ -8,7 +8,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // buildPgBlock constructs a Block from the given PostgreSQL block diff --git a/wallet/internal/db/pg/itest.go b/wallet/internal/db/pg/itest.go index 89dc9d4d87..f9f9b1a441 100644 --- a/wallet/internal/db/pg/itest.go +++ b/wallet/internal/db/pg/itest.go @@ -6,7 +6,7 @@ import ( "database/sql" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index c351bd45f8..7f0e131d73 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -6,8 +6,9 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) @@ -54,7 +55,7 @@ func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStor queries := sqlcpg.New(dbConn) - err = db.ApplyPostgresMigrations(dbConn) + err = sqlassetpg.ApplyMigrations(dbConn) if err != nil { _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index 8c01b1f0f1..63df6378b4 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index 5a4aba797e..ee1d9122d8 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index 8e1bbac1bf..abbebf3f22 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index b99bc24eaa..c9ecc7487c 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index 8438214d0e..574f27a485 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -6,7 +6,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index 38a33acf23..09057696c1 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -7,7 +7,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index 34af767a14..01881da80e 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. diff --git a/wallet/internal/db/pg/utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go index 8c8e6019f2..6865b2af8c 100644 --- a/wallet/internal/db/pg/utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -7,7 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index 9e58067f85..3f07f0bea3 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index 9f17905edf..82c57727d4 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. diff --git a/wallet/internal/db/pg/utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go index 846b17b9ad..926175b55f 100644 --- a/wallet/internal/db/pg/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -6,7 +6,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index ccf2376e5a..888a9186c9 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -5,7 +5,7 @@ import ( "fmt" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index ff6b04fc8e..694fd4668c 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -8,7 +8,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index f9a8242ddf..a83070270d 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -9,7 +9,7 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/db/sqlc/postgres" + sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Ensure PostgresStore satisfies the WalletStore interface. diff --git a/wallet/internal/sql/pg/doc.go b/wallet/internal/sql/pg/doc.go new file mode 100644 index 0000000000..090cde30ab --- /dev/null +++ b/wallet/internal/sql/pg/doc.go @@ -0,0 +1,2 @@ +// Package pg contains PostgreSQL SQL assets and migration helpers. +package pg diff --git a/wallet/internal/sql/pg/migrations.go b/wallet/internal/sql/pg/migrations.go new file mode 100644 index 0000000000..a59810dcab --- /dev/null +++ b/wallet/internal/sql/pg/migrations.go @@ -0,0 +1,52 @@ +package pg + +import ( + "database/sql" + "embed" + "errors" + "fmt" + + gomigrate "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database" + migrate "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationFS embed.FS + +type driverFactory func(*sql.DB) (database.Driver, error) + +// applyMigrations applies all embedded postgres migrations to one database. +func applyMigrations(db *sql.DB, newDriver driverFactory) error { + sourceDriver, err := iofs.New(migrationFS, "migrations") + if err != nil { + return fmt.Errorf("create source driver: %w", err) + } + + driver, err := newDriver(db) + if err != nil { + return fmt.Errorf("create postgres driver: %w", err) + } + + m, err := gomigrate.NewWithInstance( + "iofs", sourceDriver, "postgres", driver, + ) + if err != nil { + return fmt.Errorf("create migrate instance: %w", err) + } + + err = m.Up() + if err != nil && !errors.Is(err, gomigrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + + return nil +} + +// ApplyMigrations applies all PostgreSQL migrations to the database. +func ApplyMigrations(db *sql.DB) error { + return applyMigrations(db, func(db *sql.DB) (database.Driver, error) { + return migrate.WithInstance(db, &migrate.Config{}) + }) +} diff --git a/wallet/internal/db/migrations/postgres/000001_blocks.down.sql b/wallet/internal/sql/pg/migrations/000001_blocks.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000001_blocks.down.sql rename to wallet/internal/sql/pg/migrations/000001_blocks.down.sql diff --git a/wallet/internal/db/migrations/postgres/000001_blocks.up.sql b/wallet/internal/sql/pg/migrations/000001_blocks.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000001_blocks.up.sql rename to wallet/internal/sql/pg/migrations/000001_blocks.up.sql diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.down.sql b/wallet/internal/sql/pg/migrations/000002_wallets.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000002_wallets.down.sql rename to wallet/internal/sql/pg/migrations/000002_wallets.down.sql diff --git a/wallet/internal/db/migrations/postgres/000002_wallets.up.sql b/wallet/internal/sql/pg/migrations/000002_wallets.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000002_wallets.up.sql rename to wallet/internal/sql/pg/migrations/000002_wallets.up.sql diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.down.sql b/wallet/internal/sql/pg/migrations/000003_address_types.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000003_address_types.down.sql rename to wallet/internal/sql/pg/migrations/000003_address_types.down.sql diff --git a/wallet/internal/db/migrations/postgres/000003_address_types.up.sql b/wallet/internal/sql/pg/migrations/000003_address_types.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000003_address_types.up.sql rename to wallet/internal/sql/pg/migrations/000003_address_types.up.sql diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql b/wallet/internal/sql/pg/migrations/000004_key_scopes.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000004_key_scopes.down.sql rename to wallet/internal/sql/pg/migrations/000004_key_scopes.down.sql diff --git a/wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql b/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000004_key_scopes.up.sql rename to wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.down.sql b/wallet/internal/sql/pg/migrations/000005_accounts.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000005_accounts.down.sql rename to wallet/internal/sql/pg/migrations/000005_accounts.down.sql diff --git a/wallet/internal/db/migrations/postgres/000005_accounts.up.sql b/wallet/internal/sql/pg/migrations/000005_accounts.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000005_accounts.up.sql rename to wallet/internal/sql/pg/migrations/000005_accounts.up.sql diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.down.sql b/wallet/internal/sql/pg/migrations/000006_addresses.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000006_addresses.down.sql rename to wallet/internal/sql/pg/migrations/000006_addresses.down.sql diff --git a/wallet/internal/db/migrations/postgres/000006_addresses.up.sql b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000006_addresses.up.sql rename to wallet/internal/sql/pg/migrations/000006_addresses.up.sql diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.down.sql b/wallet/internal/sql/pg/migrations/000007_transactions.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000007_transactions.down.sql rename to wallet/internal/sql/pg/migrations/000007_transactions.down.sql diff --git a/wallet/internal/db/migrations/postgres/000007_transactions.up.sql b/wallet/internal/sql/pg/migrations/000007_transactions.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000007_transactions.up.sql rename to wallet/internal/sql/pg/migrations/000007_transactions.up.sql diff --git a/wallet/internal/db/migrations/postgres/000008_utxos.down.sql b/wallet/internal/sql/pg/migrations/000008_utxos.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000008_utxos.down.sql rename to wallet/internal/sql/pg/migrations/000008_utxos.down.sql diff --git a/wallet/internal/db/migrations/postgres/000008_utxos.up.sql b/wallet/internal/sql/pg/migrations/000008_utxos.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000008_utxos.up.sql rename to wallet/internal/sql/pg/migrations/000008_utxos.up.sql diff --git a/wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql b/wallet/internal/sql/pg/migrations/000009_tx_replacements.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000009_tx_replacements.down.sql rename to wallet/internal/sql/pg/migrations/000009_tx_replacements.down.sql diff --git a/wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql b/wallet/internal/sql/pg/migrations/000009_tx_replacements.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000009_tx_replacements.up.sql rename to wallet/internal/sql/pg/migrations/000009_tx_replacements.up.sql diff --git a/wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql b/wallet/internal/sql/pg/migrations/000010_utxo_leases.down.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000010_utxo_leases.down.sql rename to wallet/internal/sql/pg/migrations/000010_utxo_leases.down.sql diff --git a/wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql b/wallet/internal/sql/pg/migrations/000010_utxo_leases.up.sql similarity index 100% rename from wallet/internal/db/migrations/postgres/000010_utxo_leases.up.sql rename to wallet/internal/sql/pg/migrations/000010_utxo_leases.up.sql diff --git a/wallet/internal/db/queries/postgres/accounts.sql b/wallet/internal/sql/pg/queries/accounts.sql similarity index 100% rename from wallet/internal/db/queries/postgres/accounts.sql rename to wallet/internal/sql/pg/queries/accounts.sql diff --git a/wallet/internal/db/queries/postgres/address_types.sql b/wallet/internal/sql/pg/queries/address_types.sql similarity index 100% rename from wallet/internal/db/queries/postgres/address_types.sql rename to wallet/internal/sql/pg/queries/address_types.sql diff --git a/wallet/internal/db/queries/postgres/addresses.sql b/wallet/internal/sql/pg/queries/addresses.sql similarity index 100% rename from wallet/internal/db/queries/postgres/addresses.sql rename to wallet/internal/sql/pg/queries/addresses.sql diff --git a/wallet/internal/db/queries/postgres/blocks.sql b/wallet/internal/sql/pg/queries/blocks.sql similarity index 100% rename from wallet/internal/db/queries/postgres/blocks.sql rename to wallet/internal/sql/pg/queries/blocks.sql diff --git a/wallet/internal/db/queries/postgres/key_scopes.sql b/wallet/internal/sql/pg/queries/key_scopes.sql similarity index 100% rename from wallet/internal/db/queries/postgres/key_scopes.sql rename to wallet/internal/sql/pg/queries/key_scopes.sql diff --git a/wallet/internal/db/queries/postgres/transactions.sql b/wallet/internal/sql/pg/queries/transactions.sql similarity index 100% rename from wallet/internal/db/queries/postgres/transactions.sql rename to wallet/internal/sql/pg/queries/transactions.sql diff --git a/wallet/internal/db/queries/postgres/tx_replacements.sql b/wallet/internal/sql/pg/queries/tx_replacements.sql similarity index 100% rename from wallet/internal/db/queries/postgres/tx_replacements.sql rename to wallet/internal/sql/pg/queries/tx_replacements.sql diff --git a/wallet/internal/db/queries/postgres/utxo_leases.sql b/wallet/internal/sql/pg/queries/utxo_leases.sql similarity index 100% rename from wallet/internal/db/queries/postgres/utxo_leases.sql rename to wallet/internal/sql/pg/queries/utxo_leases.sql diff --git a/wallet/internal/db/queries/postgres/utxos.sql b/wallet/internal/sql/pg/queries/utxos.sql similarity index 100% rename from wallet/internal/db/queries/postgres/utxos.sql rename to wallet/internal/sql/pg/queries/utxos.sql diff --git a/wallet/internal/db/queries/postgres/wallets.sql b/wallet/internal/sql/pg/queries/wallets.sql similarity index 100% rename from wallet/internal/db/queries/postgres/wallets.sql rename to wallet/internal/sql/pg/queries/wallets.sql diff --git a/wallet/internal/db/sqlc/postgres/accounts.sql.go b/wallet/internal/sql/pg/sqlc/accounts.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/accounts.sql.go rename to wallet/internal/sql/pg/sqlc/accounts.sql.go diff --git a/wallet/internal/db/sqlc/postgres/address_types.sql.go b/wallet/internal/sql/pg/sqlc/address_types.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/address_types.sql.go rename to wallet/internal/sql/pg/sqlc/address_types.sql.go diff --git a/wallet/internal/db/sqlc/postgres/addresses.sql.go b/wallet/internal/sql/pg/sqlc/addresses.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/addresses.sql.go rename to wallet/internal/sql/pg/sqlc/addresses.sql.go diff --git a/wallet/internal/db/sqlc/postgres/blocks.sql.go b/wallet/internal/sql/pg/sqlc/blocks.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/blocks.sql.go rename to wallet/internal/sql/pg/sqlc/blocks.sql.go diff --git a/wallet/internal/db/sqlc/postgres/db.go b/wallet/internal/sql/pg/sqlc/db.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/db.go rename to wallet/internal/sql/pg/sqlc/db.go diff --git a/wallet/internal/db/sqlc/postgres/key_scopes.sql.go b/wallet/internal/sql/pg/sqlc/key_scopes.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/key_scopes.sql.go rename to wallet/internal/sql/pg/sqlc/key_scopes.sql.go diff --git a/wallet/internal/db/sqlc/postgres/models.go b/wallet/internal/sql/pg/sqlc/models.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/models.go rename to wallet/internal/sql/pg/sqlc/models.go diff --git a/wallet/internal/db/sqlc/postgres/querier.go b/wallet/internal/sql/pg/sqlc/querier.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/querier.go rename to wallet/internal/sql/pg/sqlc/querier.go diff --git a/wallet/internal/db/sqlc/postgres/transactions.sql.go b/wallet/internal/sql/pg/sqlc/transactions.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/transactions.sql.go rename to wallet/internal/sql/pg/sqlc/transactions.sql.go diff --git a/wallet/internal/db/sqlc/postgres/tx_replacements.sql.go b/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/tx_replacements.sql.go rename to wallet/internal/sql/pg/sqlc/tx_replacements.sql.go diff --git a/wallet/internal/db/sqlc/postgres/utxo_leases.sql.go b/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/utxo_leases.sql.go rename to wallet/internal/sql/pg/sqlc/utxo_leases.sql.go diff --git a/wallet/internal/db/sqlc/postgres/utxos.sql.go b/wallet/internal/sql/pg/sqlc/utxos.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/utxos.sql.go rename to wallet/internal/sql/pg/sqlc/utxos.sql.go diff --git a/wallet/internal/db/sqlc/postgres/wallets.sql.go b/wallet/internal/sql/pg/sqlc/wallets.sql.go similarity index 100% rename from wallet/internal/db/sqlc/postgres/wallets.sql.go rename to wallet/internal/sql/pg/sqlc/wallets.sql.go From 871e133be7e2d59bf510ecca6944cc2e0487ea1b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:54:26 +0800 Subject: [PATCH 535/691] db/sqlite: drop private backend prefixes Rename the sqlite package's private helpers, adapters, and row conversion types so they rely on the package namespace instead of repeating sqlite in every identifier. This keeps the backend-specific package easier to scan now that the split already provides the backend context. --- wallet/internal/db/sqlite/accounts.go | 78 ++++++------ wallet/internal/db/sqlite/address_types.go | 8 +- wallet/internal/db/sqlite/addresses.go | 116 +++++++++--------- .../internal/db/sqlite/backend_error_test.go | 18 +-- .../internal/db/sqlite/backend_rows_test.go | 36 +++--- wallet/internal/db/sqlite/block.go | 12 +- wallet/internal/db/sqlite/testhelpers_test.go | 4 +- wallet/internal/db/sqlite/txstore_createtx.go | 76 ++++++------ wallet/internal/db/sqlite/txstore_deletetx.go | 30 ++--- wallet/internal/db/sqlite/txstore_gettx.go | 8 +- .../db/sqlite/txstore_invalidateunmined.go | 16 +-- wallet/internal/db/sqlite/txstore_listtxns.go | 16 +-- wallet/internal/db/sqlite/txstore_rollback.go | 28 ++--- wallet/internal/db/sqlite/txstore_updatetx.go | 18 +-- wallet/internal/db/sqlite/utxo_extra_test.go | 4 +- .../internal/db/sqlite/utxostore_getutxo.go | 6 +- .../db/sqlite/utxostore_leaseoutput.go | 12 +- .../internal/db/sqlite/utxostore_listutxos.go | 2 +- .../db/sqlite/utxostore_releaseoutput.go | 14 +-- wallet/internal/db/sqlite/wallet.go | 40 +++--- 20 files changed, 271 insertions(+), 271 deletions(-) diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index 66c96b48b5..fb9b9ae041 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -17,7 +17,7 @@ var _ db.AccountStore = (*SqliteStore)(nil) func (s *SqliteStore) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { - getQueries := sqliteAccountGetQueries{q: s.queries} + getQueries := accountGetQueries{q: s.queries} return db.GetAccountByQuery( ctx, query, getQueries.byNumber, getQueries.byName, @@ -29,7 +29,7 @@ func (s *SqliteStore) GetAccount(ctx context.Context, func (s *SqliteStore) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { - listQueries := sqliteAccountListQueries{q: s.queries} + listQueries := accountListQueries{q: s.queries} return db.ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, @@ -41,7 +41,7 @@ func (s *SqliteStore) ListAccounts(ctx context.Context, func (s *SqliteStore) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { - renameQueries := sqliteAccountRenameQueries{q: s.queries} + renameQueries := accountRenameQueries{q: s.queries} return db.RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, @@ -62,7 +62,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - scopeID, err := sqliteEnsureKeyScope( + scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) if err != nil { @@ -106,9 +106,9 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, return info, nil } -// sqliteEnsureKeyScope retrieves an existing key scope or creates it if missing +// ensureKeyScope retrieves an existing key scope or creates it if missing // for SQLite. It returns the scope ID once available. -func sqliteEnsureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, scope db.KeyScope) (int64, error) { return db.EnsureKeyScope( @@ -151,18 +151,18 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, props, err = db.CreateImportedAccount( ctx, params, func() (int64, error) { - return sqliteEnsureKeyScope( + return ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) }, qtx.CreateImportedAccount, - sqliteBuildCreateImportedAccountArgs(params), + buildCreateImportedAccountArgs(params), func(row sqlcsqlite.CreateImportedAccountRow) int64 { return row.ID }, - qtx.CreateAccountSecret, sqliteBuildCreateAccountSecretArgs(params), + qtx.CreateAccountSecret, buildCreateAccountSecretArgs(params), func(accountID int64) (*db.AccountProperties, error) { - return sqliteGetAccountProps(ctx, qtx, accountID) + return getAccountProps(ctx, qtx, accountID) }, ) @@ -175,9 +175,9 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, return props, nil } -// sqliteBuildCreateImportedAccountArgs returns a function that builds the +// buildCreateImportedAccountArgs returns a function that builds the // CreateImportedAccountParams for SQLite. -func sqliteBuildCreateImportedAccountArgs( +func buildCreateImportedAccountArgs( params db.CreateImportedAccountParams, ) func(int64, bool) sqlcsqlite.CreateImportedAccountParams { @@ -198,9 +198,9 @@ func sqliteBuildCreateImportedAccountArgs( } } -// sqliteBuildCreateAccountSecretArgs returns a function that builds the +// buildCreateAccountSecretArgs returns a function that builds the // CreateAccountSecretParams for SQLite. -func sqliteBuildCreateAccountSecretArgs( +func buildCreateAccountSecretArgs( params db.CreateImportedAccountParams, ) func(int64) sqlcsqlite.CreateAccountSecretParams { @@ -212,9 +212,9 @@ func sqliteBuildCreateAccountSecretArgs( } } -// sqliteGetAccountProps fetches full account properties from the database and +// getAccountProps fetches full account properties from the database and // converts the row to AccountProperties. -func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, +func getAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) @@ -242,10 +242,10 @@ func sqliteGetAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, }) } -// sqliteAccountInfoRow is a type constraint for SQLite account info row types +// accountInfoRow is a type constraint for SQLite account info row types // that share the same field structure. This enables a single generic conversion // function to handle all account query result types. -type sqliteAccountInfoRow interface { +type accountInfoRow interface { sqlcsqlite.GetAccountByScopeAndNameRow | sqlcsqlite.GetAccountByScopeAndNumberRow | sqlcsqlite.GetAccountByWalletScopeAndNameRow | @@ -255,10 +255,10 @@ type sqliteAccountInfoRow interface { sqlcsqlite.ListAccountsByWalletAndNameRow } -// sqliteAccountRowToInfo converts a SQLite account row to an AccountInfo -// struct. It uses type conversion since all sqliteAccountInfoRow types have +// accountRowToInfo converts a SQLite account row to an AccountInfo +// struct. It uses type conversion since all accountInfoRow types have // identical fields. -func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*db.AccountInfo, +func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have @@ -280,13 +280,13 @@ func sqliteAccountRowToInfo[T sqliteAccountInfoRow](row T) (*db.AccountInfo, }) } -// sqliteAccountListQueries groups SQLite account listing query methods. -type sqliteAccountListQueries struct { +// accountListQueries groups SQLite account listing query methods. +type accountListQueries struct { q *sqlcsqlite.Queries } // byScope lists accounts filtered by wallet ID and key scope. -func (s sqliteAccountListQueries) byScope(ctx context.Context, +func (s accountListQueries) byScope(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( @@ -295,12 +295,12 @@ func (s sqliteAccountListQueries) byScope(ctx context.Context, WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - }, sqliteAccountRowToInfo, + }, accountRowToInfo, ) } // byName lists accounts filtered by wallet ID and account name. -func (s sqliteAccountListQueries) byName(ctx context.Context, +func (s accountListQueries) byName(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( @@ -308,27 +308,27 @@ func (s sqliteAccountListQueries) byName(ctx context.Context, sqlcsqlite.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), AccountName: *query.Name, - }, sqliteAccountRowToInfo, + }, accountRowToInfo, ) } // all lists all accounts for a wallet. -func (s sqliteAccountListQueries) all(ctx context.Context, +func (s accountListQueries) all(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( ctx, s.q.ListAccountsByWallet, int64(query.WalletID), - sqliteAccountRowToInfo, + accountRowToInfo, ) } -// sqliteAccountGetQueries groups SQLite account retrieval query methods. -type sqliteAccountGetQueries struct { +// accountGetQueries groups SQLite account retrieval query methods. +type accountGetQueries struct { q *sqlcsqlite.Queries } // byNumber retrieves an account by wallet ID, scope, and account number. -func (s sqliteAccountGetQueries) byNumber(ctx context.Context, +func (s accountGetQueries) byNumber(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount( @@ -338,12 +338,12 @@ func (s sqliteAccountGetQueries) byNumber(ctx context.Context, Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), - }, query, sqliteAccountRowToInfo, + }, query, accountRowToInfo, ) } // byName retrieves an account by wallet ID, scope, and account name. -func (s sqliteAccountGetQueries) byName(ctx context.Context, +func (s accountGetQueries) byName(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, @@ -352,18 +352,18 @@ func (s sqliteAccountGetQueries) byName(ctx context.Context, Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), AccountName: *query.Name, - }, query, sqliteAccountRowToInfo, + }, query, accountRowToInfo, ) } -// sqliteAccountRenameQueries groups SQLite account rename query methods. -type sqliteAccountRenameQueries struct { +// accountRenameQueries groups SQLite account rename query methods. +type accountRenameQueries struct { q *sqlcsqlite.Queries } // byNumber renames an account identified by wallet ID, scope, and account // number. -func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, +func (s accountRenameQueries) byNumber(ctx context.Context, params db.RenameAccountParams) error { return db.RenameAccount( @@ -380,7 +380,7 @@ func (s sqliteAccountRenameQueries) byNumber(ctx context.Context, // byName renames an account identified by wallet ID, scope, and old account // name. -func (s sqliteAccountRenameQueries) byName(ctx context.Context, +func (s accountRenameQueries) byName(ctx context.Context, params db.RenameAccountParams) error { return db.RenameAccount( diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index bdea9bb339..ae2e39ed4e 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -7,9 +7,9 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -// sqliteAddressTypeRowToInfo converts a SQLite address type row to an +// addressTypeRowToInfo converts a SQLite address type row to an // AddressTypeInfo struct. -func sqliteAddressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, +func addressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, error) { addrType, err := db.IDToAddressType(row.ID) @@ -29,7 +29,7 @@ func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { return db.ListAddressTypes( - ctx, s.queries.ListAddressTypes, sqliteAddressTypeRowToInfo, + ctx, s.queries.ListAddressTypes, addressTypeRowToInfo, ) } @@ -40,6 +40,6 @@ func (s *SqliteStore) GetAddressType(ctx context.Context, return db.GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int64(id), id, - sqliteAddressTypeRowToInfo, + addressTypeRowToInfo, ) } diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index c98845a245..a5c7a5a9fb 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -27,7 +27,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, sqlcsqlite.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), ScriptPubKey: q.ScriptPubKey, - }, sqliteAddressRowToInfo, + }, addressRowToInfo, ) } @@ -38,7 +38,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, func (s *SqliteStore) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { - items, err := sqliteListAddressesByAccount(ctx, s.queries, query) + items, err := listAddressesByAccount(ctx, s.queries, query) if err != nil { return page.Result[db.AddressInfo, uint32]{}, err } @@ -68,7 +68,7 @@ func (s *SqliteStore) GetAddressSecret(ctx context.Context, return db.GetAddressSecret( ctx, s.queries.GetAddressSecret, addressID, - sqliteAddressSecretRowToSecret, + addressSecretRowToSecret, ) } @@ -83,14 +83,14 @@ func (s *SqliteStore) NewDerivedAddress(ctx context.Context, sqlcsqlite.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, sqlcsqlite.CreateDerivedAddressRow]{ - GetAccount: sqliteGetAccountFromKey(s.queries), + GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromParams, - GetAccountID: newDerivedAddressGetAccountIDSQLite, - GetExtIndex: newDerivedAddressGetExtIndexSQLite, - GetIntIndex: newDerivedAddressGetIntIndexSQLite, - CreateAddr: newDerivedAddressCreateAddrSQLite, - RowID: newDerivedAddressRowIDSQLite, - RowCreatedAt: newDerivedAddressRowCreatedAtSQLite, + GetAccountID: derivedAddressGetAccountID, + GetExtIndex: derivedAddressGetExtIndex, + GetIntIndex: derivedAddressGetIntIndex, + CreateAddr: derivedAddressCreateAddr, + RowID: derivedAddressRowID, + RowCreatedAt: derivedAddressRowCreatedAt, } return db.NewDerivedAddressWithTx( @@ -109,22 +109,22 @@ func (s *SqliteStore) NewImportedAddress(ctx context.Context, sqlcsqlite.CreateImportedAddressParams, sqlcsqlite.CreateImportedAddressRow, sqlcsqlite.InsertAddressSecretParams]{ - GetAccount: sqliteGetAccountFromKey(s.queries), + GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromImportedParams, - GetAccountID: newImportedAddressGetAccountIDSQLite, - CreateAddr: sqliteCreateImportedAddress, - CreateParams: createImportedAddressParamsSQLite, - InsertSecret: sqliteInsertAddressSecret, - SecretParams: insertAddressSecretParamsSQLite, - RowID: importedAddressRowIDSQLite, - RowCreatedAt: importedAddressRowCreatedAtSQLite, + GetAccountID: importedAddressGetAccountID, + CreateAddr: createImportedAddress, + CreateParams: createImportedAddressParams, + InsertSecret: insertAddressSecret, + SecretParams: insertAddressSecretParams, + RowID: importedAddressRowID, + RowCreatedAt: importedAddressRowCreatedAt, } return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } -// sqliteGetAccountFromKey returns a helper to look up accounts by key. -func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, +// getAccountFromKey returns a helper to look up accounts by key. +func getAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, @@ -142,29 +142,29 @@ func sqliteGetAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, } } -// newDerivedAddressGetAccountIDSQLite extracts the account ID from a row. -func newDerivedAddressGetAccountIDSQLite( +// derivedAddressGetAccountID extracts the account ID from a row. +func derivedAddressGetAccountID( row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } -// newDerivedAddressGetExtIndexSQLite returns the external index query. -func newDerivedAddressGetExtIndexSQLite( +// derivedAddressGetExtIndex returns the external index query. +func derivedAddressGetExtIndex( qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextExternalIndex } -// newDerivedAddressGetIntIndexSQLite returns the internal index query. -func newDerivedAddressGetIntIndexSQLite( +// derivedAddressGetIntIndex returns the internal index query. +func derivedAddressGetIntIndex( qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextInternalIndex } -// newDerivedAddressCreateAddrSQLite returns the derived address insert helper. -func newDerivedAddressCreateAddrSQLite( +// derivedAddressCreateAddr returns the derived address insert helper. +func derivedAddressCreateAddr( qtx *sqlcsqlite.Queries, ) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) ( sqlcsqlite.CreateDerivedAddressRow, error, @@ -193,44 +193,44 @@ func newDerivedAddressCreateAddrSQLite( } } -// newDerivedAddressRowIDSQLite returns the created address ID. -func newDerivedAddressRowIDSQLite( +// derivedAddressRowID returns the created address ID. +func derivedAddressRowID( row sqlcsqlite.CreateDerivedAddressRow) int64 { return row.ID } -// newDerivedAddressRowCreatedAtSQLite returns the CreatedAt timestamp. -func newDerivedAddressRowCreatedAtSQLite( +// derivedAddressRowCreatedAt returns the CreatedAt timestamp. +func derivedAddressRowCreatedAt( row sqlcsqlite.CreateDerivedAddressRow) time.Time { return row.CreatedAt } -// newImportedAddressGetAccountIDSQLite extracts the account ID from a row. -func newImportedAddressGetAccountIDSQLite( +// importedAddressGetAccountID extracts the account ID from a row. +func importedAddressGetAccountID( row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } -// sqliteCreateImportedAddress returns the imported address insert helper. -func sqliteCreateImportedAddress(qtx *sqlcsqlite.Queries) func(context.Context, +// createImportedAddress returns the imported address insert helper. +func createImportedAddress(qtx *sqlcsqlite.Queries) func(context.Context, sqlcsqlite.CreateImportedAddressParams) ( sqlcsqlite.CreateImportedAddressRow, error) { return qtx.CreateImportedAddress } -// sqliteInsertAddressSecret returns the secret insert helper. -func sqliteInsertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, +// insertAddressSecret returns the secret insert helper. +func insertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, sqlcsqlite.InsertAddressSecretParams) error { return qtx.InsertAddressSecret } -// createImportedAddressParamsSQLite maps imported params to sqlc params. -func createImportedAddressParamsSQLite(accountID int64, +// createImportedAddressParams maps imported params to sqlc params. +func createImportedAddressParams(accountID int64, params db.NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { return sqlcsqlite.CreateImportedAddressParams{ @@ -241,20 +241,20 @@ func createImportedAddressParamsSQLite(accountID int64, } } -// importedAddressRowIDSQLite returns the created address ID. -func importedAddressRowIDSQLite(row sqlcsqlite.CreateImportedAddressRow) int64 { +// importedAddressRowID returns the created address ID. +func importedAddressRowID(row sqlcsqlite.CreateImportedAddressRow) int64 { return row.ID } -// importedAddressRowCreatedAtSQLite returns the CreatedAt timestamp. -func importedAddressRowCreatedAtSQLite( +// importedAddressRowCreatedAt returns the CreatedAt timestamp. +func importedAddressRowCreatedAt( row sqlcsqlite.CreateImportedAddressRow) time.Time { return row.CreatedAt } -// insertAddressSecretParamsSQLite maps imported params to secret params. -func insertAddressSecretParamsSQLite(addressID int64, +// insertAddressSecretParams maps imported params to secret params. +func insertAddressSecretParams(addressID int64, params db.NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { return sqlcsqlite.InsertAddressSecretParams{ @@ -264,9 +264,9 @@ func insertAddressSecretParamsSQLite(addressID int64, } } -// sqliteAddressSecretRowToSecret converts a SQLite address secret row to an +// addressSecretRowToSecret converts a SQLite address secret row to an // AddressSecret struct. -func sqliteAddressSecretRowToSecret( +func addressSecretRowToSecret( row sqlcsqlite.GetAddressSecretRow) (*db.AddressSecret, error) { return db.AddressSecretRowToSecret(db.AddressSecretRow{ @@ -276,17 +276,17 @@ func sqliteAddressSecretRowToSecret( }) } -// sqliteAddressInfoRow is a type constraint union that represents all SQLite +// addressInfoRow is a type constraint union that represents all SQLite // address row types that share the same field structure. This enables a // single generic conversion function to handle all address query result types. -type sqliteAddressInfoRow interface { +type addressInfoRow interface { sqlcsqlite.GetAddressByScriptPubKeyRow | sqlcsqlite.ListAddressesByAccountRow } -// sqliteAddressRowToInfo converts a SQLite address row to an AddressInfo +// addressRowToInfo converts a SQLite address row to an AddressInfo // struct. -func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*db.AddressInfo, +func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. @@ -314,13 +314,13 @@ func sqliteAddressRowToInfo[T sqliteAddressInfoRow](row T) (*db.AddressInfo, return info, nil } -// sqliteListAddressesByAccount lists addresses filtered by wallet ID, key +// listAddressesByAccount lists addresses filtered by wallet ID, key // scope, and account name, with pagination support. -func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, +func listAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( - ctx, sqliteBuildAddressPageParams(query), + ctx, buildAddressPageParams(query), ) if err != nil { return nil, fmt.Errorf("list addresses by account: %w", err) @@ -328,7 +328,7 @@ func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, items := make([]db.AddressInfo, len(rows)) for i, row := range rows { - item, err := sqliteAddressRowToInfo(row) + item, err := addressRowToInfo(row) if err != nil { return nil, fmt.Errorf("list addresses by account: map address row: %w", @@ -341,9 +341,9 @@ func sqliteListAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, return items, nil } -// sqliteBuildAddressPageParams translates a ListAddresses query to +// buildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. -func sqliteBuildAddressPageParams( +func buildAddressPageParams( q db.ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { params := sqlcsqlite.ListAddressesByAccountParams{ diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go index e3a911c4a5..f0ca724bf2 100644 --- a/wallet/internal/db/sqlite/backend_error_test.go +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -17,8 +17,8 @@ func TestDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { t.Parallel() qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - deleteOps := sqliteDeleteTxOps{qtx: qtx} - rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} + deleteOps := deleteTxOps{qtx: qtx} + rollbackOps := rollbackToBlockOps{qtx: qtx} err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxo rows") @@ -46,13 +46,13 @@ func TestTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - createOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{qtx: qtx}, + createOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{qtx: qtx}, } - invalidateOps := sqliteInvalidateUnminedTxOps{qtx: qtx} - rollbackOps := sqliteRollbackToBlockOps{qtx: qtx} - updateOps := &sqliteUpdateTxOps{qtx: qtx} - releaseOps := sqliteReleaseOutputOps{qtx: qtx} + invalidateOps := invalidateUnminedTxOps{qtx: qtx} + rollbackOps := rollbackToBlockOps{qtx: qtx} + updateOps := &updateTxOps{qtx: qtx} + releaseOps := releaseOutputOps{qtx: qtx} err := createOps.MarkTxnsReplaced(t.Context(), 1, []int64{2}) require.ErrorContains(t, err, "mark txns replaced") @@ -60,7 +60,7 @@ func TestTxStoreOpsWrapBackendErrors(t *testing.T) { err = createOps.InsertReplacementEdges(t.Context(), 1, []int64{2}, 3) require.ErrorContains(t, err, "insert replacement edge") - err = markInputsSpentSqlite(t.Context(), qtx, db.CreateTxParams{ + err = markInputsSpent(t.Context(), qtx, db.CreateTxParams{ WalletID: 1, Tx: testRegularMsgTx(), Received: time.Unix(1, 0), diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go index cad0e7989e..f940ee9f13 100644 --- a/wallet/internal/db/sqlite/backend_rows_test.go +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -19,10 +19,10 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { req := testCreateTxRequest(t) ctx := context.Background() - loadOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + loadOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + row: newRow(t, "SELECT 1 FROM missing_table"), }), }, } @@ -31,10 +31,10 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { require.ErrorContains(t, err, "get tx metadata") block := testBlock(8) - confirmOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + confirmOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow( + row: newRow( t, "SELECT ?, ?, ?", int64(block.Height), @@ -51,10 +51,10 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { }, db.CreateTxExistingTarget{}) require.ErrorIs(t, err, db.ErrTxNotFound) - prepareOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + prepareOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + row: newRow(t, "SELECT 1 FROM missing_table"), }), }, } @@ -63,10 +63,10 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { }) require.ErrorContains(t, err, "get block by height") - conflictOps := &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + conflictOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT ?", int64(5)), + row: newRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), }, @@ -79,8 +79,8 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { func TestReleaseOutputOpsAdditionalBranches(t *testing.T) { t.Parallel() - ops := &sqliteReleaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + ops := &releaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newRow(t, "SELECT 1 FROM missing_table"), })} _, err := ops.LookupUtxoID(context.Background(), db.ReleaseOutputParams{ @@ -99,15 +99,15 @@ func TestUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} - loadOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{ - row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), + loadOps := &updateTxOps{qtx: sqlcsqlite.New(rowDBTX{ + row: newRow(t, "SELECT 1 FROM missing_table"), })} - stateOps := &sqliteUpdateTxOps{ + stateOps := &updateTxOps{ qtx: sqlcsqlite.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt64{}, status: int64(db.TxStatusPublished), } - labelOps := &sqliteUpdateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} + labelOps := &updateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") diff --git a/wallet/internal/db/sqlite/block.go b/wallet/internal/db/sqlite/block.go index 5306caeeca..2fc2b7fedb 100644 --- a/wallet/internal/db/sqlite/block.go +++ b/wallet/internal/db/sqlite/block.go @@ -11,9 +11,9 @@ import ( sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -// buildSqliteBlock constructs a Block from the given SQLite block +// buildBlock constructs a Block from the given SQLite block // fields. -func buildSqliteBlock(height sql.NullInt64, hash []byte, +func buildBlock(height sql.NullInt64, hash []byte, timestamp sql.NullInt64) (*db.Block, error) { height32, err := db.Int64ToUint32(height.Int64) @@ -24,9 +24,9 @@ func buildSqliteBlock(height sql.NullInt64, hash []byte, return db.BuildBlock(hash, height32, timestamp.Int64) } -// ensureBlockExistsSqlite ensures that a block exists in the database. If it +// ensureBlockExists ensures that a block exists in the database. If it // doesn't exist, it inserts it. -func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureBlockExists(ctx context.Context, qtx *sqlcsqlite.Queries, block *db.Block) error { height := int64(block.Height) @@ -45,9 +45,9 @@ func ensureBlockExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } -// requireBlockMatchesSqlite loads the shared block row for the provided height +// requireBlockMatches loads the shared block row for the provided height // and verifies that its stored metadata matches the supplied block reference. -func requireBlockMatchesSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func requireBlockMatches(ctx context.Context, qtx *sqlcsqlite.Queries, block *db.Block) (int64, error) { height := int64(block.Height) diff --git a/wallet/internal/db/sqlite/testhelpers_test.go b/wallet/internal/db/sqlite/testhelpers_test.go index a3526039db..8c367d3eb2 100644 --- a/wallet/internal/db/sqlite/testhelpers_test.go +++ b/wallet/internal/db/sqlite/testhelpers_test.go @@ -108,8 +108,8 @@ func (r rowDBTX) QueryRowContext(context.Context, string, return &sql.Row{} } -// newSQLiteRow creates a query row backed by an in-memory sqlite database. -func newSQLiteRow(t *testing.T, query string, args ...any) *sql.Row { +// newRow creates a query row backed by an in-memory sqlite database. +func newRow(t *testing.T, query string, args ...any) *sql.Row { t.Helper() dbConn, err := sql.Open("sqlite", ":memory:") diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 0d4b24c19e..0b01be9915 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -30,25 +30,25 @@ func (s *SqliteStore) CreateTx(ctx context.Context, } return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return db.CreateTxWithOps(ctx, req, &sqliteCreateTxOps{ - sqliteInvalidateUnminedTxOps: sqliteInvalidateUnminedTxOps{ + return db.CreateTxWithOps(ctx, req, &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, }, }) }) } -// sqliteCreateTxOps adapts sqlite sqlc queries to the shared CreateTx flow. -type sqliteCreateTxOps struct { - sqliteInvalidateUnminedTxOps +// createTxOps adapts sqlite sqlc queries to the shared CreateTx flow. +type createTxOps struct { + invalidateUnminedTxOps blockHeight sql.NullInt64 } -var _ db.CreateTxOps = (*sqliteCreateTxOps)(nil) +var _ db.CreateTxOps = (*createTxOps)(nil) // LoadExisting loads any existing wallet-scoped row for the requested tx hash. -func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, +func (o *createTxOps) LoadExisting(ctx context.Context, req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -80,11 +80,11 @@ func (o *sqliteCreateTxOps) LoadExisting(ctx context.Context, } // ConfirmExisting promotes one existing unmined row to its confirmed state. -func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, +func (o *createTxOps) ConfirmExisting(ctx context.Context, req db.CreateTxRequest, _ db.CreateTxExistingTarget) error { - blockHeight, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) + blockHeight, err := requireBlockMatches(ctx, o.qtx, req.Params.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -110,7 +110,7 @@ func (o *sqliteCreateTxOps) ConfirmExisting(ctx context.Context, // PrepareBlock validates the optional confirming block and caches the sqlite // block-height value that the later Insert query will store. -func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, +func (o *createTxOps) PrepareBlock(ctx context.Context, req db.CreateTxRequest) error { o.blockHeight = sql.NullInt64{} @@ -119,7 +119,7 @@ func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, return nil } - height, err := requireBlockMatchesSqlite(ctx, o.qtx, req.Params.Block) + height, err := requireBlockMatches(ctx, o.qtx, req.Params.Block) if err != nil { return err } @@ -131,10 +131,10 @@ func (o *sqliteCreateTxOps) PrepareBlock(ctx context.Context, // ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. -func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, +func (o *createTxOps) ListConflictTxns(ctx context.Context, req db.CreateTxRequest) ([]int64, []chainhash.Hash, error) { - rootIDs, err := collectSqliteConflictRootIDs(ctx, o.qtx, req) + rootIDs, err := collectConflictRootIDs(ctx, o.qtx, req) if err != nil { return nil, nil, err } @@ -148,12 +148,12 @@ func (o *sqliteCreateTxOps) ListConflictTxns(ctx context.Context, return nil, nil, fmt.Errorf("list unmined txns: %w", err) } - return buildSqliteConflictRoots(rows, rootIDs) + return buildConflictRoots(rows, rootIDs) } -// collectSqliteConflictRootIDs returns the active unmined spender row +// collectConflictRootIDs returns the active unmined spender row // IDs that currently own any wallet-controlled input spent by the incoming tx. -func collectSqliteConflictRootIDs(ctx context.Context, +func collectConflictRootIDs(ctx context.Context, qtx *sqlcsqlite.Queries, req db.CreateTxRequest) (map[int64]struct{}, error) { @@ -189,9 +189,9 @@ func collectSqliteConflictRootIDs(ctx context.Context, return rootIDs, nil } -// buildSqliteConflictRoots maps the selected unmined rows into ordered root IDs +// buildConflictRoots maps the selected unmined rows into ordered root IDs // and the matching root hashes used for descendant discovery. -func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, +func buildConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, rootIDSet map[int64]struct{}) ( []int64, []chainhash.Hash, error) { @@ -216,7 +216,7 @@ func buildSqliteConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, } // Insert stores one new sqlite transaction row for CreateTx. -func (o *sqliteCreateTxOps) Insert(ctx context.Context, +func (o *createTxOps) Insert(ctx context.Context, req db.CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction( @@ -240,22 +240,22 @@ func (o *sqliteCreateTxOps) Insert(ctx context.Context, } // InsertCredits stores any wallet-owned outputs created by the transaction. -func (o *sqliteCreateTxOps) InsertCredits(ctx context.Context, +func (o *createTxOps) InsertCredits(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return insertCreditsSqlite(ctx, o.qtx, req.Params, txID) + return insertCredits(ctx, o.qtx, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. -func (o *sqliteCreateTxOps) MarkInputsSpent(ctx context.Context, +func (o *createTxOps) MarkInputsSpent(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return markInputsSpentSqlite(ctx, o.qtx, req.Params, txID) + return markInputsSpent(ctx, o.qtx, req.Params, txID) } // MarkTxnsReplaced marks the provided direct conflict roots replaced in one // batch update. -func (o *sqliteCreateTxOps) MarkTxnsReplaced( +func (o *createTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -274,7 +274,7 @@ func (o *sqliteCreateTxOps) MarkTxnsReplaced( // InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. -func (o *sqliteCreateTxOps) InsertReplacementEdges( +func (o *createTxOps) InsertReplacementEdges( ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { @@ -295,13 +295,13 @@ func (o *sqliteCreateTxOps) InsertReplacementEdges( return nil } -// insertCreditsSqlite inserts one wallet-owned UTXO row for each credited +// insertCredits inserts one wallet-owned UTXO row for each credited // output of the transaction being stored. -func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, params db.CreateTxParams, txID int64) error { for index := range params.Credits { - creditExists, err := creditExistsSqlite( + creditExists, err := creditExists( ctx, qtx, params.WalletID, params.Tx.TxHash(), index, ) if err != nil { @@ -344,9 +344,9 @@ func insertCreditsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } -// creditExistsSqlite reports whether the wallet already has a UTXO row for the +// creditExists reports whether the wallet already has a UTXO row for the // given credited output, even if that output is now spent by a child tx. -func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func creditExists(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { _, err := qtx.GetUtxoSpendByOutpoint( @@ -368,7 +368,7 @@ func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return true, nil } -// markInputsSpentSqlite attaches wallet-owned outpoints spent by the stored +// markInputsSpent attaches wallet-owned outpoints spent by the stored // transaction to its row ID and input indexes. // // If another wallet transaction already owns the spend edge for a @@ -376,7 +376,7 @@ func creditExistsSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, // instead of silently storing a second spender. Inputs that reference a // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. -func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func markInputsSpent(ctx context.Context, qtx *sqlcsqlite.Queries, params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { @@ -399,7 +399,7 @@ func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, } if rowsAffected == 0 { - err = ensureSpendConflictSqlite( + err = ensureSpendConflict( ctx, qtx, params.WalletID, txIn.PreviousOutPoint.Hash, int64(txIn.PreviousOutPoint.Index), txID, ) @@ -412,11 +412,11 @@ func markInputsSpentSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } -// ensureSpendConflictSqlite reports ErrTxInputConflict when the referenced +// ensureSpendConflict reports ErrTxInputConflict when the referenced // outpoint is wallet-owned, still eligible for spending, and already attached // to another transaction. If the wallet owns the parent output but that parent // is already invalid, the helper returns ErrTxInputInvalidParent instead. -func ensureSpendConflictSqlite(ctx context.Context, +func ensureSpendConflict(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int64, txID int64) error { @@ -429,7 +429,7 @@ func ensureSpendConflictSqlite(ctx context.Context, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return ensureWalletParentValidSqlite( + return ensureWalletParentValid( ctx, qtx, walletID, txHash, outputIndex, ) } @@ -444,10 +444,10 @@ func ensureSpendConflictSqlite(ctx context.Context, return nil } -// ensureWalletParentValidSqlite reports ErrTxInputInvalidParent when the +// ensureWalletParentValid reports ErrTxInputInvalidParent when the // wallet owns the referenced outpoint but its parent transaction is already // invalid. -func ensureWalletParentValidSqlite(ctx context.Context, +func ensureWalletParentValid(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int64) error { diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index 7f27135124..3559922ef6 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -22,23 +22,23 @@ func (s *SqliteStore) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return db.DeleteTxWithOps(ctx, params, sqliteDeleteTxOps{qtx: qtx}) + return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } -// sqliteDeleteTxOps adapts sqlite sqlc queries to the shared DeleteTx flow. -type sqliteDeleteTxOps struct { +// deleteTxOps adapts sqlite sqlc queries to the shared DeleteTx flow. +type deleteTxOps struct { qtx *sqlcsqlite.Queries } -var _ db.DeleteTxOps = (*sqliteDeleteTxOps)(nil) +var _ db.DeleteTxOps = (*deleteTxOps)(nil) // LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. -func (o sqliteDeleteTxOps) LoadDeleteTarget(ctx context.Context, +func (o deleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { - meta, err := getDeleteTxMetaSqlite(ctx, o.qtx, walletID, txHash) + meta, err := getDeleteTxMeta(ctx, o.qtx, walletID, txHash) if err != nil { return 0, err } @@ -48,15 +48,15 @@ func (o sqliteDeleteTxOps) LoadDeleteTarget(ctx context.Context, // EnsureLeaf rejects DeleteTx when the target still has direct unmined child // spenders. -func (o sqliteDeleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, +func (o deleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error { - return ensureDeleteLeafSqlite(ctx, o.qtx, walletID, txHash, txID) + return ensureDeleteLeaf(ctx, o.qtx, walletID, txHash, txID) } // ClearSpentUtxos restores any wallet-owned parent outputs the transaction had // marked spent. -func (o sqliteDeleteTxOps) ClearSpentUtxos(ctx context.Context, +func (o deleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -75,7 +75,7 @@ func (o sqliteDeleteTxOps) ClearSpentUtxos(ctx context.Context, // DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. -func (o sqliteDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, +func (o deleteTxOps) DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.DeleteUtxosByTxID( @@ -94,7 +94,7 @@ func (o sqliteDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, // DeleteUnminedTransaction removes the target unmined row after its dependent // wallet state has been cleaned up. -func (o sqliteDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, +func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { rows, err := o.qtx.DeleteUnminedTransactionByHash( @@ -111,10 +111,10 @@ func (o sqliteDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, return rows, nil } -// ensureDeleteLeafSqlite rejects DeleteTx requests for transactions that still +// ensureDeleteLeaf rejects DeleteTx requests for transactions that still // have direct unmined child spenders, including children that spend non-credit // parent outputs. -func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureDeleteLeaf(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, txID int64) error { rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) @@ -151,9 +151,9 @@ func ensureDeleteLeafSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, return nil } -// getDeleteTxMetaSqlite loads the transaction metadata DeleteTx needs and +// getDeleteTxMeta loads the transaction metadata DeleteTx needs and // enforces the unmined precondition up front. -func getDeleteTxMetaSqlite(ctx context.Context, qtx *sqlcsqlite.Queries, +func getDeleteTxMeta(ctx context.Context, qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash) ( sqlcsqlite.GetTransactionMetaByHashRow, error) { diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index fc8048fa28..56122872f4 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -32,15 +32,15 @@ func (s *SqliteStore) GetTx(ctx context.Context, return nil, fmt.Errorf("get tx: %w", err) } - return txInfoFromSqliteRow( + return txInfoFromRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, ) } -// txInfoFromSqliteRow converts one normalized sqlite query row into the public +// txInfoFromRow converts one normalized sqlite query row into the public // TxInfo shape. -func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, +func txInfoFromRow(hash []byte, rawTx []byte, received time.Time, blockHeight sql.NullInt64, blockHash []byte, blockTimestamp sql.NullInt64, status int64, label string) (*db.TxInfo, error) { @@ -52,7 +52,7 @@ func txInfoFromSqliteRow(hash []byte, rawTx []byte, received time.Time, // Unmined rows legitimately have no block metadata, so only build the Block // shape when the row still carries a valid height. if blockHeight.Valid { - block, err = buildSqliteBlock(blockHeight, blockHash, blockTimestamp) + block, err = buildBlock(blockHeight, blockHash, blockTimestamp) if err != nil { return nil, err } diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index 7c4d10745a..26a5ce9d00 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -18,22 +18,22 @@ func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { return db.InvalidateUnminedTxWithOps( - ctx, params, sqliteInvalidateUnminedTxOps{qtx: qtx}, + ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) }) } -// sqliteInvalidateUnminedTxOps adapts sqlite sqlc queries to the shared +// invalidateUnminedTxOps adapts sqlite sqlc queries to the shared // InvalidateUnminedTx workflow. -type sqliteInvalidateUnminedTxOps struct { +type invalidateUnminedTxOps struct { qtx *sqlcsqlite.Queries } -var _ db.InvalidateUnminedTxOps = (*sqliteInvalidateUnminedTxOps)(nil) +var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. -func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, +func (o invalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { @@ -70,7 +70,7 @@ func (o sqliteInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, // ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. -func (o sqliteInvalidateUnminedTxOps) ListUnminedTxRecords( +func (o invalidateUnminedTxOps) ListUnminedTxRecords( ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) @@ -89,7 +89,7 @@ func (o sqliteInvalidateUnminedTxOps) ListUnminedTxRecords( // ClearSpentUtxos restores any wallet-owned parent outputs spent by the given // transaction row. -func (o sqliteInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, +func (o invalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -110,7 +110,7 @@ func (o sqliteInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, // MarkTxnsFailed marks the provided tx rows failed in one // batch update. -func (o sqliteInvalidateUnminedTxOps) MarkTxnsFailed( +func (o invalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index 673f953171..f656805c5c 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -19,17 +19,17 @@ func (s *SqliteStore) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { - return s.listTxnsWithoutBlockSqlite(ctx, query.WalletID) + return s.listTxnsWithoutBlock(ctx, query.WalletID) } - return s.listConfirmedTxnsSqlite(ctx, query) + return s.listConfirmedTxns(ctx, query) } -// listTxnsWithoutBlockSqlite loads every transaction row that currently has no +// listTxnsWithoutBlock loads every transaction row that currently has no // confirming block. This includes the active unmined set together with any // retained invalid history that rollback or invalidation flows left without a // confirming block. -func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, +func (s *SqliteStore) listTxnsWithoutBlock(ctx context.Context, walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) @@ -39,7 +39,7 @@ func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, infos := make([]db.TxInfo, len(rows)) for i, row := range rows { - info, err := txInfoFromSqliteRow( + info, err := txInfoFromRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, ) @@ -53,9 +53,9 @@ func (s *SqliteStore) listTxnsWithoutBlockSqlite(ctx context.Context, return infos, nil } -// listConfirmedTxnsSqlite loads the confirmed height-range view used by +// listConfirmedTxns loads the confirmed height-range view used by // ListTxns when callers query mined history. -func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, +func (s *SqliteStore) listConfirmedTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsByHeightRange( @@ -71,7 +71,7 @@ func (s *SqliteStore) listConfirmedTxnsSqlite(ctx context.Context, infos := make([]db.TxInfo, len(rows)) for i, row := range rows { - block, err := buildSqliteBlock( + block, err := buildBlock( row.BlockHeight, row.BlockHash, sql.NullInt64{Int64: row.BlockTimestamp, Valid: true}, diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index aee979fd70..a24beb2252 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -18,21 +18,21 @@ func (s *SqliteStore) RollbackToBlock(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { return db.RollbackToBlockWithOps(ctx, height, - sqliteRollbackToBlockOps{qtx: qtx}) + rollbackToBlockOps{qtx: qtx}) }) } -// sqliteRollbackToBlockOps adapts sqlite sqlc queries to the shared rollback +// rollbackToBlockOps adapts sqlite sqlc queries to the shared rollback // sequence. -type sqliteRollbackToBlockOps struct { +type rollbackToBlockOps struct { qtx *sqlcsqlite.Queries } -var _ db.RollbackToBlockOps = (*sqliteRollbackToBlockOps)(nil) +var _ db.RollbackToBlockOps = (*rollbackToBlockOps)(nil) // ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. -func (o sqliteRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, +func (o rollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { rows, err := o.qtx.ListRollbackCoinbaseRoots(ctx, int64(height)) @@ -40,12 +40,12 @@ func (o sqliteRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, return nil, fmt.Errorf("query rollback coinbase roots: %w", err) } - return groupRollbackCoinbaseRootsSqlite(rows) + return groupRollbackCoinbaseRoots(rows) } // RewindWalletSyncStateHeights clamps wallet sync-state references below the // rollback boundary before the block rows are deleted. -func (o sqliteRollbackToBlockOps) RewindWalletSyncStateHeights( +func (o rollbackToBlockOps) RewindWalletSyncStateHeights( ctx context.Context, height uint32) error { newHeight := sql.NullInt64{} @@ -68,7 +68,7 @@ func (o sqliteRollbackToBlockOps) RewindWalletSyncStateHeights( // DeleteBlocksAtOrAboveHeight removes the shared block rows after sync-state // references have been rewound. -func (o sqliteRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( +func (o rollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { _, err := o.qtx.DeleteBlocksAtOrAboveHeight(ctx, int64(height)) @@ -81,7 +81,7 @@ func (o sqliteRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( // MarkTxRootsOrphaned rewrites each disconnected coinbase root to the // orphaned state once its confirming block has been deleted. -func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( +func (o rollbackToBlockOps) MarkTxRootsOrphaned( ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error { @@ -112,7 +112,7 @@ func (o sqliteRollbackToBlockOps) MarkTxRootsOrphaned( // ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. -func (o sqliteRollbackToBlockOps) ListUnminedTxRecords( +func (o rollbackToBlockOps) ListUnminedTxRecords( ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) @@ -131,7 +131,7 @@ func (o sqliteRollbackToBlockOps) ListUnminedTxRecords( // ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant transaction before its status is rewritten. -func (o sqliteRollbackToBlockOps) ClearDescendantSpends( +func (o rollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -152,7 +152,7 @@ func (o sqliteRollbackToBlockOps) ClearDescendantSpends( // MarkDescendantsFailed batch-marks the collected rollback descendants as // failed once every dependent spend edge has been cleared. -func (o sqliteRollbackToBlockOps) MarkDescendantsFailed( +func (o rollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -169,9 +169,9 @@ func (o sqliteRollbackToBlockOps) MarkDescendantsFailed( return nil } -// groupRollbackCoinbaseRootsSqlite groups rollback-affected coinbase hashes by +// groupRollbackCoinbaseRoots groups rollback-affected coinbase hashes by // wallet while preserving the query order inside each wallet bucket. -func groupRollbackCoinbaseRootsSqlite( +func groupRollbackCoinbaseRoots( rows []sqlcsqlite.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, error) { diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index 4e0e514947..6a1edc74fc 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -21,12 +21,12 @@ func (s *SqliteStore) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - return db.UpdateTxWithOps(ctx, params, &sqliteUpdateTxOps{qtx: qtx}) + return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } -// sqliteUpdateTxOps adapts sqlite sqlc queries to the shared UpdateTx flow. -type sqliteUpdateTxOps struct { +// updateTxOps adapts sqlite sqlc queries to the shared UpdateTx flow. +type updateTxOps struct { // qtx is the transaction-scoped sqlite query set used by UpdateTx. qtx *sqlcsqlite.Queries @@ -39,11 +39,11 @@ type sqliteUpdateTxOps struct { status int64 } -var _ db.UpdateTxOps = (*sqliteUpdateTxOps)(nil) +var _ db.UpdateTxOps = (*updateTxOps)(nil) // LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. -func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, +func (o *updateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -66,13 +66,13 @@ func (o *sqliteUpdateTxOps) LoadIsCoinbase(ctx context.Context, // PrepareState validates any referenced confirming block and captures the // sqlite-specific state params for the later row update. -func (o *sqliteUpdateTxOps) PrepareState(ctx context.Context, +func (o *updateTxOps) PrepareState(ctx context.Context, state db.UpdateTxState) error { blockHeight := sql.NullInt64{} if state.Block != nil { - height, err := requireBlockMatchesSqlite(ctx, o.qtx, state.Block) + height, err := requireBlockMatches(ctx, o.qtx, state.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -87,7 +87,7 @@ func (o *sqliteUpdateTxOps) PrepareState(ctx context.Context, } // UpdateLabel writes one user-visible label change. -func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, +func (o *updateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { rows, err := o.qtx.UpdateTransactionLabelByHash( @@ -111,7 +111,7 @@ func (o *sqliteUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, // UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. -func (o *sqliteUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, +func (o *updateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, _ db.UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( diff --git a/wallet/internal/db/sqlite/utxo_extra_test.go b/wallet/internal/db/sqlite/utxo_extra_test.go index f2dd783578..623106d9b1 100644 --- a/wallet/internal/db/sqlite/utxo_extra_test.go +++ b/wallet/internal/db/sqlite/utxo_extra_test.go @@ -14,7 +14,7 @@ func TestUtxoInfoFromSqliteRowInvalidOutputIndex(t *testing.T) { t.Parallel() hash := chainhash.Hash{13} - _, err := utxoInfoFromSqliteRow( + _, err := utxoInfoFromRow( hash[:], -1, 1000, []byte{0x57}, time.Unix(888, 0), false, sql.NullInt64{}, ) @@ -26,7 +26,7 @@ func TestUtxoInfoFromSqliteRowInvalidBlockHeight(t *testing.T) { t.Parallel() hash := chainhash.Hash{14} - _, err := utxoInfoFromSqliteRow( + _, err := utxoInfoFromRow( hash[:], 0, 1000, []byte{0x58}, time.Unix(999, 0), false, sql.NullInt64{Int64: -1, Valid: true}, ) diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index a63615b790..f0eb24dbdb 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -34,15 +34,15 @@ func (s *SqliteStore) GetUtxo(ctx context.Context, return nil, fmt.Errorf("get utxo: %w", err) } - return utxoInfoFromSqliteRow( + return utxoInfoFromRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, row.ReceivedTime, row.IsCoinbase, row.BlockHeight, ) } -// utxoInfoFromSqliteRow converts one normalized sqlite query row into the +// utxoInfoFromRow converts one normalized sqlite query row into the // public UtxoInfo shape. -func utxoInfoFromSqliteRow(hash []byte, outputIndex int64, amount int64, +func utxoInfoFromRow(hash []byte, outputIndex int64, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight sql.NullInt64) (*db.UtxoInfo, error) { diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index 177644f847..19849dc232 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -23,7 +23,7 @@ func (s *SqliteStore) LeaseOutput(ctx context.Context, err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( - ctx, params, &sqliteLeaseOutputOps{qtx: qtx}, + ctx, params, &leaseOutputOps{qtx: qtx}, ) if err != nil { return err @@ -40,17 +40,17 @@ func (s *SqliteStore) LeaseOutput(ctx context.Context, return lease, nil } -// sqliteLeaseOutputOps adapts sqlite sqlc queries to the shared LeaseOutput +// leaseOutputOps adapts sqlite sqlc queries to the shared LeaseOutput // workflow. -type sqliteLeaseOutputOps struct { +type leaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ db.LeaseOutputOps = (*sqliteLeaseOutputOps)(nil) +var _ db.LeaseOutputOps = (*leaseOutputOps)(nil) // Acquire attempts to write or renew one sqlite lease row for the requested // outpoint. -func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, +func (o *leaseOutputOps) Acquire(ctx context.Context, params db.LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { @@ -77,7 +77,7 @@ func (o *sqliteLeaseOutputOps) Acquire(ctx context.Context, // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. -func (o *sqliteLeaseOutputOps) HasUtxo(ctx context.Context, +func (o *leaseOutputOps) HasUtxo(ctx context.Context, params db.LeaseOutputParams) (bool, error) { _, err := o.qtx.GetUtxoIDByOutpoint( diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index dd47d02cfb..433c4416fd 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -27,7 +27,7 @@ func (s *SqliteStore) ListUTXOs(ctx context.Context, utxos := make([]db.UtxoInfo, len(rows)) for i, row := range rows { - utxo, err := utxoInfoFromSqliteRow( + utxo, err := utxoInfoFromRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, row.ReceivedTime, row.IsCoinbase, row.BlockHeight, ) diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index dd716d9228..a7f9a0fc48 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -21,22 +21,22 @@ func (s *SqliteStore) ReleaseOutput(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { return db.ReleaseOutputWithOps( - ctx, params, &sqliteReleaseOutputOps{qtx: qtx}, + ctx, params, &releaseOutputOps{qtx: qtx}, ) }) } -// sqliteReleaseOutputOps adapts sqlite sqlc queries to the shared +// releaseOutputOps adapts sqlite sqlc queries to the shared // ReleaseOutput workflow. -type sqliteReleaseOutputOps struct { +type releaseOutputOps struct { qtx *sqlcsqlite.Queries } -var _ db.ReleaseOutputOps = (*sqliteReleaseOutputOps)(nil) +var _ db.ReleaseOutputOps = (*releaseOutputOps)(nil) // LookupUtxoID resolves the wallet-owned outpoint to its stable sqlite UTXO row // ID. -func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, +func (o *releaseOutputOps) LookupUtxoID(ctx context.Context, params db.ReleaseOutputParams) (int64, error) { utxoID, err := o.qtx.GetUtxoIDByOutpoint( @@ -59,7 +59,7 @@ func (o *sqliteReleaseOutputOps) LookupUtxoID(ctx context.Context, // Release attempts to delete the sqlite lease row for the provided UTXO ID and // lock ID. -func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, +func (o *releaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( @@ -78,7 +78,7 @@ func (o *sqliteReleaseOutputOps) Release(ctx context.Context, walletID uint32, // ActiveLockID returns the currently active sqlite lease lock ID for the // provided UTXO ID. -func (o *sqliteReleaseOutputOps) ActiveLockID(ctx context.Context, +func (o *releaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 8e687e253b..51f582534f 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -84,7 +84,7 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, ) } - info, err = buildSqliteWalletInfo(sqliteWalletRowParams{ + info, err = buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -127,7 +127,7 @@ func (s *SqliteStore) GetWallet(ctx context.Context, return nil, fmt.Errorf("get wallet: %w", err) } - return buildSqliteWalletInfo(sqliteWalletRowParams{ + return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -148,7 +148,7 @@ func (s *SqliteStore) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets( - ctx, sqliteListWalletsParams(query.Page), + ctx, listWalletsParams(query.Page), ) if err != nil { return page.Result[db.WalletInfo, uint32]{}, @@ -157,7 +157,7 @@ func (s *SqliteStore) ListWallets(ctx context.Context, items := make([]db.WalletInfo, len(rows)) for i, row := range rows { - item, errMap := sqliteListWalletRowToInfo(row) + item, errMap := listWalletRowToInfo(row) if errMap != nil { return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: map row: %w", errMap) @@ -195,7 +195,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { - err := ensureBlockExistsSqlite( + err := ensureBlockExists( ctx, qtx, params.SyncedTo, ) if err != nil { @@ -205,7 +205,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, } if params.BirthdayBlock != nil { - err := ensureBlockExistsSqlite( + err := ensureBlockExists( ctx, qtx, params.BirthdayBlock, ) if err != nil { @@ -214,7 +214,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, } } - syncParams := buildUpdateSyncParamsSqlite(params) + syncParams := buildUpdateSyncParams(params) rowsAffected, err := qtx.UpdateWalletSyncState(ctx, syncParams) if err != nil { @@ -281,9 +281,9 @@ func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, return nil } -// sqliteWalletRowParams holds the parameters needed to build a +// walletRowParams holds the parameters needed to build a // WalletInfo from a wallet row. -type sqliteWalletRowParams struct { +type walletRowParams struct { id int64 name string isImported bool @@ -298,12 +298,12 @@ type sqliteWalletRowParams struct { birthdayBlockTimestamp sql.NullInt64 } -// sqliteListWalletRowToInfo converts a ListWallets result row to a WalletInfo +// listWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. -func sqliteListWalletRowToInfo( +func listWalletRowToInfo( row sqlcsqlite.ListWalletsRow) (*db.WalletInfo, error) { - return buildSqliteWalletInfo(sqliteWalletRowParams{ + return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -319,9 +319,9 @@ func sqliteListWalletRowToInfo( }) } -// sqliteListWalletsParams translates a page request to ListWallets query +// listWalletsParams translates a page request to ListWallets query // parameters, handling optional cursor setup for pagination. -func sqliteListWalletsParams( +func listWalletsParams( req page.Request[uint32]) sqlcsqlite.ListWalletsParams { params := sqlcsqlite.ListWalletsParams{ @@ -335,9 +335,9 @@ func sqliteListWalletsParams( return params } -// buildSqliteWalletInfo constructs a WalletInfo from the given wallet +// buildWalletInfo constructs a WalletInfo from the given wallet // row parameters. -func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { +func buildWalletInfo(row walletRowParams) (*db.WalletInfo, error) { walletID, err := db.Int64ToUint32(row.id) if err != nil { return nil, err @@ -361,7 +361,7 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { } if row.syncedHeight.Valid { - block, err := buildSqliteBlock( + block, err := buildBlock( row.syncedHeight, row.syncedBlockHash, row.syncedBlockTimestamp, @@ -374,7 +374,7 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { } if row.birthdayHeight.Valid { - block, err := buildSqliteBlock( + block, err := buildBlock( row.birthdayHeight, row.birthdayBlockHash, row.birthdayBlockTimestamp, @@ -389,9 +389,9 @@ func buildSqliteWalletInfo(row sqliteWalletRowParams) (*db.WalletInfo, error) { return info, nil } -// buildUpdateSyncParamsSqlite constructs the UpdateWalletSyncStateParams from +// buildUpdateSyncParams constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. -func buildUpdateSyncParamsSqlite( +func buildUpdateSyncParams( params db.UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ From 4e3b5d5c1f583af8215a80d4fd056b85a15b51dd Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 06:57:20 +0800 Subject: [PATCH 536/691] db/pg: drop private backend prefixes Rename the pg package's private helpers, adapters, and row conversion types so they rely on the package namespace instead of repeating pg in every identifier. This keeps the backend-specific package easier to scan now that the split already provides the backend context. --- wallet/internal/db/pg/accounts.go | 78 ++++++------ wallet/internal/db/pg/address_types.go | 8 +- wallet/internal/db/pg/addresses.go | 116 +++++++++--------- wallet/internal/db/pg/backend_error_test.go | 32 ++--- wallet/internal/db/pg/backend_rows_test.go | 18 +-- wallet/internal/db/pg/block.go | 12 +- wallet/internal/db/pg/txstore_createtx.go | 76 ++++++------ wallet/internal/db/pg/txstore_deletetx.go | 30 ++--- wallet/internal/db/pg/txstore_gettx.go | 8 +- .../db/pg/txstore_invalidateunmined.go | 16 +-- wallet/internal/db/pg/txstore_listtxns.go | 16 +-- wallet/internal/db/pg/txstore_rollback.go | 28 ++--- wallet/internal/db/pg/txstore_updatetx.go | 18 +-- wallet/internal/db/pg/utxo_extra_test.go | 4 +- wallet/internal/db/pg/utxostore_getutxo.go | 6 +- .../internal/db/pg/utxostore_leaseoutput.go | 12 +- wallet/internal/db/pg/utxostore_listutxos.go | 8 +- .../internal/db/pg/utxostore_releaseoutput.go | 14 +-- wallet/internal/db/pg/wallet.go | 40 +++--- 19 files changed, 270 insertions(+), 270 deletions(-) diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index 2ee775c5c1..cda543c54f 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -17,7 +17,7 @@ var _ db.AccountStore = (*PostgresStore)(nil) func (s *PostgresStore) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { - getQueries := pgAccountGetQueries{q: s.queries} + getQueries := accountGetQueries{q: s.queries} return db.GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) } @@ -27,7 +27,7 @@ func (s *PostgresStore) GetAccount(ctx context.Context, func (s *PostgresStore) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { - listQueries := pgAccountListQueries{q: s.queries} + listQueries := accountListQueries{q: s.queries} return db.ListAccountsByQuery( ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, @@ -39,7 +39,7 @@ func (s *PostgresStore) ListAccounts(ctx context.Context, func (s *PostgresStore) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { - renameQueries := pgAccountRenameQueries{q: s.queries} + renameQueries := accountRenameQueries{q: s.queries} return db.RenameAccountByQuery( ctx, params, renameQueries.byNumber, renameQueries.byName, @@ -60,7 +60,7 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - scopeID, err := pgEnsureKeyScope( + scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) if err != nil { @@ -128,13 +128,13 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, props, err = db.CreateImportedAccount( ctx, params, func() (int64, error) { - return pgEnsureKeyScope(ctx, qtx, params.WalletID, params.Scope) + return ensureKeyScope(ctx, qtx, params.WalletID, params.Scope) }, qtx.CreateImportedAccount, - pgBuildCreateImportedAccountArgs(params), + buildCreateImportedAccountArgs(params), func(row sqlcpg.CreateImportedAccountRow) int64 { return row.ID }, - qtx.CreateAccountSecret, pgBuildCreateAccountSecretArgs(params), + qtx.CreateAccountSecret, buildCreateAccountSecretArgs(params), func(accountID int64) (*db.AccountProperties, error) { - return pgGetAccountProps(ctx, qtx, accountID) + return getAccountProps(ctx, qtx, accountID) }, ) @@ -147,9 +147,9 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, return props, nil } -// pgBuildCreateImportedAccountArgs returns a function that builds the +// buildCreateImportedAccountArgs returns a function that builds the // CreateImportedAccountParams for PostgreSQL. -func pgBuildCreateImportedAccountArgs( +func buildCreateImportedAccountArgs( params db.CreateImportedAccountParams, ) func(int64, bool) sqlcpg.CreateImportedAccountParams { @@ -170,9 +170,9 @@ func pgBuildCreateImportedAccountArgs( } } -// pgBuildCreateAccountSecretArgs returns a function that builds the +// buildCreateAccountSecretArgs returns a function that builds the // CreateAccountSecretParams for PostgreSQL. -func pgBuildCreateAccountSecretArgs( +func buildCreateAccountSecretArgs( params db.CreateImportedAccountParams, ) func(int64) sqlcpg.CreateAccountSecretParams { @@ -184,9 +184,9 @@ func pgBuildCreateAccountSecretArgs( } } -// pgGetAccountProps fetches full account properties from the database and +// getAccountProps fetches full account properties from the database and // converts the row to AccountProperties. -func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, +func getAccountProps(ctx context.Context, qtx *sqlcpg.Queries, accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) @@ -214,9 +214,9 @@ func pgGetAccountProps(ctx context.Context, qtx *sqlcpg.Queries, }) } -// pgEnsureKeyScope retrieves an existing key scope or creates it if missing for +// ensureKeyScope retrieves an existing key scope or creates it if missing for // PostgreSQL. It returns the scope ID once available. -func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, +func ensureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, scope db.KeyScope) (int64, error) { return db.EnsureKeyScope( @@ -246,10 +246,10 @@ func pgEnsureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, ) } -// pgAccountInfoRow is a type constraint for PostgreSQL account info row types +// accountInfoRow is a type constraint for PostgreSQL account info row types // that share the same field structure. This enables a single generic conversion // function to handle all account query result types. -type pgAccountInfoRow interface { +type accountInfoRow interface { sqlcpg.GetAccountByScopeAndNameRow | sqlcpg.GetAccountByScopeAndNumberRow | sqlcpg.GetAccountByWalletScopeAndNameRow | @@ -259,10 +259,10 @@ type pgAccountInfoRow interface { sqlcpg.ListAccountsByWalletAndNameRow } -// pgAccountRowToInfo converts a PostgreSQL account row to an AccountInfo -// struct. It uses type conversion since all pgAccountInfoRow types have +// accountRowToInfo converts a PostgreSQL account row to an AccountInfo +// struct. It uses type conversion since all accountInfoRow types have // identical fields. -func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*db.AccountInfo, error) { +func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAccountByScopeAndNameRow(row) @@ -282,13 +282,13 @@ func pgAccountRowToInfo[T pgAccountInfoRow](row T) (*db.AccountInfo, error) { }) } -// pgAccountListQueries groups PostgreSQL account listing query methods. -type pgAccountListQueries struct { +// accountListQueries groups PostgreSQL account listing query methods. +type accountListQueries struct { q *sqlcpg.Queries } // byScope lists accounts filtered by wallet ID and key scope. -func (p pgAccountListQueries) byScope(ctx context.Context, +func (p accountListQueries) byScope(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( @@ -297,12 +297,12 @@ func (p pgAccountListQueries) byScope(ctx context.Context, WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), - }, pgAccountRowToInfo, + }, accountRowToInfo, ) } // byName lists accounts filtered by wallet ID and account name. -func (p pgAccountListQueries) byName(ctx context.Context, +func (p accountListQueries) byName(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( @@ -310,27 +310,27 @@ func (p pgAccountListQueries) byName(ctx context.Context, sqlcpg.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), AccountName: *query.Name, - }, pgAccountRowToInfo, + }, accountRowToInfo, ) } // all lists all accounts for a wallet. -func (p pgAccountListQueries) all(ctx context.Context, +func (p accountListQueries) all(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { return db.ListAccounts( ctx, p.q.ListAccountsByWallet, int64(query.WalletID), - pgAccountRowToInfo, + accountRowToInfo, ) } -// pgAccountGetQueries groups PostgreSQL account retrieval query methods. -type pgAccountGetQueries struct { +// accountGetQueries groups PostgreSQL account retrieval query methods. +type accountGetQueries struct { q *sqlcpg.Queries } // byNumber retrieves an account by wallet ID, scope, and account number. -func (p pgAccountGetQueries) byNumber(ctx context.Context, +func (p accountGetQueries) byNumber(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount( @@ -340,12 +340,12 @@ func (p pgAccountGetQueries) byNumber(ctx context.Context, Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), - }, query, pgAccountRowToInfo, + }, query, accountRowToInfo, ) } // byName retrieves an account by wallet ID, scope, and account name. -func (p pgAccountGetQueries) byName(ctx context.Context, +func (p accountGetQueries) byName(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount( @@ -355,18 +355,18 @@ func (p pgAccountGetQueries) byName(ctx context.Context, Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), AccountName: *query.Name, - }, query, pgAccountRowToInfo, + }, query, accountRowToInfo, ) } -// pgAccountRenameQueries groups PostgreSQL account rename query methods. -type pgAccountRenameQueries struct { +// accountRenameQueries groups PostgreSQL account rename query methods. +type accountRenameQueries struct { q *sqlcpg.Queries } // byNumber renames an account identified by wallet ID, scope, and account // number. -func (p pgAccountRenameQueries) byNumber(ctx context.Context, +func (p accountRenameQueries) byNumber(ctx context.Context, params db.RenameAccountParams) error { return db.RenameAccount( @@ -383,7 +383,7 @@ func (p pgAccountRenameQueries) byNumber(ctx context.Context, // byName renames an account identified by wallet ID, scope, and old account // name. -func (p pgAccountRenameQueries) byName(ctx context.Context, +func (p accountRenameQueries) byName(ctx context.Context, params db.RenameAccountParams) error { return db.RenameAccount( diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index 46388b9d09..85346c1f35 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -7,9 +7,9 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -// pgAddressTypeRowToInfo converts a PostgreSQL address type row to an +// addressTypeRowToInfo converts a PostgreSQL address type row to an // AddressTypeInfo struct. -func pgAddressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { +func addressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { addrType, err := db.IDToAddressType(row.ID) if err != nil { return db.AddressTypeInfo{}, err @@ -27,7 +27,7 @@ func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { return db.ListAddressTypes( - ctx, s.queries.ListAddressTypes, pgAddressTypeRowToInfo, + ctx, s.queries.ListAddressTypes, addressTypeRowToInfo, ) } @@ -38,6 +38,6 @@ func (s *PostgresStore) GetAddressType(ctx context.Context, return db.GetAddressTypeByID( ctx, s.queries.GetAddressTypeByID, int16(id), id, - pgAddressTypeRowToInfo, + addressTypeRowToInfo, ) } diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 3a35ad3c88..2e9b13b736 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -27,7 +27,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, sqlcpg.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, WalletID: int64(q.WalletID), - }, pgAddressRowToInfo, + }, addressRowToInfo, ) } @@ -38,7 +38,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, func (s *PostgresStore) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { - items, err := pgListAddressesByAccount(ctx, s.queries, query) + items, err := listAddressesByAccount(ctx, s.queries, query) if err != nil { return page.Result[db.AddressInfo, uint32]{}, err } @@ -67,7 +67,7 @@ func (s *PostgresStore) GetAddressSecret(ctx context.Context, addressID uint32) (*db.AddressSecret, error) { return db.GetAddressSecret( - ctx, s.queries.GetAddressSecret, addressID, pgAddressSecretRowToSecret, + ctx, s.queries.GetAddressSecret, addressID, addressSecretRowToSecret, ) } @@ -82,14 +82,14 @@ func (s *PostgresStore) NewDerivedAddress(ctx context.Context, sqlcpg.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, sqlcpg.CreateDerivedAddressRow]{ - GetAccount: pgGetAccountFromKey(s.queries), + GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromParams, - GetAccountID: newDerivedAddressGetAccountIDPg, - GetExtIndex: newDerivedAddressGetExtIndexPg, - GetIntIndex: newDerivedAddressGetIntIndexPg, - CreateAddr: newDerivedAddressCreateAddrPg, - RowID: newDerivedAddressRowIDPg, - RowCreatedAt: newDerivedAddressRowCreatedAtPg, + GetAccountID: derivedAddressGetAccountID, + GetExtIndex: derivedAddressGetExtIndex, + GetIntIndex: derivedAddressGetIntIndex, + CreateAddr: derivedAddressCreateAddr, + RowID: derivedAddressRowID, + RowCreatedAt: derivedAddressRowCreatedAt, } return db.NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) @@ -106,22 +106,22 @@ func (s *PostgresStore) NewImportedAddress(ctx context.Context, sqlcpg.CreateImportedAddressParams, sqlcpg.CreateImportedAddressRow, sqlcpg.InsertAddressSecretParams]{ - GetAccount: pgGetAccountFromKey(s.queries), + GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromImportedParams, - GetAccountID: newImportedAddressGetAccountIDPg, - CreateAddr: pgCreateImportedAddress, - CreateParams: createImportedAddressParamsPg, - InsertSecret: pgInsertAddressSecret, - SecretParams: insertAddressSecretParamsPg, - RowID: importedAddressRowIDPg, - RowCreatedAt: importedAddressRowCreatedAtPg, + GetAccountID: importedAddressGetAccountID, + CreateAddr: createImportedAddress, + CreateParams: createImportedAddressParams, + InsertSecret: insertAddressSecret, + SecretParams: insertAddressSecretParams, + RowID: importedAddressRowID, + RowCreatedAt: importedAddressRowCreatedAt, } return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) } -// pgGetAccountFromKey returns a helper to look up accounts by key. -func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, +// getAccountFromKey returns a helper to look up accounts by key. +func getAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, @@ -139,29 +139,29 @@ func pgGetAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, } } -// newDerivedAddressGetAccountIDPg extracts the account ID from a row. -func newDerivedAddressGetAccountIDPg( +// derivedAddressGetAccountID extracts the account ID from a row. +func derivedAddressGetAccountID( row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } -// newDerivedAddressGetExtIndexPg returns the external index query. -func newDerivedAddressGetExtIndexPg(qtx *sqlcpg.Queries) func(context.Context, +// derivedAddressGetExtIndex returns the external index query. +func derivedAddressGetExtIndex(qtx *sqlcpg.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextExternalIndex } -// newDerivedAddressGetIntIndexPg returns the internal index query. -func newDerivedAddressGetIntIndexPg(qtx *sqlcpg.Queries) func(context.Context, +// derivedAddressGetIntIndex returns the internal index query. +func derivedAddressGetIntIndex(qtx *sqlcpg.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextInternalIndex } -// newDerivedAddressCreateAddrPg returns the derived address insert helper. -func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, +// derivedAddressCreateAddr returns the derived address insert helper. +func derivedAddressCreateAddr(qtx *sqlcpg.Queries) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, error) { @@ -195,42 +195,42 @@ func newDerivedAddressCreateAddrPg(qtx *sqlcpg.Queries) func(context.Context, } } -// newDerivedAddressRowIDPg returns the created address ID. -func newDerivedAddressRowIDPg(row sqlcpg.CreateDerivedAddressRow) int64 { +// derivedAddressRowID returns the created address ID. +func derivedAddressRowID(row sqlcpg.CreateDerivedAddressRow) int64 { return row.ID } -// newDerivedAddressRowCreatedAtPg returns the CreatedAt timestamp. -func newDerivedAddressRowCreatedAtPg( +// derivedAddressRowCreatedAt returns the CreatedAt timestamp. +func derivedAddressRowCreatedAt( row sqlcpg.CreateDerivedAddressRow) time.Time { return row.CreatedAt } -// newImportedAddressGetAccountIDPg extracts the account ID from a row. -func newImportedAddressGetAccountIDPg( +// importedAddressGetAccountID extracts the account ID from a row. +func importedAddressGetAccountID( row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } -// pgCreateImportedAddress returns the imported address insert helper. -func pgCreateImportedAddress(qtx *sqlcpg.Queries) func(context.Context, +// createImportedAddress returns the imported address insert helper. +func createImportedAddress(qtx *sqlcpg.Queries) func(context.Context, sqlcpg.CreateImportedAddressParams) (sqlcpg.CreateImportedAddressRow, error) { return qtx.CreateImportedAddress } -// pgInsertAddressSecret returns the secret insert helper. -func pgInsertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, +// insertAddressSecret returns the secret insert helper. +func insertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, sqlcpg.InsertAddressSecretParams) error { return qtx.InsertAddressSecret } -// createImportedAddressParamsPg maps imported params to sqlc params. -func createImportedAddressParamsPg(accountID int64, +// createImportedAddressParams maps imported params to sqlc params. +func createImportedAddressParams(accountID int64, params db.NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { return sqlcpg.CreateImportedAddressParams{ @@ -241,8 +241,8 @@ func createImportedAddressParamsPg(accountID int64, } } -// insertAddressSecretParamsPg maps imported params to secret params. -func insertAddressSecretParamsPg(addressID int64, +// insertAddressSecretParams maps imported params to secret params. +func insertAddressSecretParams(addressID int64, params db.NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { return sqlcpg.InsertAddressSecretParams{ @@ -252,21 +252,21 @@ func insertAddressSecretParamsPg(addressID int64, } } -// importedAddressRowIDPg returns the created address ID. -func importedAddressRowIDPg(row sqlcpg.CreateImportedAddressRow) int64 { +// importedAddressRowID returns the created address ID. +func importedAddressRowID(row sqlcpg.CreateImportedAddressRow) int64 { return row.ID } -// importedAddressRowCreatedAtPg returns the CreatedAt timestamp. -func importedAddressRowCreatedAtPg( +// importedAddressRowCreatedAt returns the CreatedAt timestamp. +func importedAddressRowCreatedAt( row sqlcpg.CreateImportedAddressRow) time.Time { return row.CreatedAt } -// pgAddressSecretRowToSecret converts a PostgreSQL address secret row to an +// addressSecretRowToSecret converts a PostgreSQL address secret row to an // AddressSecret struct. -func pgAddressSecretRowToSecret( +func addressSecretRowToSecret( row sqlcpg.GetAddressSecretRow) (*db.AddressSecret, error) { return db.AddressSecretRowToSecret(db.AddressSecretRow{ @@ -276,17 +276,17 @@ func pgAddressSecretRowToSecret( }) } -// pgAddressInfoRow is a type constraint that unifies all PostgreSQL address +// addressInfoRow is a type constraint that unifies all PostgreSQL address // row types that share the same field structure. This enables a single // generic conversion function to handle all address query result types. -type pgAddressInfoRow interface { +type addressInfoRow interface { sqlcpg.GetAddressByScriptPubKeyRow | sqlcpg.ListAddressesByAccountRow } -// pgAddressRowToInfo converts a PostgreSQL address row to an AddressInfo +// addressRowToInfo converts a PostgreSQL address row to an AddressInfo // struct. -func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*db.AddressInfo, error) { +func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. base := sqlcpg.GetAddressByScriptPubKeyRow(row) @@ -316,13 +316,13 @@ func pgAddressRowToInfo[T pgAddressInfoRow](row T) (*db.AddressInfo, error) { return info, nil } -// pgListAddressesByAccount lists addresses filtered by wallet ID, key scope, +// listAddressesByAccount lists addresses filtered by wallet ID, key scope, // and account name, with pagination support. -func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, +func listAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( - ctx, pgBuildAddressPageParams(query), + ctx, buildAddressPageParams(query), ) if err != nil { return nil, fmt.Errorf("list addresses by account: %w", err) @@ -330,7 +330,7 @@ func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, items := make([]db.AddressInfo, len(rows)) for i, row := range rows { - item, err := pgAddressRowToInfo(row) + item, err := addressRowToInfo(row) if err != nil { return nil, fmt.Errorf("list addresses by account: map address row: %w", @@ -343,9 +343,9 @@ func pgListAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, return items, nil } -// pgBuildAddressPageParams translates a ListAddresses query to +// buildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. -func pgBuildAddressPageParams( +func buildAddressPageParams( q db.ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { params := sqlcpg.ListAddressesByAccountParams{ diff --git a/wallet/internal/db/pg/backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go index 7643d87983..4bc4c373a0 100644 --- a/wallet/internal/db/pg/backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -60,8 +60,8 @@ func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { execErr: errDummy, queryErr: errDummy, }) - deleteOps := pgDeleteTxOps{qtx: qtx} - rollbackOps := pgRollbackToBlockOps{qtx: qtx} + deleteOps := deleteTxOps{qtx: qtx} + rollbackOps := rollbackToBlockOps{qtx: qtx} err := deleteOps.ClearSpentUtxos(t.Context(), 1, 2) require.ErrorContains(t, err, "clear spent utxo rows") @@ -91,13 +91,13 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() qtx := sqlcpg.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) - createOps := &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{qtx: qtx}, + createOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{qtx: qtx}, } - invalidateOps := pgInvalidateUnminedTxOps{qtx: qtx} - rollbackOps := pgRollbackToBlockOps{qtx: qtx} - updateOps := &pgUpdateTxOps{qtx: qtx} - releaseOps := pgReleaseOutputOps{qtx: qtx} + invalidateOps := invalidateUnminedTxOps{qtx: qtx} + rollbackOps := rollbackToBlockOps{qtx: qtx} + updateOps := &updateTxOps{qtx: qtx} + releaseOps := releaseOutputOps{qtx: qtx} err := createOps.MarkTxnsReplaced( t.Context(), 1, []int64{2}, @@ -109,7 +109,7 @@ func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { ) require.ErrorContains(t, err, "insert replacement edge") - err = markInputsSpentPg(t.Context(), qtx, db.CreateTxParams{ + err = markInputsSpent(t.Context(), qtx, db.CreateTxParams{ WalletID: 1, Tx: testRegularMsgTx(), Received: time.Unix(1, 0), @@ -175,15 +175,15 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }) require.NoError(t, err) - _, err = collectPgConflictRootIDs( + _, err = collectConflictRootIDs( t.Context(), nil, req, ) require.ErrorContains(t, err, "convert input outpoint index 0") - _, err = creditExistsPg(t.Context(), nil, 1, chainhash.Hash{1}, ^uint32(0)) + _, err = creditExists(t.Context(), nil, 1, chainhash.Hash{1}, ^uint32(0)) require.ErrorContains(t, err, "convert credit index") - err = markInputsSpentPg(t.Context(), nil, db.CreateTxParams{ + err = markInputsSpent(t.Context(), nil, db.CreateTxParams{ WalletID: 1, Tx: &wire.MsgTx{ Version: wire.TxVersion, @@ -198,24 +198,24 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { }, 3) require.ErrorContains(t, err, "convert input outpoint index 0") - err = pgRollbackToBlockOps{}.RewindWalletSyncStateHeights( + err = rollbackToBlockOps{}.RewindWalletSyncStateHeights( t.Context(), ^uint32(0), ) require.ErrorContains(t, err, "convert rollback height") - err = pgRollbackToBlockOps{}.DeleteBlocksAtOrAboveHeight( + err = rollbackToBlockOps{}.DeleteBlocksAtOrAboveHeight( t.Context(), ^uint32(0), ) require.ErrorContains(t, err, "convert rollback height") - _, _, err = buildPgConflictRoots([]sqlcpg.ListUnminedTransactionsRow{{ + _, _, err = buildConflictRoots([]sqlcpg.ListUnminedTransactionsRow{{ ID: 1, TxHash: []byte{1}, TxStatus: 0, }}, map[int64]struct{}{1: {}}) require.ErrorContains(t, err, "tx hash") - leaseOps := &pgLeaseOutputOps{} + leaseOps := &leaseOutputOps{} _, err = leaseOps.Acquire(t.Context(), db.LeaseOutputParams{ WalletID: 1, diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index e1abc6277d..2f17fceb61 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -92,8 +92,8 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { req := testCreateTxRequest(t) ctx := context.Background() - loadOps := &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + loadOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), @@ -108,8 +108,8 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { Height: 7, Timestamp: time.Unix(77, 0), } - confirmOps := &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + confirmOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow( t, "SELECT ?, ?, ?", @@ -126,8 +126,8 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { }, db.CreateTxExistingTarget{}) require.ErrorIs(t, err, db.ErrTxNotFound) - conflictOps := &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + conflictOps := &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT ?", int64(5)), queryErr: errDummy, @@ -146,15 +146,15 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} - loadOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{ + loadOps := &updateTxOps{qtx: sqlcpg.New(rowDBTX{ row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} - stateOps := &pgUpdateTxOps{ + stateOps := &updateTxOps{ qtx: sqlcpg.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt32{}, status: int16(db.TxStatusPublished), } - labelOps := &pgUpdateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} + labelOps := &updateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") diff --git a/wallet/internal/db/pg/block.go b/wallet/internal/db/pg/block.go index 4cd4881d40..16d29c6476 100644 --- a/wallet/internal/db/pg/block.go +++ b/wallet/internal/db/pg/block.go @@ -11,9 +11,9 @@ import ( sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -// buildPgBlock constructs a Block from the given PostgreSQL block +// buildBlock constructs a Block from the given PostgreSQL block // fields. -func buildPgBlock(height sql.NullInt32, hash []byte, +func buildBlock(height sql.NullInt32, hash []byte, timestamp sql.NullInt64) (*db.Block, error) { height32, err := db.NullInt32ToUint32(height) @@ -24,8 +24,8 @@ func buildPgBlock(height sql.NullInt32, hash []byte, return db.BuildBlock(hash, height32, timestamp.Int64) } -// ensureBlockExistsPg ensures that a block exists in the database. -func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, +// ensureBlockExists ensures that a block exists in the database. +func ensureBlockExists(ctx context.Context, qtx *sqlcpg.Queries, block *db.Block) error { height, err := db.Uint32ToInt32(block.Height) @@ -47,9 +47,9 @@ func ensureBlockExistsPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// requireBlockMatchesPg loads the shared block row for the provided height and +// requireBlockMatches loads the shared block row for the provided height and // verifies that its stored metadata matches the supplied block reference. -func requireBlockMatchesPg(ctx context.Context, qtx *sqlcpg.Queries, +func requireBlockMatches(ctx context.Context, qtx *sqlcpg.Queries, block *db.Block) (int32, error) { height, err := db.Uint32ToInt32(block.Height) diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index 63df6378b4..83e08cd100 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -30,25 +30,25 @@ func (s *PostgresStore) CreateTx(ctx context.Context, } return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return db.CreateTxWithOps(ctx, req, &pgCreateTxOps{ - pgInvalidateUnminedTxOps: pgInvalidateUnminedTxOps{ + return db.CreateTxWithOps(ctx, req, &createTxOps{ + invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, }, }) }) } -// pgCreateTxOps adapts postgres sqlc queries to the shared CreateTx flow. -type pgCreateTxOps struct { - pgInvalidateUnminedTxOps +// createTxOps adapts postgres sqlc queries to the shared CreateTx flow. +type createTxOps struct { + invalidateUnminedTxOps blockHeight sql.NullInt32 } -var _ db.CreateTxOps = (*pgCreateTxOps)(nil) +var _ db.CreateTxOps = (*createTxOps)(nil) // LoadExisting loads any existing wallet-scoped row for the requested tx hash. -func (o *pgCreateTxOps) LoadExisting(ctx context.Context, +func (o *createTxOps) LoadExisting(ctx context.Context, req db.CreateTxRequest) (*db.CreateTxExistingTarget, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -80,11 +80,11 @@ func (o *pgCreateTxOps) LoadExisting(ctx context.Context, } // ConfirmExisting promotes one existing unmined row to its confirmed state. -func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, +func (o *createTxOps) ConfirmExisting(ctx context.Context, req db.CreateTxRequest, _ db.CreateTxExistingTarget) error { - blockHeight, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) + blockHeight, err := requireBlockMatches(ctx, o.qtx, req.Params.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -110,7 +110,7 @@ func (o *pgCreateTxOps) ConfirmExisting(ctx context.Context, // PrepareBlock validates the optional confirming block and caches the postgres // block-height value that the later Insert query will store. -func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, +func (o *createTxOps) PrepareBlock(ctx context.Context, req db.CreateTxRequest) error { o.blockHeight = sql.NullInt32{} @@ -119,7 +119,7 @@ func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, return nil } - height, err := requireBlockMatchesPg(ctx, o.qtx, req.Params.Block) + height, err := requireBlockMatches(ctx, o.qtx, req.Params.Block) if err != nil { return err } @@ -131,10 +131,10 @@ func (o *pgCreateTxOps) PrepareBlock(ctx context.Context, // ListConflictTxns returns the direct conflict root IDs plus the matching tx // hashes used for descendant discovery. -func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, +func (o *createTxOps) ListConflictTxns(ctx context.Context, req db.CreateTxRequest) ([]int64, []chainhash.Hash, error) { - rootIDs, err := collectPgConflictRootIDs(ctx, o.qtx, req) + rootIDs, err := collectConflictRootIDs(ctx, o.qtx, req) if err != nil { return nil, nil, err } @@ -148,12 +148,12 @@ func (o *pgCreateTxOps) ListConflictTxns(ctx context.Context, return nil, nil, fmt.Errorf("list unmined txns: %w", err) } - return buildPgConflictRoots(rows, rootIDs) + return buildConflictRoots(rows, rootIDs) } -// collectPgConflictRootIDs returns the active unmined spender row IDs +// collectConflictRootIDs returns the active unmined spender row IDs // that currently own any wallet-controlled input spent by the incoming tx. -func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, +func collectConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { @@ -194,9 +194,9 @@ func collectPgConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, return rootIDs, nil } -// buildPgConflictRoots maps the selected unmined rows into ordered root IDs and +// buildConflictRoots maps the selected unmined rows into ordered root IDs and // the matching root hashes used for descendant discovery. -func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, +func buildConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, rootIDSet map[int64]struct{}) ( []int64, []chainhash.Hash, error) { @@ -221,7 +221,7 @@ func buildPgConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, } // Insert stores one new postgres transaction row for CreateTx. -func (o *pgCreateTxOps) Insert(ctx context.Context, +func (o *createTxOps) Insert(ctx context.Context, req db.CreateTxRequest) (int64, error) { txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ @@ -242,22 +242,22 @@ func (o *pgCreateTxOps) Insert(ctx context.Context, } // InsertCredits stores any wallet-owned outputs created by the transaction. -func (o *pgCreateTxOps) InsertCredits(ctx context.Context, +func (o *createTxOps) InsertCredits(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return insertCreditsPg(ctx, o.qtx, req.Params, txID) + return insertCredits(ctx, o.qtx, req.Params, txID) } // MarkInputsSpent records wallet-owned inputs spent by the transaction. -func (o *pgCreateTxOps) MarkInputsSpent(ctx context.Context, +func (o *createTxOps) MarkInputsSpent(ctx context.Context, req db.CreateTxRequest, txID int64) error { - return markInputsSpentPg(ctx, o.qtx, req.Params, txID) + return markInputsSpent(ctx, o.qtx, req.Params, txID) } // MarkTxnsReplaced marks the provided direct conflict roots replaced in one // batch update. -func (o *pgCreateTxOps) MarkTxnsReplaced( +func (o *createTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -276,7 +276,7 @@ func (o *pgCreateTxOps) MarkTxnsReplaced( // InsertReplacementEdges records replacement-history edges from each direct // conflict root to the newly inserted confirmed transaction row. -func (o *pgCreateTxOps) InsertReplacementEdges( +func (o *createTxOps) InsertReplacementEdges( ctx context.Context, walletID int64, replacedTxIDs []int64, replacementTxID int64) error { @@ -297,13 +297,13 @@ func (o *pgCreateTxOps) InsertReplacementEdges( return nil } -// insertCreditsPg inserts one wallet-owned UTXO row for each credited output of +// insertCredits inserts one wallet-owned UTXO row for each credited output of // the transaction being stored. -func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, +func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, params db.CreateTxParams, txID int64) error { for index := range params.Credits { - creditExists, err := creditExistsPg( + creditExists, err := creditExists( ctx, qtx, params.WalletID, params.Tx.TxHash(), index, ) if err != nil { @@ -351,9 +351,9 @@ func insertCreditsPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// creditExistsPg reports whether the wallet already has a UTXO row for the +// creditExists reports whether the wallet already has a UTXO row for the // given credited output, even if that output is now spent by a child tx. -func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, +func creditExists(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { convertedIndex, err := db.Uint32ToInt32(outputIndex) @@ -381,7 +381,7 @@ func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, return true, nil } -// markInputsSpentPg attaches wallet-owned outpoints spent by the stored +// markInputsSpent attaches wallet-owned outpoints spent by the stored // transaction to its row ID and input indexes. // // If another wallet transaction already owns the spend edge for a @@ -389,7 +389,7 @@ func creditExistsPg(ctx context.Context, qtx *sqlcpg.Queries, // instead of silently storing a second spender. Inputs that reference a // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. -func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, +func markInputsSpent(ctx context.Context, qtx *sqlcpg.Queries, params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { @@ -420,7 +420,7 @@ func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, } if rowsAffected == 0 { - err = ensureSpendConflictPg( + err = ensureSpendConflict( ctx, qtx, params.WalletID, txIn.PreviousOutPoint.Hash, outputIndex, txID, ) @@ -433,11 +433,11 @@ func markInputsSpentPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// ensureSpendConflictPg reports ErrTxInputConflict when the referenced outpoint +// ensureSpendConflict reports ErrTxInputConflict when the referenced outpoint // is wallet-owned, still eligible for spending, and already attached to another // transaction. If the wallet owns the parent output but that parent is already // invalid, the helper returns ErrTxInputInvalidParent instead. -func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, +func ensureSpendConflict(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int32, txID int64) error { @@ -450,7 +450,7 @@ func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return ensureWalletParentValidPg( + return ensureWalletParentValid( ctx, qtx, walletID, txHash, outputIndex, ) } @@ -465,9 +465,9 @@ func ensureSpendConflictPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// ensureWalletParentValidPg reports ErrTxInputInvalidParent when the wallet +// ensureWalletParentValid reports ErrTxInputInvalidParent when the wallet // owns the referenced outpoint but its parent transaction is already invalid. -func ensureWalletParentValidPg(ctx context.Context, qtx *sqlcpg.Queries, +func ensureWalletParentValid(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int32) error { hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index ee1d9122d8..25fefff1a8 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -22,23 +22,23 @@ func (s *PostgresStore) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return db.DeleteTxWithOps(ctx, params, pgDeleteTxOps{qtx: qtx}) + return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } -// pgDeleteTxOps adapts postgres sqlc queries to the shared DeleteTx flow. -type pgDeleteTxOps struct { +// deleteTxOps adapts postgres sqlc queries to the shared DeleteTx flow. +type deleteTxOps struct { qtx *sqlcpg.Queries } -var _ db.DeleteTxOps = (*pgDeleteTxOps)(nil) +var _ db.DeleteTxOps = (*deleteTxOps)(nil) // LoadDeleteTarget loads and validates the unmined transaction row DeleteTx is // allowed to remove. -func (o pgDeleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, +func (o deleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { - meta, err := getDeleteTxMetaPg(ctx, o.qtx, walletID, txHash) + meta, err := getDeleteTxMeta(ctx, o.qtx, walletID, txHash) if err != nil { return 0, err } @@ -48,15 +48,15 @@ func (o pgDeleteTxOps) LoadDeleteTarget(ctx context.Context, walletID uint32, // EnsureLeaf rejects DeleteTx when the target still has direct unmined child // spenders. -func (o pgDeleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, +func (o deleteTxOps) EnsureLeaf(ctx context.Context, walletID uint32, txHash chainhash.Hash, txID int64) error { - return ensureDeleteLeafPg(ctx, o.qtx, walletID, txHash, txID) + return ensureDeleteLeaf(ctx, o.qtx, walletID, txHash, txID) } // ClearSpentUtxos restores any wallet-owned parent outputs the transaction had // marked spent. -func (o pgDeleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, +func (o deleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -75,7 +75,7 @@ func (o pgDeleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, // DeleteCreatedUtxos removes any wallet-owned outputs created by the // transaction being deleted. -func (o pgDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, +func (o deleteTxOps) DeleteCreatedUtxos(ctx context.Context, walletID uint32, txID int64) error { _, err := o.qtx.DeleteUtxosByTxID( @@ -94,7 +94,7 @@ func (o pgDeleteTxOps) DeleteCreatedUtxos(ctx context.Context, // DeleteUnminedTransaction removes the target unmined row after its dependent // wallet state has been cleaned up. -func (o pgDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, +func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, walletID uint32, txHash chainhash.Hash) (int64, error) { rows, err := o.qtx.DeleteUnminedTransactionByHash( @@ -111,10 +111,10 @@ func (o pgDeleteTxOps) DeleteUnminedTransaction(ctx context.Context, return rows, nil } -// ensureDeleteLeafPg rejects DeleteTx requests for transactions that still have +// ensureDeleteLeaf rejects DeleteTx requests for transactions that still have // direct unmined child spenders, including children that spend non-credit // parent outputs. -func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, +func ensureDeleteLeaf(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash, txID int64) error { rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) @@ -148,9 +148,9 @@ func ensureDeleteLeafPg(ctx context.Context, qtx *sqlcpg.Queries, return nil } -// getDeleteTxMetaPg loads the transaction metadata DeleteTx needs and enforces +// getDeleteTxMeta loads the transaction metadata DeleteTx needs and enforces // the unmined precondition up front. -func getDeleteTxMetaPg(ctx context.Context, qtx *sqlcpg.Queries, +func getDeleteTxMeta(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, txHash chainhash.Hash) ( sqlcpg.GetTransactionMetaByHashRow, error) { diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index abbebf3f22..bb3c10dee4 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -32,15 +32,15 @@ func (s *PostgresStore) GetTx(ctx context.Context, return nil, fmt.Errorf("get tx: %w", err) } - return txInfoFromPgRow( + return txInfoFromRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, ) } -// txInfoFromPgRow converts one normalized postgres query row into the public +// txInfoFromRow converts one normalized postgres query row into the public // TxInfo shape. -func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, +func txInfoFromRow(hash []byte, rawTx []byte, received time.Time, blockHeight sql.NullInt32, blockHash []byte, blockTimestamp sql.NullInt64, status int64, label string) (*db.TxInfo, error) { @@ -52,7 +52,7 @@ func txInfoFromPgRow(hash []byte, rawTx []byte, received time.Time, // Unmined rows legitimately have no block metadata, so only build the Block // shape when the row still carries a valid height. if blockHeight.Valid { - block, err = buildPgBlock(blockHeight, blockHash, blockTimestamp) + block, err = buildBlock(blockHeight, blockHash, blockTimestamp) if err != nil { return nil, err } diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index c9ecc7487c..cc3d8140c8 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -18,22 +18,22 @@ func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { return db.InvalidateUnminedTxWithOps( - ctx, params, pgInvalidateUnminedTxOps{qtx: qtx}, + ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) }) } -// pgInvalidateUnminedTxOps adapts postgres sqlc queries to the shared +// invalidateUnminedTxOps adapts postgres sqlc queries to the shared // InvalidateUnminedTx workflow. -type pgInvalidateUnminedTxOps struct { +type invalidateUnminedTxOps struct { qtx *sqlcpg.Queries } -var _ db.InvalidateUnminedTxOps = (*pgInvalidateUnminedTxOps)(nil) +var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. -func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, +func (o invalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, walletID uint32, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( @@ -68,7 +68,7 @@ func (o pgInvalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, // ListUnminedTxRecords loads and decodes the wallet's active unmined // transaction rows. -func (o pgInvalidateUnminedTxOps) ListUnminedTxRecords( +func (o invalidateUnminedTxOps) ListUnminedTxRecords( ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) @@ -85,7 +85,7 @@ func (o pgInvalidateUnminedTxOps) ListUnminedTxRecords( // ClearSpentUtxos restores any wallet-owned parent outputs spent by the given // transaction row. -func (o pgInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, +func (o invalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -106,7 +106,7 @@ func (o pgInvalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, // MarkTxnsFailed marks the provided tx rows failed in one // batch update. -func (o pgInvalidateUnminedTxOps) MarkTxnsFailed( +func (o invalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index 574f27a485..8a37df6e42 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -19,17 +19,17 @@ func (s *PostgresStore) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { - return s.listTxnsWithoutBlockPg(ctx, query.WalletID) + return s.listTxnsWithoutBlock(ctx, query.WalletID) } - return s.listConfirmedTxnsPg(ctx, query) + return s.listConfirmedTxns(ctx, query) } -// listTxnsWithoutBlockPg loads every transaction row that currently has no +// listTxnsWithoutBlock loads every transaction row that currently has no // confirming block. This includes the active unmined set together with any // retained invalid history that rollback or invalidation flows left without a // confirming block. -func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, +func (s *PostgresStore) listTxnsWithoutBlock(ctx context.Context, walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) @@ -39,7 +39,7 @@ func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, infos := make([]db.TxInfo, len(rows)) for i, row := range rows { - info, err := txInfoFromPgRow( + info, err := txInfoFromRow( row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, ) @@ -53,9 +53,9 @@ func (s *PostgresStore) listTxnsWithoutBlockPg(ctx context.Context, return infos, nil } -// listConfirmedTxnsPg loads the confirmed height-range view used by ListTxns +// listConfirmedTxns loads the confirmed height-range view used by ListTxns // when callers query mined history. -func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, +func (s *PostgresStore) listConfirmedTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { startHeight, err := db.Uint32ToInt32(query.StartHeight) @@ -81,7 +81,7 @@ func (s *PostgresStore) listConfirmedTxnsPg(ctx context.Context, infos := make([]db.TxInfo, len(rows)) for i, row := range rows { - block, err := buildPgBlock( + block, err := buildBlock( row.BlockHeight, row.BlockHash, sql.NullInt64{Int64: row.BlockTimestamp, Valid: true}, diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index 09057696c1..1acd187053 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -18,21 +18,21 @@ func (s *PostgresStore) RollbackToBlock(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { return db.RollbackToBlockWithOps(ctx, height, - pgRollbackToBlockOps{qtx: qtx}) + rollbackToBlockOps{qtx: qtx}) }) } -// pgRollbackToBlockOps adapts postgres sqlc queries to the shared rollback +// rollbackToBlockOps adapts postgres sqlc queries to the shared rollback // sequence. -type pgRollbackToBlockOps struct { +type rollbackToBlockOps struct { qtx *sqlcpg.Queries } -var _ db.RollbackToBlockOps = (*pgRollbackToBlockOps)(nil) +var _ db.RollbackToBlockOps = (*rollbackToBlockOps)(nil) // ListRollbackRootHashes loads the coinbase roots that a rollback disconnects // and groups them by wallet. -func (o pgRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, +func (o rollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, height uint32) (map[uint32][]chainhash.Hash, error) { rollbackHeight, err := db.Uint32ToInt32(height) @@ -45,12 +45,12 @@ func (o pgRollbackToBlockOps) ListRollbackRootHashes(ctx context.Context, return nil, fmt.Errorf("query rollback coinbase roots: %w", err) } - return groupRollbackCoinbaseRootsPg(rows) + return groupRollbackCoinbaseRoots(rows) } // RewindWalletSyncStateHeights clamps wallet sync-state references below the // rollback boundary before the block rows are deleted. -func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( +func (o rollbackToBlockOps) RewindWalletSyncStateHeights( ctx context.Context, height uint32) error { // PostgreSQL stores block heights as INTEGER today, so rollback still needs @@ -89,7 +89,7 @@ func (o pgRollbackToBlockOps) RewindWalletSyncStateHeights( // DeleteBlocksAtOrAboveHeight removes the shared block rows after sync-state // references have been rewound. -func (o pgRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( +func (o rollbackToBlockOps) DeleteBlocksAtOrAboveHeight( ctx context.Context, height uint32) error { rollbackHeight, err := db.Uint32ToInt32(height) @@ -107,7 +107,7 @@ func (o pgRollbackToBlockOps) DeleteBlocksAtOrAboveHeight( // MarkTxRootsOrphaned rewrites each disconnected coinbase root to the // orphaned state once its confirming block has been deleted. -func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, +func (o rollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, walletID uint32, rootHashes []chainhash.Hash) error { for _, txHash := range rootHashes { @@ -137,7 +137,7 @@ func (o pgRollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, // ListUnminedTxRecords loads and decodes every unmined transaction row for the // wallet so the shared helper can inspect raw inputs for descendant edges. -func (o pgRollbackToBlockOps) ListUnminedTxRecords( +func (o rollbackToBlockOps) ListUnminedTxRecords( ctx context.Context, walletID int64) ([]db.UnminedTxRecord, error) { rows, err := o.qtx.ListUnminedTransactions(ctx, walletID) @@ -154,7 +154,7 @@ func (o pgRollbackToBlockOps) ListUnminedTxRecords( // ClearDescendantSpends removes any wallet-owned spend edges claimed by one // invalid descendant transaction before its status is rewritten. -func (o pgRollbackToBlockOps) ClearDescendantSpends( +func (o rollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( @@ -175,7 +175,7 @@ func (o pgRollbackToBlockOps) ClearDescendantSpends( // MarkDescendantsFailed batch-marks the collected rollback descendants as // failed once every dependent spend edge has been cleared. -func (o pgRollbackToBlockOps) MarkDescendantsFailed( +func (o rollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( @@ -192,9 +192,9 @@ func (o pgRollbackToBlockOps) MarkDescendantsFailed( return nil } -// groupRollbackCoinbaseRootsPg groups rollback-affected coinbase hashes by +// groupRollbackCoinbaseRoots groups rollback-affected coinbase hashes by // wallet while preserving the query order inside each wallet bucket. -func groupRollbackCoinbaseRootsPg(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( +func groupRollbackCoinbaseRoots(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index 01881da80e..139679323f 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -21,12 +21,12 @@ func (s *PostgresStore) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - return db.UpdateTxWithOps(ctx, params, &pgUpdateTxOps{qtx: qtx}) + return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } -// pgUpdateTxOps adapts postgres sqlc queries to the shared UpdateTx flow. -type pgUpdateTxOps struct { +// updateTxOps adapts postgres sqlc queries to the shared UpdateTx flow. +type updateTxOps struct { // qtx is the transaction-scoped postgres query set used by UpdateTx. qtx *sqlcpg.Queries @@ -39,11 +39,11 @@ type pgUpdateTxOps struct { status int16 } -var _ db.UpdateTxOps = (*pgUpdateTxOps)(nil) +var _ db.UpdateTxOps = (*updateTxOps)(nil) // LoadIsCoinbase loads the existing row metadata UpdateTx needs before it can // validate one patch. -func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, +func (o *updateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, txHash chainhash.Hash) (bool, error) { meta, err := o.qtx.GetTransactionMetaByHash( @@ -66,13 +66,13 @@ func (o *pgUpdateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, // PrepareState validates any referenced confirming block and captures the // postgres-specific state params for the later row update. -func (o *pgUpdateTxOps) PrepareState(ctx context.Context, +func (o *updateTxOps) PrepareState(ctx context.Context, state db.UpdateTxState) error { blockHeight := sql.NullInt32{} if state.Block != nil { - height, err := requireBlockMatchesPg(ctx, o.qtx, state.Block) + height, err := requireBlockMatches(ctx, o.qtx, state.Block) if err != nil { return fmt.Errorf("require confirming block: %w", err) } @@ -88,7 +88,7 @@ func (o *pgUpdateTxOps) PrepareState(ctx context.Context, // UpdateState writes one block/status patch after PrepareState has validated // any referenced block metadata. -func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, +func (o *updateTxOps) UpdateState(ctx context.Context, walletID uint32, txHash chainhash.Hash, _ db.UpdateTxState) error { rows, err := o.qtx.UpdateTransactionStateByHash( @@ -112,7 +112,7 @@ func (o *pgUpdateTxOps) UpdateState(ctx context.Context, walletID uint32, } // UpdateLabel writes one user-visible label change. -func (o *pgUpdateTxOps) UpdateLabel(ctx context.Context, walletID uint32, +func (o *updateTxOps) UpdateLabel(ctx context.Context, walletID uint32, txHash chainhash.Hash, label string) error { rows, err := o.qtx.UpdateTransactionLabelByHash( diff --git a/wallet/internal/db/pg/utxo_extra_test.go b/wallet/internal/db/pg/utxo_extra_test.go index 8de30d009d..601e2eee7c 100644 --- a/wallet/internal/db/pg/utxo_extra_test.go +++ b/wallet/internal/db/pg/utxo_extra_test.go @@ -14,7 +14,7 @@ func TestUtxoInfoFromPgRowInvalidOutputIndex(t *testing.T) { t.Parallel() hash := chainhash.Hash{15} - _, err := utxoInfoFromPgRow( + _, err := utxoInfoFromRow( hash[:], -1, 1000, []byte{0x59}, time.Unix(1000, 0), false, sql.NullInt32{}, ) @@ -26,7 +26,7 @@ func TestUtxoInfoFromPgRowInvalidBlockHeight(t *testing.T) { t.Parallel() hash := chainhash.Hash{16} - _, err := utxoInfoFromPgRow( + _, err := utxoInfoFromRow( hash[:], 0, 1000, []byte{0x5a}, time.Unix(1001, 0), false, sql.NullInt32{Int32: -1, Valid: true}, ) diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index 3f07f0bea3..c12bad5a0c 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -39,15 +39,15 @@ func (s *PostgresStore) GetUtxo(ctx context.Context, return nil, fmt.Errorf("get utxo: %w", err) } - return utxoInfoFromPgRow( + return utxoInfoFromRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, row.ReceivedTime, row.IsCoinbase, row.BlockHeight, ) } -// utxoInfoFromPgRow converts one normalized postgres query row into the public +// utxoInfoFromRow converts one normalized postgres query row into the public // UtxoInfo shape. -func utxoInfoFromPgRow(hash []byte, outputIndex int32, amount int64, +func utxoInfoFromRow(hash []byte, outputIndex int32, amount int64, pkScript []byte, received time.Time, isCoinbase bool, blockHeight sql.NullInt32) (*db.UtxoInfo, error) { diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index 82c57727d4..d40a7cfdc7 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -23,7 +23,7 @@ func (s *PostgresStore) LeaseOutput(ctx context.Context, err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( - ctx, params, &pgLeaseOutputOps{qtx: qtx}, + ctx, params, &leaseOutputOps{qtx: qtx}, ) if err != nil { return err @@ -40,17 +40,17 @@ func (s *PostgresStore) LeaseOutput(ctx context.Context, return lease, nil } -// pgLeaseOutputOps adapts postgres sqlc queries to the shared LeaseOutput +// leaseOutputOps adapts postgres sqlc queries to the shared LeaseOutput // workflow. -type pgLeaseOutputOps struct { +type leaseOutputOps struct { qtx *sqlcpg.Queries } -var _ db.LeaseOutputOps = (*pgLeaseOutputOps)(nil) +var _ db.LeaseOutputOps = (*leaseOutputOps)(nil) // Acquire attempts to write or renew one postgres lease row for the requested // outpoint. -func (o *pgLeaseOutputOps) Acquire(ctx context.Context, +func (o *leaseOutputOps) Acquire(ctx context.Context, params db.LeaseOutputParams, nowUTC time.Time, expiresAt time.Time) (time.Time, error) { @@ -82,7 +82,7 @@ func (o *pgLeaseOutputOps) Acquire(ctx context.Context, // HasUtxo reports whether the requested outpoint still exists as a current // wallet-owned UTXO. -func (o *pgLeaseOutputOps) HasUtxo(ctx context.Context, +func (o *leaseOutputOps) HasUtxo(ctx context.Context, params db.LeaseOutputParams) (bool, error) { outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index 888a9186c9..2f1f65e828 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -15,14 +15,14 @@ import ( func (s *PostgresStore) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { - rows, err := s.queries.ListUtxos(ctx, buildListUtxosParamsPg(query)) + rows, err := s.queries.ListUtxos(ctx, buildListUtxosParams(query)) if err != nil { return nil, fmt.Errorf("list utxos: %w", err) } utxos := make([]db.UtxoInfo, len(rows)) for i, row := range rows { - utxo, err := utxoInfoFromPgRow( + utxo, err := utxoInfoFromRow( row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, row.ReceivedTime, row.IsCoinbase, row.BlockHeight, ) @@ -36,9 +36,9 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, return utxos, nil } -// buildListUtxosParamsPg prepares the typed nullable filters required by the +// buildListUtxosParams prepares the typed nullable filters required by the // postgres ListUtxos query. -func buildListUtxosParamsPg(query db.ListUtxosQuery) sqlcpg.ListUtxosParams { +func buildListUtxosParams(query db.ListUtxosQuery) sqlcpg.ListUtxosParams { return sqlcpg.ListUtxosParams{ WalletID: int64(query.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(query.Account), diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index 694fd4668c..ed87995902 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -21,22 +21,22 @@ func (s *PostgresStore) ReleaseOutput(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { return db.ReleaseOutputWithOps( - ctx, params, &pgReleaseOutputOps{qtx: qtx}, + ctx, params, &releaseOutputOps{qtx: qtx}, ) }) } -// pgReleaseOutputOps adapts postgres sqlc queries to the shared ReleaseOutput +// releaseOutputOps adapts postgres sqlc queries to the shared ReleaseOutput // workflow. -type pgReleaseOutputOps struct { +type releaseOutputOps struct { qtx *sqlcpg.Queries } -var _ db.ReleaseOutputOps = (*pgReleaseOutputOps)(nil) +var _ db.ReleaseOutputOps = (*releaseOutputOps)(nil) // LookupUtxoID resolves the wallet-owned outpoint to its stable postgres UTXO // row ID. -func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, +func (o *releaseOutputOps) LookupUtxoID(ctx context.Context, params db.ReleaseOutputParams) (int64, error) { outputIndex, err := db.Uint32ToInt32(params.OutPoint.Index) @@ -64,7 +64,7 @@ func (o *pgReleaseOutputOps) LookupUtxoID(ctx context.Context, // Release attempts to delete the postgres lease row for the provided UTXO ID // and lock ID. -func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, +func (o *releaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( @@ -83,7 +83,7 @@ func (o *pgReleaseOutputOps) Release(ctx context.Context, walletID uint32, // ActiveLockID returns the currently active postgres lease lock ID for the // provided UTXO ID. -func (o *pgReleaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, +func (o *releaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index a83070270d..da6c148ad1 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -84,7 +84,7 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, ) } - info, err = buildPgWalletInfo(pgWalletRowParams{ + info, err = buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -127,7 +127,7 @@ func (s *PostgresStore) GetWallet(ctx context.Context, return nil, fmt.Errorf("get wallet: %w", err) } - return buildPgWalletInfo(pgWalletRowParams{ + return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -147,7 +147,7 @@ func (s *PostgresStore) GetWallet(ctx context.Context, func (s *PostgresStore) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { - rows, err := s.queries.ListWallets(ctx, pgListWalletsParams(query.Page)) + rows, err := s.queries.ListWallets(ctx, listWalletsParams(query.Page)) if err != nil { return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: %w", err) @@ -155,7 +155,7 @@ func (s *PostgresStore) ListWallets(ctx context.Context, items := make([]db.WalletInfo, len(rows)) for i, row := range rows { - item, errMap := pgListWalletRowToInfo(row) + item, errMap := listWalletRowToInfo(row) if errMap != nil { return page.Result[db.WalletInfo, uint32]{}, fmt.Errorf("list wallets page: map row: %w", errMap) @@ -193,7 +193,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { - err := ensureBlockExistsPg(ctx, qtx, params.SyncedTo) + err := ensureBlockExists(ctx, qtx, params.SyncedTo) if err != nil { return fmt.Errorf("ensure synced block: %w", err) @@ -201,7 +201,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, } if params.BirthdayBlock != nil { - err := ensureBlockExistsPg( + err := ensureBlockExists( ctx, qtx, params.BirthdayBlock, ) if err != nil { @@ -210,7 +210,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, } } - syncParams, err := buildUpdateSyncParamsPg(params) + syncParams, err := buildUpdateSyncParams(params) if err != nil { return err } @@ -280,9 +280,9 @@ func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, return nil } -// pgWalletRowParams holds the parameters needed to build a WalletInfo +// walletRowParams holds the parameters needed to build a WalletInfo // from a wallet row. -type pgWalletRowParams struct { +type walletRowParams struct { id int64 name string isImported bool @@ -297,10 +297,10 @@ type pgWalletRowParams struct { birthdayBlockTimestamp sql.NullInt64 } -// pgListWalletRowToInfo converts a ListWallets result row to a WalletInfo +// listWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. -func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { - return buildPgWalletInfo(pgWalletRowParams{ +func listWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { + return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, isImported: row.IsImported, @@ -316,9 +316,9 @@ func pgListWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { }) } -// pgListWalletsParams translates a page request to ListWallets query +// listWalletsParams translates a page request to ListWallets query // parameters, handling optional cursor setup for pagination. -func pgListWalletsParams( +func listWalletsParams( req page.Request[uint32]) sqlcpg.ListWalletsParams { params := sqlcpg.ListWalletsParams{ @@ -335,9 +335,9 @@ func pgListWalletsParams( return params } -// buildPgWalletInfo constructs a WalletInfo from the given wallet row +// buildWalletInfo constructs a WalletInfo from the given wallet row // parameters. -func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { +func buildWalletInfo(row walletRowParams) (*db.WalletInfo, error) { walletID, err := db.Int64ToUint32(row.id) if err != nil { return nil, err @@ -356,7 +356,7 @@ func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { } if row.syncedHeight.Valid { - block, err := buildPgBlock( + block, err := buildBlock( row.syncedHeight, row.syncedBlockHash, row.syncedBlockTimestamp, @@ -369,7 +369,7 @@ func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { } if row.birthdayHeight.Valid { - block, err := buildPgBlock( + block, err := buildBlock( row.birthdayHeight, row.birthdayBlockHash, row.birthdayBlockTimestamp, @@ -384,9 +384,9 @@ func buildPgWalletInfo(row pgWalletRowParams) (*db.WalletInfo, error) { return info, nil } -// buildUpdateSyncParamsPg constructs the UpdateWalletSyncStateParams from +// buildUpdateSyncParams constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. -func buildUpdateSyncParamsPg(params db.UpdateWalletParams) ( +func buildUpdateSyncParams(params db.UpdateWalletParams) ( sqlcpg.UpdateWalletSyncStateParams, error) { syncParams := sqlcpg.UpdateWalletSyncStateParams{ From b82a653f321b650e10dd531f8c45bff44ea960b7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:01:03 +0800 Subject: [PATCH 537/691] db/sqlite: rename exported store API Rename the sqlite backend's exported store API to the package-local Store, Config, and NewStore names now that the backend lives in its own package. The sqlite config type and tests also move into the sqlite package so callers import the backend package for its concrete constructor surface. This removes package stutter at sqlite call sites while keeping the shared db package focused on backend-neutral helpers and errors. --- wallet/internal/db/config.go | 23 ----- wallet/internal/db/config_test.go | 71 -------------- wallet/internal/db/db_connectors_test.go | 16 +-- .../internal/db/itest/fixtures_sqlite_test.go | 34 +++---- wallet/internal/db/itest/sqlite_test.go | 30 +++--- .../db/itest/tx_corruption_sqlite_test.go | 11 +-- wallet/internal/db/sqlite/accounts.go | 84 ++++++++-------- wallet/internal/db/sqlite/address_types.go | 10 +- wallet/internal/db/sqlite/addresses.go | 98 +++++++++---------- .../internal/db/sqlite/backend_error_test.go | 6 +- .../internal/db/sqlite/backend_rows_test.go | 18 ++-- wallet/internal/db/sqlite/block.go | 10 +- wallet/internal/db/sqlite/config.go | 26 +++++ wallet/internal/db/sqlite/config_test.go | 79 +++++++++++++++ wallet/internal/db/sqlite/doc.go | 2 + wallet/internal/db/sqlite/itest.go | 6 +- wallet/internal/db/sqlite/store.go | 23 +++-- wallet/internal/db/sqlite/txstore_createtx.go | 46 ++++----- wallet/internal/db/sqlite/txstore_deletetx.go | 34 +++---- wallet/internal/db/sqlite/txstore_gettx.go | 8 +- .../db/sqlite/txstore_invalidateunmined.go | 18 ++-- wallet/internal/db/sqlite/txstore_listtxns.go | 12 +-- wallet/internal/db/sqlite/txstore_rollback.go | 22 ++--- wallet/internal/db/sqlite/txstore_updatetx.go | 16 +-- .../internal/db/sqlite/utxostore_balance.go | 8 +- .../internal/db/sqlite/utxostore_getutxo.go | 8 +- .../db/sqlite/utxostore_leaseoutput.go | 14 +-- .../db/sqlite/utxostore_listleasedoutputs.go | 8 +- .../internal/db/sqlite/utxostore_listutxos.go | 8 +- .../db/sqlite/utxostore_releaseoutput.go | 16 +-- wallet/internal/db/sqlite/wallet.go | 42 ++++---- wallet/internal/db/tx.go | 2 +- 32 files changed, 410 insertions(+), 399 deletions(-) create mode 100644 wallet/internal/db/sqlite/config.go create mode 100644 wallet/internal/db/sqlite/config_test.go create mode 100644 wallet/internal/db/sqlite/doc.go diff --git a/wallet/internal/db/config.go b/wallet/internal/db/config.go index 0df15b22b9..43363a14ab 100644 --- a/wallet/internal/db/config.go +++ b/wallet/internal/db/config.go @@ -36,29 +36,6 @@ var ( ErrEmptyDSN = errors.New("DSN is required") ) -// SqliteConfig holds the configuration for the SQLite database. -type SqliteConfig struct { - // DBPath is the filesystem path to the SQLite database file. - DBPath string - - // MaxConnections is the maximum number of open connections to the - // database. Set to zero to use DefaultMaxConnections. - MaxConnections int -} - -// Validate checks that the SqliteConfig values are valid. -func (c *SqliteConfig) Validate() error { - if c.DBPath == "" { - return ErrEmptyDBPath - } - - if c.MaxConnections < 0 { - return ErrNegativeMaxConns - } - - return nil -} - // PostgresConfig holds the configuration for the PostgreSQL database. type PostgresConfig struct { // Dsn is the database connection string. diff --git a/wallet/internal/db/config_test.go b/wallet/internal/db/config_test.go index 142f8925a8..5981fba9a1 100644 --- a/wallet/internal/db/config_test.go +++ b/wallet/internal/db/config_test.go @@ -6,77 +6,6 @@ import ( "github.com/stretchr/testify/require" ) -// TestSqliteConfigValidateSuccess tests valid SqliteConfig scenarios. -func TestSqliteConfigValidateSuccess(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - config SqliteConfig - }{ - { - name: "valid config with zero max connections", - config: SqliteConfig{ - DBPath: "/tmp/test.db", - MaxConnections: 0, - }, - }, - { - name: "valid config with positive max connections", - config: SqliteConfig{ - DBPath: "/tmp/test.db", - MaxConnections: 10, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - err := tc.config.Validate() - require.NoError(t, err) - }) - } -} - -// TestSqliteConfigValidateErrors tests SqliteConfig validation errors. -func TestSqliteConfigValidateErrors(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - config SqliteConfig - expectedErr error - }{ - { - name: "empty DB path", - config: SqliteConfig{ - DBPath: "", - MaxConnections: 0, - }, - expectedErr: ErrEmptyDBPath, - }, - { - name: "negative max connections", - config: SqliteConfig{ - DBPath: "/tmp/test.db", - MaxConnections: -1, - }, - expectedErr: ErrNegativeMaxConns, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - err := tc.config.Validate() - require.ErrorIs(t, err, tc.expectedErr) - }) - } -} - // TestPostgresConfigValidateSuccess tests valid PostgresConfig scenarios. func TestPostgresConfigValidateSuccess(t *testing.T) { t.Parallel() diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index f0c24649d2..49f1dcdf1a 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -65,24 +65,24 @@ func TestNewPostgresStoreConnectionFailure(t *testing.T) { require.Nil(t, store) } -func TestNewSqliteStoreValidateConfig(t *testing.T) { +func TestSQLiteNewStoreValidateConfig(t *testing.T) { t.Parallel() tests := []struct { name string - cfg db.SqliteConfig + cfg dbsqlite.Config wantErr error }{ { name: "empty DB path", - cfg: db.SqliteConfig{ + cfg: dbsqlite.Config{ DBPath: "", }, wantErr: db.ErrEmptyDBPath, }, { name: "negative max connections", - cfg: db.SqliteConfig{ + cfg: dbsqlite.Config{ DBPath: "/tmp/test.db", MaxConnections: -1, }, @@ -94,21 +94,21 @@ func TestNewSqliteStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := dbsqlite.NewSqliteStore(t.Context(), tc.cfg) + store, err := dbsqlite.NewStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) } } -func TestNewSqliteStoreSuccess(t *testing.T) { +func TestSQLiteNewStoreSuccess(t *testing.T) { t.Parallel() - cfg := db.SqliteConfig{ + cfg := dbsqlite.Config{ DBPath: filepath.Join(t.TempDir(), "wallet.db"), } - store, err := dbsqlite.NewSqliteStore(t.Context(), cfg) + store, err := dbsqlite.NewStore(t.Context(), cfg) require.NoError(t, err) require.NotNil(t, store) diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index cb0a8d5d6c..634dcb0a4b 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -10,19 +10,19 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) // CreateBlockFixture inserts a test block into the database and returns it. -func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, +func CreateBlockFixture(t *testing.T, queries *sqlc.Queries, height uint32) db.Block { t.Helper() block := NewBlockFixture(height) err := queries.InsertBlock( - t.Context(), sqlcsqlite.InsertBlockParams{ + t.Context(), sqlc.InsertBlockParams{ BlockHeight: int64(block.Height), HeaderHash: block.Hash[:], BlockTimestamp: block.Timestamp.Unix(), @@ -35,12 +35,12 @@ func CreateBlockFixture(t *testing.T, queries *sqlcsqlite.Queries, // CreateAccountWithNumber creates an account with a specific account number. // Used to test account number overflow without creating billions of accounts. -func CreateAccountWithNumber(t *testing.T, queries *sqlcsqlite.Queries, +func CreateAccountWithNumber(t *testing.T, queries *sqlc.Queries, scopeID int64, accountNumber uint32, name string) { t.Helper() _, err := queries.CreateDerivedAccountWithNumber( - t.Context(), sqlcsqlite.CreateDerivedAccountWithNumberParams{ + t.Context(), sqlc.CreateDerivedAccountWithNumberParams{ ScopeID: scopeID, AccountNumber: sql.NullInt64{Int64: int64(accountNumber), Valid: true}, AccountName: name, @@ -54,12 +54,12 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcsqlite.Queries, // CreateAddressWithIndex creates a derived address with a specific address // index. Used to test address index overflow without creating billions of // addresses. -func CreateAddressWithIndex(t *testing.T, queries *sqlcsqlite.Queries, +func CreateAddressWithIndex(t *testing.T, queries *sqlc.Queries, accountID int64, branch uint32, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( - t.Context(), sqlcsqlite.CreateDerivedAddressParams{ + t.Context(), sqlc.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int64(db.WitnessPubKey), @@ -99,12 +99,12 @@ func UpdateAccountNextInternalIndex(t *testing.T, dbConn *sql.DB, } // GetKeyScopeID retrieves the scope ID for a given wallet and key scope. -func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, +func GetKeyScopeID(t *testing.T, queries *sqlc.Queries, walletID uint32, scope db.KeyScope) int64 { t.Helper() row, err := queries.GetKeyScopeByWalletAndScope( - t.Context(), sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ + t.Context(), sqlc.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), @@ -116,13 +116,13 @@ func GetKeyScopeID(t *testing.T, queries *sqlcsqlite.Queries, } // GetAccountID retrieves the account ID for a given scope and account name. -func GetAccountID(t *testing.T, queries *sqlcsqlite.Queries, +func GetAccountID(t *testing.T, queries *sqlc.Queries, scopeID int64, accountName string) int64 { t.Helper() row, err := queries.GetAccountByScopeAndName( t.Context(), - sqlcsqlite.GetAccountByScopeAndNameParams{ + sqlc.GetAccountByScopeAndNameParams{ ScopeID: scopeID, AccountName: accountName, }, @@ -132,12 +132,12 @@ func GetAccountID(t *testing.T, queries *sqlcsqlite.Queries, return row.ID } -func getAddressID(t *testing.T, queries *sqlcsqlite.Queries, +func getAddressID(t *testing.T, queries *sqlc.Queries, scriptPubKey []byte, walletID uint32) int64 { t.Helper() addr, err := queries.GetAddressByScriptPubKey( - t.Context(), sqlcsqlite.GetAddressByScriptPubKeyParams{ + t.Context(), sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: scriptPubKey, WalletID: int64(walletID), }, @@ -147,8 +147,8 @@ func getAddressID(t *testing.T, queries *sqlcsqlite.Queries, return addr.ID } -func GetAddressSecret(t *testing.T, queries *sqlcsqlite.Queries, - addressID int64) (sqlcsqlite.GetAddressSecretRow, error) { +func GetAddressSecret(t *testing.T, queries *sqlc.Queries, + addressID int64) (sqlc.GetAddressSecretRow, error) { t.Helper() return queries.GetAddressSecret(t.Context(), addressID) @@ -190,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &dbsqlite.SqliteStore{}, store) + require.IsType(t, &dbsqlite.Store{}, store) - sqliteStore := store.(*dbsqlite.SqliteStore) + sqliteStore := store.(*dbsqlite.Store) queries := sqliteStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index cd066cfc7d..a2c93da5b2 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -11,25 +11,25 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) // NewTestStore creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewTestStore(t *testing.T) *dbsqlite.SqliteStore { +func NewTestStore(t *testing.T) *dbsqlite.Store { t.Helper() tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") - cfg := db.SqliteConfig{ + cfg := dbsqlite.Config{ DBPath: dbPath, MaxConnections: 0, } - store, err := dbsqlite.NewSqliteStore(t.Context(), cfg) + store, err := dbsqlite.NewStore(t.Context(), cfg) require.NoError(t, err, "failed to create sqlite store") t.Cleanup(func() { @@ -41,14 +41,14 @@ func NewTestStore(t *testing.T) *dbsqlite.SqliteStore { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *dbsqlite.SqliteStore, +func childSpendingTxIDs(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash) []int64 { t.Helper() meta, err := store.Queries().GetTransactionMetaByHash( - t.Context(), sqlcsqlite.GetTransactionMetaByHashParams{ + t.Context(), sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -56,7 +56,7 @@ func childSpendingTxIDs(t *testing.T, store *dbsqlite.SqliteStore, require.NoError(t, err) childIDs, err := store.Queries().ListSpendingTxIDsByParentTxID( - t.Context(), sqlcsqlite.ListSpendingTxIDsByParentTxIDParams{ + t.Context(), sqlc.ListSpendingTxIDsByParentTxIDParams{ WalletID: int64(walletID), TxID: meta.ID, }, @@ -74,13 +74,13 @@ func childSpendingTxIDs(t *testing.T, store *dbsqlite.SqliteStore, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, +func txIDByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() meta, err := store.Queries().GetTransactionMetaByHash( - t.Context(), sqlcsqlite.GetTransactionMetaByHashParams{ + t.Context(), sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -98,13 +98,13 @@ func txIDByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, +func rawTxByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() row, err := store.Queries().GetTransactionByHash( - t.Context(), sqlcsqlite.GetTransactionByHashParams{ + t.Context(), sqlc.GetTransactionByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -116,7 +116,7 @@ func rawTxByHash(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, +func setTxStatus(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -125,7 +125,7 @@ func setTxStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, require.True(t, ok) rows, err := store.Queries().UpdateTransactionStatusByIDs( - t.Context(), sqlcsqlite.UpdateTransactionStatusByIDsParams{ + t.Context(), sqlc.UpdateTransactionStatusByIDsParams{ WalletID: int64(walletID), Status: int64(status), TxIds: []int64{txID}, @@ -137,14 +137,14 @@ func setTxStatus(t *testing.T, store *dbsqlite.SqliteStore, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *dbsqlite.SqliteStore, +func walletUtxoExists(t *testing.T, store *dbsqlite.Store, walletID uint32, outPoint wire.OutPoint) bool { t.Helper() _, err := store.Queries().GetUtxoIDByOutpoint( - t.Context(), sqlcsqlite.GetUtxoIDByOutpointParams{ + t.Context(), sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(walletID), TxHash: outPoint.Hash[:], OutputIndex: int64(outPoint.Index), diff --git a/wallet/internal/db/itest/tx_corruption_sqlite_test.go b/wallet/internal/db/itest/tx_corruption_sqlite_test.go index 2d6e147067..2827a8f713 100644 --- a/wallet/internal/db/itest/tx_corruption_sqlite_test.go +++ b/wallet/internal/db/itest/tx_corruption_sqlite_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcwallet/wallet/internal/db" dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -20,7 +19,7 @@ import ( // corruptTransactionStatus writes an invalid tx status into one stored row while // sqlite check constraints are disabled inside the surrounding transaction. The // corruption itests use this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *dbsqlite.SqliteStore, +func corruptTransactionStatus(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -55,7 +54,7 @@ func corruptTransactionStatus(t *testing.T, store *dbsqlite.SqliteStore, // corruptTransactionHash writes malformed tx-hash bytes into one stored row // while sqlite check constraints are disabled. The corruption itests then // verify that hash decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *dbsqlite.SqliteStore, +func corruptTransactionHash(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -90,7 +89,7 @@ func corruptTransactionHash(t *testing.T, store *dbsqlite.SqliteStore, // corruptTransactionBlockHeight writes an invalid block height after first // creating a matching block row in sqlite. The corruption itests use this to // verify that reads reject impossible confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.SqliteStore, +func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -135,7 +134,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.SqliteStore, // corruptUtxoOutputIndex writes an invalid output index into one stored UTXO // while sqlite check constraints are disabled. The corruption itests then // verify that UTXO decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.SqliteStore, +func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -171,7 +170,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.SqliteStore, // corruptActiveLeaseLockID writes an invalid lease lock ID into one active // lease row while sqlite check constraints are disabled. The corruption itests // use this to verify that lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *dbsqlite.SqliteStore, +func corruptActiveLeaseLockID(t *testing.T, store *dbsqlite.Store, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index fb9b9ae041..e6315a4f21 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -4,17 +4,17 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -// Ensure SqliteStore satisfies the AccountStore interface. -var _ db.AccountStore = (*SqliteStore)(nil) +// Ensure Store satisfies the AccountStore interface. +var _ db.AccountStore = (*Store)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. -func (s *SqliteStore) GetAccount(ctx context.Context, +func (s *Store) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { getQueries := accountGetQueries{q: s.queries} @@ -26,7 +26,7 @@ func (s *SqliteStore) GetAccount(ctx context.Context, // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. -func (s *SqliteStore) ListAccounts(ctx context.Context, +func (s *Store) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { listQueries := accountListQueries{q: s.queries} @@ -38,7 +38,7 @@ func (s *SqliteStore) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. -func (s *SqliteStore) RenameAccount(ctx context.Context, +func (s *Store) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { renameQueries := accountRenameQueries{q: s.queries} @@ -51,7 +51,7 @@ func (s *SqliteStore) RenameAccount(ctx context.Context, // CreateDerivedAccount creates a new derived account with the given name and // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. -func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, +func (s *Store) CreateDerivedAccount(ctx context.Context, params db.CreateDerivedAccountParams) (*db.AccountInfo, error) { paramsErr := params.Validate() @@ -61,7 +61,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo - err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -70,7 +70,7 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, } row, err := qtx.CreateDerivedAccount( - ctx, sqlcsqlite.CreateDerivedAccountParams{ + ctx, sqlc.CreateDerivedAccountParams{ ScopeID: scopeID, AccountName: params.Name, OriginID: int64(db.DerivedAccount), @@ -108,18 +108,18 @@ func (s *SqliteStore) CreateDerivedAccount(ctx context.Context, // ensureKeyScope retrieves an existing key scope or creates it if missing // for SQLite. It returns the scope ID once available. -func ensureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureKeyScope(ctx context.Context, qtx *sqlc.Queries, walletID uint32, scope db.KeyScope) (int64, error) { return db.EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, - sqlcsqlite.GetKeyScopeByWalletAndScopeParams{ + sqlc.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), }, qtx.CreateKeyScope, - func(addrSchema db.ScopeAddrSchema) sqlcsqlite.CreateKeyScopeParams { - return sqlcsqlite.CreateKeyScopeParams{ + func(addrSchema db.ScopeAddrSchema) sqlc.CreateKeyScopeParams { + return sqlc.CreateKeyScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), @@ -132,7 +132,7 @@ func ensureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, ), } }, - func(row sqlcsqlite.KeyScope) int64 { return row.ID }, scope, + func(row sqlc.KeyScope) int64 { return row.ID }, scope, ) } @@ -140,12 +140,12 @@ func ensureKeyScope(ctx context.Context, qtx *sqlcsqlite.Queries, // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. -func (s *SqliteStore) CreateImportedAccount(ctx context.Context, +func (s *Store) CreateImportedAccount(ctx context.Context, params db.CreateImportedAccountParams) (*db.AccountProperties, error) { var props *db.AccountProperties - err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { var err error props, err = db.CreateImportedAccount( @@ -157,7 +157,7 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, }, qtx.CreateImportedAccount, buildCreateImportedAccountArgs(params), - func(row sqlcsqlite.CreateImportedAccountRow) int64 { + func(row sqlc.CreateImportedAccountRow) int64 { return row.ID }, qtx.CreateAccountSecret, buildCreateAccountSecretArgs(params), @@ -179,12 +179,12 @@ func (s *SqliteStore) CreateImportedAccount(ctx context.Context, // CreateImportedAccountParams for SQLite. func buildCreateImportedAccountArgs( params db.CreateImportedAccountParams, -) func(int64, bool) sqlcsqlite.CreateImportedAccountParams { +) func(int64, bool) sqlc.CreateImportedAccountParams { return func(scopeID int64, - isWatchOnly bool) sqlcsqlite.CreateImportedAccountParams { + isWatchOnly bool) sqlc.CreateImportedAccountParams { - return sqlcsqlite.CreateImportedAccountParams{ + return sqlc.CreateImportedAccountParams{ ScopeID: scopeID, AccountName: params.Name, OriginID: int64(db.ImportedAccount), @@ -202,10 +202,10 @@ func buildCreateImportedAccountArgs( // CreateAccountSecretParams for SQLite. func buildCreateAccountSecretArgs( params db.CreateImportedAccountParams, -) func(int64) sqlcsqlite.CreateAccountSecretParams { +) func(int64) sqlc.CreateAccountSecretParams { - return func(accountID int64) sqlcsqlite.CreateAccountSecretParams { - return sqlcsqlite.CreateAccountSecretParams{ + return func(accountID int64) sqlc.CreateAccountSecretParams { + return sqlc.CreateAccountSecretParams{ AccountID: accountID, EncryptedPrivateKey: params.EncryptedPrivateKey, } @@ -214,7 +214,7 @@ func buildCreateAccountSecretArgs( // getAccountProps fetches full account properties from the database and // converts the row to AccountProperties. -func getAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, +func getAccountProps(ctx context.Context, qtx *sqlc.Queries, accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) @@ -246,13 +246,13 @@ func getAccountProps(ctx context.Context, qtx *sqlcsqlite.Queries, // that share the same field structure. This enables a single generic conversion // function to handle all account query result types. type accountInfoRow interface { - sqlcsqlite.GetAccountByScopeAndNameRow | - sqlcsqlite.GetAccountByScopeAndNumberRow | - sqlcsqlite.GetAccountByWalletScopeAndNameRow | - sqlcsqlite.GetAccountByWalletScopeAndNumberRow | - sqlcsqlite.ListAccountsByWalletRow | - sqlcsqlite.ListAccountsByWalletScopeRow | - sqlcsqlite.ListAccountsByWalletAndNameRow + sqlc.GetAccountByScopeAndNameRow | + sqlc.GetAccountByScopeAndNumberRow | + sqlc.GetAccountByWalletScopeAndNameRow | + sqlc.GetAccountByWalletScopeAndNumberRow | + sqlc.ListAccountsByWalletRow | + sqlc.ListAccountsByWalletScopeRow | + sqlc.ListAccountsByWalletAndNameRow } // accountRowToInfo converts a SQLite account row to an AccountInfo @@ -263,7 +263,7 @@ func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. - base := sqlcsqlite.GetAccountByScopeAndNameRow(row) + base := sqlc.GetAccountByScopeAndNameRow(row) return db.AccountRowToInfo(db.AccountInfoRow[int64]{ AccountNumber: base.AccountNumber, @@ -282,7 +282,7 @@ func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, // accountListQueries groups SQLite account listing query methods. type accountListQueries struct { - q *sqlcsqlite.Queries + q *sqlc.Queries } // byScope lists accounts filtered by wallet ID and key scope. @@ -291,7 +291,7 @@ func (s accountListQueries) byScope(ctx context.Context, return db.ListAccounts( ctx, s.q.ListAccountsByWalletScope, - sqlcsqlite.ListAccountsByWalletScopeParams{ + sqlc.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -305,7 +305,7 @@ func (s accountListQueries) byName(ctx context.Context, return db.ListAccounts( ctx, s.q.ListAccountsByWalletAndName, - sqlcsqlite.ListAccountsByWalletAndNameParams{ + sqlc.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), AccountName: *query.Name, }, accountRowToInfo, @@ -324,7 +324,7 @@ func (s accountListQueries) all(ctx context.Context, // accountGetQueries groups SQLite account retrieval query methods. type accountGetQueries struct { - q *sqlcsqlite.Queries + q *sqlc.Queries } // byNumber retrieves an account by wallet ID, scope, and account number. @@ -333,7 +333,7 @@ func (s accountGetQueries) byNumber(ctx context.Context, return db.GetAccount( ctx, s.q.GetAccountByWalletScopeAndNumber, - sqlcsqlite.GetAccountByWalletScopeAndNumberParams{ + sqlc.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -347,7 +347,7 @@ func (s accountGetQueries) byName(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { return db.GetAccount(ctx, s.q.GetAccountByWalletScopeAndName, - sqlcsqlite.GetAccountByWalletScopeAndNameParams{ + sqlc.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -358,7 +358,7 @@ func (s accountGetQueries) byName(ctx context.Context, // accountRenameQueries groups SQLite account rename query methods. type accountRenameQueries struct { - q *sqlcsqlite.Queries + q *sqlc.Queries } // byNumber renames an account identified by wallet ID, scope, and account @@ -368,7 +368,7 @@ func (s accountRenameQueries) byNumber(ctx context.Context, return db.RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndNumber, - sqlcsqlite.UpdateAccountNameByWalletScopeAndNumberParams{ + sqlc.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), @@ -385,7 +385,7 @@ func (s accountRenameQueries) byName(ctx context.Context, return db.RenameAccount( ctx, s.q.UpdateAccountNameByWalletScopeAndName, - sqlcsqlite.UpdateAccountNameByWalletScopeAndNameParams{ + sqlc.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index ae2e39ed4e..1f39b389a7 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -2,14 +2,14 @@ package sqlite import ( "context" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // addressTypeRowToInfo converts a SQLite address type row to an // AddressTypeInfo struct. -func addressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, +func addressTypeRowToInfo(row sqlc.AddressType) (db.AddressTypeInfo, error) { addrType, err := db.IDToAddressType(row.ID) @@ -25,7 +25,7 @@ func addressTypeRowToInfo(row sqlcsqlite.AddressType) (db.AddressTypeInfo, // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. -func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( +func (s *Store) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { return db.ListAddressTypes( @@ -35,7 +35,7 @@ func (s *SqliteStore) ListAddressTypes(ctx context.Context) ( // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. -func (s *SqliteStore) GetAddressType(ctx context.Context, +func (s *Store) GetAddressType(ctx context.Context, id db.AddressType) (db.AddressTypeInfo, error) { return db.GetAddressTypeByID( diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index a5c7a5a9fb..1dea5eeaec 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -9,14 +9,14 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -var _ db.AddressStore = (*SqliteStore)(nil) +var _ db.AddressStore = (*Store)(nil) // GetAddress retrieves information about a specific address, identified by // its script pubkey. -func (s *SqliteStore) GetAddress(ctx context.Context, +func (s *Store) GetAddress(ctx context.Context, query db.GetAddressQuery) (*db.AddressInfo, error) { getByScript := func(ctx context.Context, @@ -24,7 +24,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, return db.GetAddress( ctx, s.queries.GetAddressByScriptPubKey, - sqlcsqlite.GetAddressByScriptPubKeyParams{ + sqlc.GetAddressByScriptPubKeyParams{ WalletID: int64(q.WalletID), ScriptPubKey: q.ScriptPubKey, }, addressRowToInfo, @@ -35,7 +35,7 @@ func (s *SqliteStore) GetAddress(ctx context.Context, } // ListAddresses returns a page of addresses matching the given query. -func (s *SqliteStore) ListAddresses(ctx context.Context, +func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { items, err := listAddressesByAccount(ctx, s.queries, query) @@ -54,7 +54,7 @@ func (s *SqliteStore) ListAddresses(ctx context.Context, } // IterAddresses returns an iterator over paginated address results. -func (s *SqliteStore) IterAddresses(ctx context.Context, +func (s *Store) IterAddresses(ctx context.Context, query db.ListAddressesQuery) iter.Seq2[db.AddressInfo, error] { return page.Iter( @@ -63,7 +63,7 @@ func (s *SqliteStore) IterAddresses(ctx context.Context, } // GetAddressSecret retrieves the encrypted secret information for an address. -func (s *SqliteStore) GetAddressSecret(ctx context.Context, +func (s *Store) GetAddressSecret(ctx context.Context, addressID uint32) (*db.AddressSecret, error) { return db.GetAddressSecret( @@ -74,15 +74,15 @@ func (s *SqliteStore) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. -func (s *SqliteStore) NewDerivedAddress(ctx context.Context, +func (s *Store) NewDerivedAddress(ctx context.Context, params db.NewDerivedAddressParams, deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { adapters := db.DerivedAddressAdapters[ - *sqlcsqlite.Queries, - sqlcsqlite.GetAccountByWalletScopeAndNameRow, + *sqlc.Queries, + sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, - sqlcsqlite.CreateDerivedAddressRow]{ + sqlc.CreateDerivedAddressRow]{ GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromParams, GetAccountID: derivedAddressGetAccountID, @@ -99,16 +99,16 @@ func (s *SqliteStore) NewDerivedAddress(ctx context.Context, } // NewImportedAddress imports a new address, script, or private key. -func (s *SqliteStore) NewImportedAddress(ctx context.Context, +func (s *Store) NewImportedAddress(ctx context.Context, params db.NewImportedAddressParams) (*db.AddressInfo, error) { adapters := db.ImportedAddressAdapters[ - *sqlcsqlite.Queries, - sqlcsqlite.GetAccountByWalletScopeAndNameRow, + *sqlc.Queries, + sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, - sqlcsqlite.CreateImportedAddressParams, - sqlcsqlite.CreateImportedAddressRow, - sqlcsqlite.InsertAddressSecretParams]{ + sqlc.CreateImportedAddressParams, + sqlc.CreateImportedAddressRow, + sqlc.InsertAddressSecretParams]{ GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromImportedParams, GetAccountID: importedAddressGetAccountID, @@ -124,15 +124,15 @@ func (s *SqliteStore) NewImportedAddress(ctx context.Context, } // getAccountFromKey returns a helper to look up accounts by key. -func getAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, - db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, error) { +func getAccountFromKey(qtx *sqlc.Queries) func(context.Context, + db.AccountLookupKey) (sqlc.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key db.AccountLookupKey) (sqlcsqlite.GetAccountByWalletScopeAndNameRow, + key db.AccountLookupKey) (sqlc.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( - ctx, sqlcsqlite.GetAccountByWalletScopeAndNameParams{ + ctx, sqlc.GetAccountByWalletScopeAndNameParams{ WalletID: key.WalletID, Purpose: key.Purpose, CoinType: key.CoinType, @@ -144,38 +144,38 @@ func getAccountFromKey(qtx *sqlcsqlite.Queries) func(context.Context, // derivedAddressGetAccountID extracts the account ID from a row. func derivedAddressGetAccountID( - row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { + row sqlc.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } // derivedAddressGetExtIndex returns the external index query. func derivedAddressGetExtIndex( - qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { + qtx *sqlc.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextExternalIndex } // derivedAddressGetIntIndex returns the internal index query. func derivedAddressGetIntIndex( - qtx *sqlcsqlite.Queries) func(context.Context, int64) (int64, error) { + qtx *sqlc.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextInternalIndex } // derivedAddressCreateAddr returns the derived address insert helper. func derivedAddressCreateAddr( - qtx *sqlcsqlite.Queries, + qtx *sqlc.Queries, ) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) ( - sqlcsqlite.CreateDerivedAddressRow, error, + sqlc.CreateDerivedAddressRow, error, ) { return func(ctx context.Context, accountID int64, addrType db.AddressType, branch uint32, index uint32, - scriptPubKey []byte) (sqlcsqlite.CreateDerivedAddressRow, error) { + scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { return qtx.CreateDerivedAddress( - ctx, sqlcsqlite.CreateDerivedAddressParams{ + ctx, sqlc.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int64(addrType), @@ -195,45 +195,45 @@ func derivedAddressCreateAddr( // derivedAddressRowID returns the created address ID. func derivedAddressRowID( - row sqlcsqlite.CreateDerivedAddressRow) int64 { + row sqlc.CreateDerivedAddressRow) int64 { return row.ID } // derivedAddressRowCreatedAt returns the CreatedAt timestamp. func derivedAddressRowCreatedAt( - row sqlcsqlite.CreateDerivedAddressRow) time.Time { + row sqlc.CreateDerivedAddressRow) time.Time { return row.CreatedAt } // importedAddressGetAccountID extracts the account ID from a row. func importedAddressGetAccountID( - row sqlcsqlite.GetAccountByWalletScopeAndNameRow) int64 { + row sqlc.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } // createImportedAddress returns the imported address insert helper. -func createImportedAddress(qtx *sqlcsqlite.Queries) func(context.Context, - sqlcsqlite.CreateImportedAddressParams) ( - sqlcsqlite.CreateImportedAddressRow, error) { +func createImportedAddress(qtx *sqlc.Queries) func(context.Context, + sqlc.CreateImportedAddressParams) ( + sqlc.CreateImportedAddressRow, error) { return qtx.CreateImportedAddress } // insertAddressSecret returns the secret insert helper. -func insertAddressSecret(qtx *sqlcsqlite.Queries) func(context.Context, - sqlcsqlite.InsertAddressSecretParams) error { +func insertAddressSecret(qtx *sqlc.Queries) func(context.Context, + sqlc.InsertAddressSecretParams) error { return qtx.InsertAddressSecret } // createImportedAddressParams maps imported params to sqlc params. func createImportedAddressParams(accountID int64, - params db.NewImportedAddressParams) sqlcsqlite.CreateImportedAddressParams { + params db.NewImportedAddressParams) sqlc.CreateImportedAddressParams { - return sqlcsqlite.CreateImportedAddressParams{ + return sqlc.CreateImportedAddressParams{ AccountID: accountID, ScriptPubKey: params.ScriptPubKey, TypeID: int64(params.AddressType), @@ -242,22 +242,22 @@ func createImportedAddressParams(accountID int64, } // importedAddressRowID returns the created address ID. -func importedAddressRowID(row sqlcsqlite.CreateImportedAddressRow) int64 { +func importedAddressRowID(row sqlc.CreateImportedAddressRow) int64 { return row.ID } // importedAddressRowCreatedAt returns the CreatedAt timestamp. func importedAddressRowCreatedAt( - row sqlcsqlite.CreateImportedAddressRow) time.Time { + row sqlc.CreateImportedAddressRow) time.Time { return row.CreatedAt } // insertAddressSecretParams maps imported params to secret params. func insertAddressSecretParams(addressID int64, - params db.NewImportedAddressParams) sqlcsqlite.InsertAddressSecretParams { + params db.NewImportedAddressParams) sqlc.InsertAddressSecretParams { - return sqlcsqlite.InsertAddressSecretParams{ + return sqlc.InsertAddressSecretParams{ AddressID: addressID, EncryptedPrivKey: params.EncryptedPrivateKey, EncryptedScript: params.EncryptedScript, @@ -267,7 +267,7 @@ func insertAddressSecretParams(addressID int64, // addressSecretRowToSecret converts a SQLite address secret row to an // AddressSecret struct. func addressSecretRowToSecret( - row sqlcsqlite.GetAddressSecretRow) (*db.AddressSecret, error) { + row sqlc.GetAddressSecretRow) (*db.AddressSecret, error) { return db.AddressSecretRowToSecret(db.AddressSecretRow{ AddressID: row.AddressID, @@ -280,8 +280,8 @@ func addressSecretRowToSecret( // address row types that share the same field structure. This enables a // single generic conversion function to handle all address query result types. type addressInfoRow interface { - sqlcsqlite.GetAddressByScriptPubKeyRow | - sqlcsqlite.ListAddressesByAccountRow + sqlc.GetAddressByScriptPubKeyRow | + sqlc.ListAddressesByAccountRow } // addressRowToInfo converts a SQLite address row to an AddressInfo @@ -290,7 +290,7 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. - base := sqlcsqlite.GetAddressByScriptPubKeyRow(row) + base := sqlc.GetAddressByScriptPubKeyRow(row) info, err := db.AddressRowToInfo(db.AddressInfoRow[int64, int64]{ ID: base.ID, @@ -316,7 +316,7 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, // listAddressesByAccount lists addresses filtered by wallet ID, key // scope, and account name, with pagination support. -func listAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, +func listAddressesByAccount(ctx context.Context, q *sqlc.Queries, query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( @@ -344,9 +344,9 @@ func listAddressesByAccount(ctx context.Context, q *sqlcsqlite.Queries, // buildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. func buildAddressPageParams( - q db.ListAddressesQuery) sqlcsqlite.ListAddressesByAccountParams { + q db.ListAddressesQuery) sqlc.ListAddressesByAccountParams { - params := sqlcsqlite.ListAddressesByAccountParams{ + params := sqlc.ListAddressesByAccountParams{ WalletID: int64(q.WalletID), Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go index f0ca724bf2..55909fcded 100644 --- a/wallet/internal/db/sqlite/backend_error_test.go +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -7,7 +7,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) @@ -16,7 +16,7 @@ import ( func TestDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { t.Parallel() - qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + qtx := sqlc.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) deleteOps := deleteTxOps{qtx: qtx} rollbackOps := rollbackToBlockOps{qtx: qtx} @@ -45,7 +45,7 @@ func TestDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { func TestTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() - qtx := sqlcsqlite.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + qtx := sqlc.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) createOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{qtx: qtx}, } diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go index f940ee9f13..f1df054789 100644 --- a/wallet/internal/db/sqlite/backend_rows_test.go +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) @@ -21,7 +21,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() loadOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT 1 FROM missing_table"), }), }, @@ -33,7 +33,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { block := testBlock(8) confirmOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newRow( t, "SELECT ?, ?, ?", @@ -53,7 +53,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { prepareOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT 1 FROM missing_table"), }), }, @@ -65,7 +65,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { conflictOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcsqlite.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), @@ -79,7 +79,7 @@ func TestCreateTxOpsAdditionalBranches(t *testing.T) { func TestReleaseOutputOpsAdditionalBranches(t *testing.T) { t.Parallel() - ops := &releaseOutputOps{qtx: sqlcsqlite.New(rowDBTX{ + ops := &releaseOutputOps{qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT 1 FROM missing_table"), })} @@ -99,15 +99,15 @@ func TestUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} - loadOps := &updateTxOps{qtx: sqlcsqlite.New(rowDBTX{ + loadOps := &updateTxOps{qtx: sqlc.New(rowDBTX{ row: newRow(t, "SELECT 1 FROM missing_table"), })} stateOps := &updateTxOps{ - qtx: sqlcsqlite.New(rowDBTX{rows: 0}), + qtx: sqlc.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt64{}, status: int64(db.TxStatusPublished), } - labelOps := &updateTxOps{qtx: sqlcsqlite.New(rowDBTX{rows: 0})} + labelOps := &updateTxOps{qtx: sqlc.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") diff --git a/wallet/internal/db/sqlite/block.go b/wallet/internal/db/sqlite/block.go index 2fc2b7fedb..1c4ddc78e4 100644 --- a/wallet/internal/db/sqlite/block.go +++ b/wallet/internal/db/sqlite/block.go @@ -6,9 +6,9 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // buildBlock constructs a Block from the given SQLite block @@ -26,12 +26,12 @@ func buildBlock(height sql.NullInt64, hash []byte, // ensureBlockExists ensures that a block exists in the database. If it // doesn't exist, it inserts it. -func ensureBlockExists(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureBlockExists(ctx context.Context, qtx *sqlc.Queries, block *db.Block) error { height := int64(block.Height) - blockParams := sqlcsqlite.InsertBlockParams{ + blockParams := sqlc.InsertBlockParams{ BlockHeight: height, HeaderHash: block.Hash[:], BlockTimestamp: block.Timestamp.Unix(), @@ -47,7 +47,7 @@ func ensureBlockExists(ctx context.Context, qtx *sqlcsqlite.Queries, // requireBlockMatches loads the shared block row for the provided height // and verifies that its stored metadata matches the supplied block reference. -func requireBlockMatches(ctx context.Context, qtx *sqlcsqlite.Queries, +func requireBlockMatches(ctx context.Context, qtx *sqlc.Queries, block *db.Block) (int64, error) { height := int64(block.Height) diff --git a/wallet/internal/db/sqlite/config.go b/wallet/internal/db/sqlite/config.go new file mode 100644 index 0000000000..f9aba8086e --- /dev/null +++ b/wallet/internal/db/sqlite/config.go @@ -0,0 +1,26 @@ +package sqlite + +import db "github.com/btcsuite/btcwallet/wallet/internal/db" + +// Config holds the configuration for the SQLite database. +type Config struct { + // DBPath is the filesystem path to the SQLite database file. + DBPath string + + // MaxConnections is the maximum number of open connections to the + // database. Set to zero to use db.DefaultMaxConnections. + MaxConnections int +} + +// Validate checks that the Config values are valid. +func (c *Config) Validate() error { + if c.DBPath == "" { + return db.ErrEmptyDBPath + } + + if c.MaxConnections < 0 { + return db.ErrNegativeMaxConns + } + + return nil +} diff --git a/wallet/internal/db/sqlite/config_test.go b/wallet/internal/db/sqlite/config_test.go new file mode 100644 index 0000000000..c3dc9c7e4c --- /dev/null +++ b/wallet/internal/db/sqlite/config_test.go @@ -0,0 +1,79 @@ +package sqlite + +import ( + "testing" + + db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestConfigValidateSuccess tests valid Config scenarios. +func TestConfigValidateSuccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config Config + }{ + { + name: "valid config with zero max connections", + config: Config{ + DBPath: "/tmp/test.db", + MaxConnections: 0, + }, + }, + { + name: "valid config with positive max connections", + config: Config{ + DBPath: "/tmp/test.db", + MaxConnections: 10, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.NoError(t, err) + }) + } +} + +// TestConfigValidateErrors tests Config validation errors. +func TestConfigValidateErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config Config + expectedErr error + }{ + { + name: "empty DB path", + config: Config{ + DBPath: "", + MaxConnections: 0, + }, + expectedErr: db.ErrEmptyDBPath, + }, + { + name: "negative max connections", + config: Config{ + DBPath: "/tmp/test.db", + MaxConnections: -1, + }, + expectedErr: db.ErrNegativeMaxConns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.config.Validate() + require.ErrorIs(t, err, tc.expectedErr) + }) + } +} diff --git a/wallet/internal/db/sqlite/doc.go b/wallet/internal/db/sqlite/doc.go new file mode 100644 index 0000000000..655a60f0de --- /dev/null +++ b/wallet/internal/db/sqlite/doc.go @@ -0,0 +1,2 @@ +// Package sqlite implements the SQLite wallet store backend. +package sqlite diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go index 16144c37a7..9069ae7af7 100644 --- a/wallet/internal/db/sqlite/itest.go +++ b/wallet/internal/db/sqlite/itest.go @@ -5,15 +5,15 @@ package sqlite import ( "database/sql" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. -func (s *SqliteStore) DB() *sql.DB { +func (s *Store) DB() *sql.DB { return s.db } // Queries returns the underlying sqlc queries for integration testing. -func (s *SqliteStore) Queries() *sqlcsqlite.Queries { +func (s *Store) Queries() *sqlc.Queries { return s.queries } diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index 5fce98f1e7..6f08363d82 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -7,22 +7,21 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) -// SqliteStore is the SQLite implementation of the WalletStore interface. - -type SqliteStore struct { +// Store is the SQLite implementation of the WalletStore interface. +type Store struct { db *sql.DB - queries *sqlcsqlite.Queries + queries *sqlc.Queries } -// NewSqliteStore creates a new SQLite-based WalletStore. It handles the full +// NewStore creates a new SQLite-based WalletStore. It handles the full // connection setup including DSN construction with pragmas, connection // opening, health checks, connection pool configuration, and migration // application. -func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, +func NewStore(ctx context.Context, cfg Config) (*Store, error) { err := cfg.Validate() @@ -59,7 +58,7 @@ func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, dbConn.SetMaxIdleConns(maxConns) dbConn.SetConnMaxIdleTime(db.DefaultConnIdleLifetime) - queries := sqlcsqlite.New(dbConn) + queries := sqlc.New(dbConn) err = sqlassetsqlite.ApplyMigrations(dbConn) if err != nil { @@ -67,14 +66,14 @@ func NewSqliteStore(ctx context.Context, cfg db.SqliteConfig) (*SqliteStore, return nil, fmt.Errorf("apply migrations: %w", err) } - return &SqliteStore{ + return &Store{ db: dbConn, queries: queries, }, nil } // Close closes the database connection. -func (s *SqliteStore) Close() error { +func (s *Store) Close() error { err := s.db.Close() if err != nil { return fmt.Errorf("close database: %w", err) @@ -87,8 +86,8 @@ func (s *SqliteStore) Close() error { // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (s *SqliteStore) ExecuteTx(ctx context.Context, - fn func(*sqlcsqlite.Queries) error) error { +func (s *Store) ExecuteTx(ctx context.Context, + fn func(*sqlc.Queries) error) error { return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 0b01be9915..2c367536f5 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -5,11 +5,11 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its wallet-owned @@ -21,7 +21,7 @@ import ( // Insert. When the wallet already stores the same unmined transaction hash, // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. -func (s *SqliteStore) CreateTx(ctx context.Context, +func (s *Store) CreateTx(ctx context.Context, params db.CreateTxParams) error { req, err := db.NewCreateTxRequest(params) @@ -29,7 +29,7 @@ func (s *SqliteStore) CreateTx(ctx context.Context, return err } - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.CreateTxWithOps(ctx, req, &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, @@ -53,7 +53,7 @@ func (o *createTxOps) LoadExisting(ctx context.Context, meta, err := o.qtx.GetTransactionMetaByHash( ctx, - sqlcsqlite.GetTransactionMetaByHashParams{ + sqlc.GetTransactionMetaByHashParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -90,7 +90,7 @@ func (o *createTxOps) ConfirmExisting(ctx context.Context, } rows, err := o.qtx.UpdateTransactionStateByHash( - ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ + ctx, sqlc.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{Int64: blockHeight, Valid: true}, Status: int64(db.TxStatusPublished), WalletID: int64(req.Params.WalletID), @@ -154,7 +154,7 @@ func (o *createTxOps) ListConflictTxns(ctx context.Context, // collectConflictRootIDs returns the active unmined spender row // IDs that currently own any wallet-controlled input spent by the incoming tx. func collectConflictRootIDs(ctx context.Context, - qtx *sqlcsqlite.Queries, + qtx *sqlc.Queries, req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { @@ -164,7 +164,7 @@ func collectConflictRootIDs(ctx context.Context, rootIDs := make(map[int64]struct{}, len(req.Params.Tx.TxIn)) for inputIndex, txIn := range req.Params.Tx.TxIn { spentByTxID, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: int64(txIn.PreviousOutPoint.Index), @@ -191,7 +191,7 @@ func collectConflictRootIDs(ctx context.Context, // buildConflictRoots maps the selected unmined rows into ordered root IDs // and the matching root hashes used for descendant discovery. -func buildConflictRoots(rows []sqlcsqlite.ListUnminedTransactionsRow, +func buildConflictRoots(rows []sqlc.ListUnminedTransactionsRow, rootIDSet map[int64]struct{}) ( []int64, []chainhash.Hash, error) { @@ -221,7 +221,7 @@ func (o *createTxOps) Insert(ctx context.Context, txID, err := o.qtx.InsertTransaction( ctx, - sqlcsqlite.InsertTransactionParams{ + sqlc.InsertTransactionParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], RawTx: req.RawTx, @@ -259,7 +259,7 @@ func (o *createTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int64(db.TxStatusReplaced), TxIds: txIDs, @@ -280,7 +280,7 @@ func (o *createTxOps) InsertReplacementEdges( for _, replacedTxID := range replacedTxIDs { _, err := o.qtx.InsertTxReplacementEdge( - ctx, sqlcsqlite.InsertTxReplacementEdgeParams{ + ctx, sqlc.InsertTxReplacementEdgeParams{ WalletID: walletID, ReplacedTxID: replacedTxID, ReplacementTxID: replacementTxID, @@ -297,7 +297,7 @@ func (o *createTxOps) InsertReplacementEdges( // insertCredits inserts one wallet-owned UTXO row for each credited // output of the transaction being stored. -func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, +func insertCredits(ctx context.Context, qtx *sqlc.Queries, params db.CreateTxParams, txID int64) error { for index := range params.Credits { @@ -315,7 +315,7 @@ func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, pkScript := params.Tx.TxOut[index].PkScript addrRow, err := qtx.GetAddressByScriptPubKey( - ctx, sqlcsqlite.GetAddressByScriptPubKeyParams{ + ctx, sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: pkScript, WalletID: int64(params.WalletID), }, @@ -329,7 +329,7 @@ func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, return fmt.Errorf("resolve credit address %d: %w", index, err) } - _, err = qtx.InsertUtxo(ctx, sqlcsqlite.InsertUtxoParams{ + _, err = qtx.InsertUtxo(ctx, sqlc.InsertUtxoParams{ WalletID: int64(params.WalletID), TxID: txID, OutputIndex: int64(index), @@ -346,11 +346,11 @@ func insertCredits(ctx context.Context, qtx *sqlcsqlite.Queries, // creditExists reports whether the wallet already has a UTXO row for the // given credited output, even if that output is now spent by a child tx. -func creditExists(ctx context.Context, qtx *sqlcsqlite.Queries, +func creditExists(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { _, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: int64(outputIndex), @@ -376,7 +376,7 @@ func creditExists(ctx context.Context, qtx *sqlcsqlite.Queries, // instead of silently storing a second spender. Inputs that reference a // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. -func markInputsSpent(ctx context.Context, qtx *sqlcsqlite.Queries, +func markInputsSpent(ctx context.Context, qtx *sqlc.Queries, params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { @@ -387,7 +387,7 @@ func markInputsSpent(ctx context.Context, qtx *sqlcsqlite.Queries, spentInputIndex := sql.NullInt64{Int64: int64(inputIndex), Valid: true} rowsAffected, err := qtx.MarkUtxoSpent(ctx, - sqlcsqlite.MarkUtxoSpentParams{ + sqlc.MarkUtxoSpentParams{ WalletID: int64(params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: int64(txIn.PreviousOutPoint.Index), @@ -417,11 +417,11 @@ func markInputsSpent(ctx context.Context, qtx *sqlcsqlite.Queries, // to another transaction. If the wallet owns the parent output but that parent // is already invalid, the helper returns ErrTxInputInvalidParent instead. func ensureSpendConflict(ctx context.Context, - qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, + qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int64, txID int64) error { spendByTxID, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcsqlite.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: outputIndex, @@ -448,11 +448,11 @@ func ensureSpendConflict(ctx context.Context, // wallet owns the referenced outpoint but its parent transaction is already // invalid. func ensureWalletParentValid(ctx context.Context, - qtx *sqlcsqlite.Queries, walletID uint32, txHash chainhash.Hash, + qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int64) error { hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( - ctx, sqlcsqlite.HasInvalidWalletUtxoByOutpointParams{ + ctx, sqlc.HasInvalidWalletUtxoByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: outputIndex, diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index 3559922ef6..c930d83263 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet @@ -18,17 +18,17 @@ import ( // terminal invalid-history rows remain part of the wallet timeline. The // transaction must also be a leaf among the wallet's unmined transactions so // the delete cannot detach child spenders from their parent history. -func (s *SqliteStore) DeleteTx(ctx context.Context, +func (s *Store) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } // deleteTxOps adapts sqlite sqlc queries to the shared DeleteTx flow. type deleteTxOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.DeleteTxOps = (*deleteTxOps)(nil) @@ -61,7 +61,7 @@ func (o deleteTxOps) ClearSpentUtxos(ctx context.Context, _, err := o.qtx.ClearUtxosSpentByTxID( ctx, - sqlcsqlite.ClearUtxosSpentByTxIDParams{ + sqlc.ClearUtxosSpentByTxIDParams{ WalletID: int64(walletID), SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, }, @@ -80,7 +80,7 @@ func (o deleteTxOps) DeleteCreatedUtxos(ctx context.Context, _, err := o.qtx.DeleteUtxosByTxID( ctx, - sqlcsqlite.DeleteUtxosByTxIDParams{ + sqlc.DeleteUtxosByTxIDParams{ WalletID: int64(walletID), TxID: txID, }, @@ -99,7 +99,7 @@ func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, rows, err := o.qtx.DeleteUnminedTransactionByHash( ctx, - sqlcsqlite.DeleteUnminedTransactionByHashParams{ + sqlc.DeleteUnminedTransactionByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -114,7 +114,7 @@ func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, // ensureDeleteLeaf rejects DeleteTx requests for transactions that still // have direct unmined child spenders, including children that spend non-credit // parent outputs. -func ensureDeleteLeaf(ctx context.Context, qtx *sqlcsqlite.Queries, +func ensureDeleteLeaf(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, txID int64) error { rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) @@ -124,7 +124,7 @@ func ensureDeleteLeaf(ctx context.Context, qtx *sqlcsqlite.Queries, candidates, err := db.BuildUnminedTxRecords( rows, - func(row sqlcsqlite.ListUnminedTransactionsRow) (int64, + func(row sqlc.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx @@ -153,33 +153,33 @@ func ensureDeleteLeaf(ctx context.Context, qtx *sqlcsqlite.Queries, // getDeleteTxMeta loads the transaction metadata DeleteTx needs and // enforces the unmined precondition up front. -func getDeleteTxMeta(ctx context.Context, qtx *sqlcsqlite.Queries, +func getDeleteTxMeta(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash) ( - sqlcsqlite.GetTransactionMetaByHashRow, error) { + sqlc.GetTransactionMetaByHashRow, error) { meta, err := qtx.GetTransactionMetaByHash( - ctx, sqlcsqlite.GetTransactionMetaByHashParams{ + ctx, sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return sqlcsqlite.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } - return sqlcsqlite.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("get tx metadata: %w", err) } status, err := db.ParseTxStatus(meta.TxStatus) if err != nil { - return sqlcsqlite.GetTransactionMetaByHashRow{}, err + return sqlc.GetTransactionMetaByHashRow{}, err } if meta.BlockHeight.Valid || !db.IsUnminedStatus(status) { - return sqlcsqlite.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, db.ErrDeleteRequiresUnmined) } diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index 56122872f4..955d0a71a5 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -5,21 +5,21 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. // // The returned TxInfo is rebuilt from normalized SQL columns; missing rows map // to ErrTxNotFound for the requested wallet/hash pair. -func (s *SqliteStore) GetTx(ctx context.Context, +func (s *Store) GetTx(ctx context.Context, query db.GetTxQuery) (*db.TxInfo, error) { row, err := s.queries.GetTransactionByHash( - ctx, sqlcsqlite.GetTransactionByHashParams{ + ctx, sqlc.GetTransactionByHashParams{ WalletID: int64(query.WalletID), TxHash: query.Txid[:], }, diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index 26a5ce9d00..2c3ede8c7b 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -5,18 +5,18 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined // transaction branch and marks the root plus descendants failed. -func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, +func (s *Store) InvalidateUnminedTx(ctx context.Context, params db.InvalidateUnminedTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.InvalidateUnminedTxWithOps( ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) @@ -26,7 +26,7 @@ func (s *SqliteStore) InvalidateUnminedTx(ctx context.Context, // invalidateUnminedTxOps adapts sqlite sqlc queries to the shared // InvalidateUnminedTx workflow. type invalidateUnminedTxOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) @@ -38,7 +38,7 @@ func (o invalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( - ctx, sqlcsqlite.GetTransactionMetaByHashParams{ + ctx, sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -79,7 +79,7 @@ func (o invalidateUnminedTxOps) ListUnminedTxRecords( } return db.BuildUnminedTxRecords( - rows, func(row sqlcsqlite.ListUnminedTransactionsRow) ( + rows, func(row sqlc.ListUnminedTransactionsRow) ( int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx @@ -93,7 +93,7 @@ func (o invalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( - ctx, sqlcsqlite.ClearUtxosSpentByTxIDParams{ + ctx, sqlc.ClearUtxosSpentByTxIDParams{ WalletID: walletID, SpentByTxID: sql.NullInt64{ Int64: txID, @@ -114,7 +114,7 @@ func (o invalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int64(db.TxStatusFailed), TxIds: txIDs, diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index f656805c5c..bcfbe12bc3 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -4,9 +4,9 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range @@ -15,7 +15,7 @@ import ( // The no-confirming-block path returns every row without a confirming block, // including retained invalid history such as orphaned or failed transactions, // while the confirmed path is bounded by the requested height range. -func (s *SqliteStore) ListTxns(ctx context.Context, +func (s *Store) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { @@ -29,7 +29,7 @@ func (s *SqliteStore) ListTxns(ctx context.Context, // confirming block. This includes the active unmined set together with any // retained invalid history that rollback or invalidation flows left without a // confirming block. -func (s *SqliteStore) listTxnsWithoutBlock(ctx context.Context, +func (s *Store) listTxnsWithoutBlock(ctx context.Context, walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) @@ -55,11 +55,11 @@ func (s *SqliteStore) listTxnsWithoutBlock(ctx context.Context, // listConfirmedTxns loads the confirmed height-range view used by // ListTxns when callers query mined history. -func (s *SqliteStore) listConfirmedTxns(ctx context.Context, +func (s *Store) listConfirmedTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsByHeightRange( - ctx, sqlcsqlite.ListTransactionsByHeightRangeParams{ + ctx, sqlc.ListTransactionsByHeightRangeParams{ WalletID: int64(query.WalletID), StartHeight: int64(query.StartHeight), EndHeight: int64(query.EndHeight), diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index a24beb2252..2e2b55686c 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -4,19 +4,19 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided // height and rewrites wallet sync-state references so the block delete can // succeed. -func (s *SqliteStore) RollbackToBlock(ctx context.Context, +func (s *Store) RollbackToBlock(ctx context.Context, height uint32) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.RollbackToBlockWithOps(ctx, height, rollbackToBlockOps{qtx: qtx}) }) @@ -25,7 +25,7 @@ func (s *SqliteStore) RollbackToBlock(ctx context.Context, // rollbackToBlockOps adapts sqlite sqlc queries to the shared rollback // sequence. type rollbackToBlockOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.RollbackToBlockOps = (*rollbackToBlockOps)(nil) @@ -54,7 +54,7 @@ func (o rollbackToBlockOps) RewindWalletSyncStateHeights( } _, err := o.qtx.RewindWalletSyncStateHeightsForRollback( - ctx, sqlcsqlite.RewindWalletSyncStateHeightsForRollbackParams{ + ctx, sqlc.RewindWalletSyncStateHeightsForRollbackParams{ RollbackHeight: int64(height), NewHeight: newHeight, }, @@ -91,7 +91,7 @@ func (o rollbackToBlockOps) MarkTxRootsOrphaned( // block reference and become orphaned in the same // row-local state patch. rows, err := o.qtx.UpdateTransactionStateByHash( - ctx, sqlcsqlite.UpdateTransactionStateByHashParams{ + ctx, sqlc.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt64{}, Status: int64(db.TxStatusOrphaned), WalletID: int64(walletID), @@ -121,7 +121,7 @@ func (o rollbackToBlockOps) ListUnminedTxRecords( } return db.BuildUnminedTxRecords(rows, - func(row sqlcsqlite.ListUnminedTransactionsRow) ( + func(row sqlc.ListUnminedTransactionsRow) ( int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx @@ -135,7 +135,7 @@ func (o rollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( - ctx, sqlcsqlite.ClearUtxosSpentByTxIDParams{ + ctx, sqlc.ClearUtxosSpentByTxIDParams{ WalletID: walletID, SpentByTxID: sql.NullInt64{ Int64: descendantID, @@ -156,7 +156,7 @@ func (o rollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcsqlite.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int64(db.TxStatusFailed), TxIds: descendantIDs, @@ -172,7 +172,7 @@ func (o rollbackToBlockOps) MarkDescendantsFailed( // groupRollbackCoinbaseRoots groups rollback-affected coinbase hashes by // wallet while preserving the query order inside each wallet bucket. func groupRollbackCoinbaseRoots( - rows []sqlcsqlite.ListRollbackCoinbaseRootsRow) ( + rows []sqlc.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index 6a1edc74fc..6d2ab78d6b 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. @@ -17,10 +17,10 @@ import ( // one SQL transaction. Immutable transaction facts such as raw_tx, credits, and // spent-input edges stay owned by CreateTx and the internal rollback/delete // flows. -func (s *SqliteStore) UpdateTx(ctx context.Context, +func (s *Store) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } @@ -28,7 +28,7 @@ func (s *SqliteStore) UpdateTx(ctx context.Context, // updateTxOps adapts sqlite sqlc queries to the shared UpdateTx flow. type updateTxOps struct { // qtx is the transaction-scoped sqlite query set used by UpdateTx. - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries // blockHeight caches the validated sqlite block-height wrapper prepared for // the later state update query. @@ -48,7 +48,7 @@ func (o *updateTxOps) LoadIsCoinbase(ctx context.Context, meta, err := o.qtx.GetTransactionMetaByHash( ctx, - sqlcsqlite.GetTransactionMetaByHashParams{ + sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -92,7 +92,7 @@ func (o *updateTxOps) UpdateLabel(ctx context.Context, walletID uint32, rows, err := o.qtx.UpdateTransactionLabelByHash( ctx, - sqlcsqlite.UpdateTransactionLabelByHashParams{ + sqlc.UpdateTransactionLabelByHashParams{ Label: label, WalletID: int64(walletID), TxHash: txHash[:], @@ -116,7 +116,7 @@ func (o *updateTxOps) UpdateState(ctx context.Context, walletID uint32, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, - sqlcsqlite.UpdateTransactionStateByHashParams{ + sqlc.UpdateTransactionStateByHashParams{ BlockHeight: o.blockHeight, Status: o.status, WalletID: int64(walletID), diff --git a/wallet/internal/db/sqlite/utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go index a4f094c892..7e9cca30ad 100644 --- a/wallet/internal/db/sqlite/utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -3,20 +3,20 @@ package sqlite import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" "github.com/btcsuite/btcd/btcutil" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. -func (s *SqliteStore) Balance(ctx context.Context, +func (s *Store) Balance(ctx context.Context, params db.BalanceParams) (db.BalanceResult, error) { nowUTC := time.Now().UTC() - balance, err := s.queries.Balance(ctx, sqlcsqlite.BalanceParams{ + balance, err := s.queries.Balance(ctx, sqlc.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(params.Account), diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index f0eb24dbdb..d5bc3b60cf 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -5,21 +5,21 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. // // The output must still be unspent and its creating transaction must still be // in `pending` or `published` status. -func (s *SqliteStore) GetUtxo(ctx context.Context, +func (s *Store) GetUtxo(ctx context.Context, query db.GetUtxoQuery) (*db.UtxoInfo, error) { row, err := s.queries.GetUtxoByOutpoint( - ctx, sqlcsqlite.GetUtxoByOutpointParams{ + ctx, sqlc.GetUtxoByOutpointParams{ WalletID: int64(query.WalletID), TxHash: query.OutPoint.Hash[:], OutputIndex: int64(query.OutPoint.Index), diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index 19849dc232..cbe2c2b3b2 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. @@ -16,12 +16,12 @@ import ( // The lease lookup and acquisition run in one transaction so competing calls // cannot observe a partially-written lease. Expiration timestamps are // normalized to UTC before Insert. -func (s *SqliteStore) LeaseOutput(ctx context.Context, +func (s *Store) LeaseOutput(ctx context.Context, params db.LeaseOutputParams) (*db.LeasedOutput, error) { var lease *db.LeasedOutput - err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &leaseOutputOps{qtx: qtx}, ) @@ -43,7 +43,7 @@ func (s *SqliteStore) LeaseOutput(ctx context.Context, // leaseOutputOps adapts sqlite sqlc queries to the shared LeaseOutput // workflow. type leaseOutputOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.LeaseOutputOps = (*leaseOutputOps)(nil) @@ -55,7 +55,7 @@ func (o *leaseOutputOps) Acquire(ctx context.Context, expiresAt time.Time) (time.Time, error) { expiration, err := o.qtx.AcquireUtxoLease( - ctx, sqlcsqlite.AcquireUtxoLeaseParams{ + ctx, sqlc.AcquireUtxoLeaseParams{ WalletID: int64(params.WalletID), LockID: params.ID[:], ExpiresAt: expiresAt, @@ -81,7 +81,7 @@ func (o *leaseOutputOps) HasUtxo(ctx context.Context, params db.LeaseOutputParams) (bool, error) { _, err := o.qtx.GetUtxoIDByOutpoint( - ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ + ctx, sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(params.WalletID), TxHash: params.OutPoint.Hash[:], OutputIndex: int64(params.OutPoint.Index), diff --git a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index b4f1b70d68..68705d245d 100644 --- a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -3,20 +3,20 @@ package sqlite import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. -func (s *SqliteStore) ListLeasedOutputs(ctx context.Context, +func (s *Store) ListLeasedOutputs(ctx context.Context, walletID uint32) ([]db.LeasedOutput, error) { nowUTC := time.Now().UTC() rows, err := s.queries.ListActiveUtxoLeases( - ctx, sqlcsqlite.ListActiveUtxoLeasesParams{ + ctx, sqlc.ListActiveUtxoLeasesParams{ WalletID: int64(walletID), NowUtc: nowUTC, }, diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index 433c4416fd..03c1d082d2 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -3,19 +3,19 @@ package sqlite import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. // // The result set is already constrained to outputs whose creating // transactions are still in `pending` or `published` status. -func (s *SqliteStore) ListUTXOs(ctx context.Context, +func (s *Store) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { - rows, err := s.queries.ListUtxos(ctx, sqlcsqlite.ListUtxosParams{ + rows, err := s.queries.ListUtxos(ctx, sqlc.ListUtxosParams{ WalletID: int64(query.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(query.Account), MinConfirms: db.NullableInt32ToSQLInt64(query.MinConfs), diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index a7f9a0fc48..5cfad1d1e6 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the @@ -16,10 +16,10 @@ import ( // // The ownership check and lease deletion run in one transaction so callers // cannot unlock a UTXO using stale state from a separate read. -func (s *SqliteStore) ReleaseOutput(ctx context.Context, +func (s *Store) ReleaseOutput(ctx context.Context, params db.ReleaseOutputParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.ReleaseOutputWithOps( ctx, params, &releaseOutputOps{qtx: qtx}, ) @@ -29,7 +29,7 @@ func (s *SqliteStore) ReleaseOutput(ctx context.Context, // releaseOutputOps adapts sqlite sqlc queries to the shared // ReleaseOutput workflow. type releaseOutputOps struct { - qtx *sqlcsqlite.Queries + qtx *sqlc.Queries } var _ db.ReleaseOutputOps = (*releaseOutputOps)(nil) @@ -40,7 +40,7 @@ func (o *releaseOutputOps) LookupUtxoID(ctx context.Context, params db.ReleaseOutputParams) (int64, error) { utxoID, err := o.qtx.GetUtxoIDByOutpoint( - ctx, sqlcsqlite.GetUtxoIDByOutpointParams{ + ctx, sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(params.WalletID), TxHash: params.OutPoint.Hash[:], OutputIndex: int64(params.OutPoint.Index), @@ -63,7 +63,7 @@ func (o *releaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( - ctx, sqlcsqlite.ReleaseUtxoLeaseParams{ + ctx, sqlc.ReleaseUtxoLeaseParams{ WalletID: int64(walletID), UtxoID: utxoID, LockID: lockID[:], @@ -82,7 +82,7 @@ func (o *releaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( - ctx, sqlcsqlite.GetActiveUtxoLeaseLockIDParams{ + ctx, sqlc.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, NowUtc: nowUTC, diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 51f582534f..eaf0f9fd85 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -9,22 +9,22 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) -// Ensure SqliteStore satisfies the WalletStore interface. -var _ db.WalletStore = (*SqliteStore)(nil) +// Ensure Store satisfies the WalletStore interface. +var _ db.WalletStore = (*Store)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. -func (s *SqliteStore) CreateWallet(ctx context.Context, +func (s *Store) CreateWallet(ctx context.Context, params db.CreateWalletParams) (*db.WalletInfo, error) { var info *db.WalletInfo - err := s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { - walletParams := sqlcsqlite.CreateWalletParams{ + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + walletParams := sqlc.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, ManagerVersion: int64(params.ManagerVersion), @@ -39,7 +39,7 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, return fmt.Errorf("create wallet: %w", err) } - secretsParams := sqlcsqlite.InsertWalletSecretsParams{ + secretsParams := sqlc.InsertWalletSecretsParams{ WalletID: id, MasterPrivParams: params.MasterKeyPrivParams, EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, @@ -63,7 +63,7 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, } } - syncParams := sqlcsqlite.InsertWalletSyncStateParams{ + syncParams := sqlc.InsertWalletSyncStateParams{ WalletID: id, SyncedHeight: sql.NullInt64{}, BirthdayHeight: sql.NullInt64{}, @@ -114,7 +114,7 @@ func (s *SqliteStore) CreateWallet(ctx context.Context, // GetWallet retrieves information about a wallet given its name. It // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. -func (s *SqliteStore) GetWallet(ctx context.Context, +func (s *Store) GetWallet(ctx context.Context, name string) (*db.WalletInfo, error) { row, err := s.queries.GetWalletByName(ctx, name) @@ -144,7 +144,7 @@ func (s *SqliteStore) GetWallet(ctx context.Context, } // ListWallets returns a page of wallets matching the given query. -func (s *SqliteStore) ListWallets(ctx context.Context, +func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets( @@ -177,7 +177,7 @@ func (s *SqliteStore) ListWallets(ctx context.Context, } // IterWallets returns an iterator over paginated wallet results. -func (s *SqliteStore) IterWallets(ctx context.Context, +func (s *Store) IterWallets(ctx context.Context, query db.ListWalletsQuery) iter.Seq2[db.WalletInfo, error] { return page.Iter( @@ -189,10 +189,10 @@ func (s *SqliteStore) IterWallets(ctx context.Context, // birthday, birthday block, or sync state. The specific fields to // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. -func (s *SqliteStore) UpdateWallet(ctx context.Context, +func (s *Store) UpdateWallet(ctx context.Context, params db.UpdateWalletParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcsqlite.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExists( @@ -234,7 +234,7 @@ func (s *SqliteStore) UpdateWallet(ctx context.Context, // Deterministic (HD) seed of the wallet. This seed is sensitive // information and is returned in its encrypted form. It returns the // encrypted seed as a byte slice or an error if the retrieval fails. -func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, +func (s *Store) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) @@ -257,10 +257,10 @@ func (s *SqliteStore) GetEncryptedHDSeed(ctx context.Context, } // UpdateWalletSecrets updates the secrets for the wallet. -func (s *SqliteStore) UpdateWalletSecrets(ctx context.Context, +func (s *Store) UpdateWalletSecrets(ctx context.Context, params db.UpdateWalletSecretsParams) error { - secretsParams := sqlcsqlite.UpdateWalletSecretsParams{ + secretsParams := sqlc.UpdateWalletSecretsParams{ MasterPrivParams: params.MasterPrivParams, EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, EncryptedCryptoScriptKey: params.EncryptedCryptoScriptKey, @@ -301,7 +301,7 @@ type walletRowParams struct { // listWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. func listWalletRowToInfo( - row sqlcsqlite.ListWalletsRow) (*db.WalletInfo, error) { + row sqlc.ListWalletsRow) (*db.WalletInfo, error) { return buildWalletInfo(walletRowParams{ id: row.ID, @@ -322,9 +322,9 @@ func listWalletRowToInfo( // listWalletsParams translates a page request to ListWallets query // parameters, handling optional cursor setup for pagination. func listWalletsParams( - req page.Request[uint32]) sqlcsqlite.ListWalletsParams { + req page.Request[uint32]) sqlc.ListWalletsParams { - params := sqlcsqlite.ListWalletsParams{ + params := sqlc.ListWalletsParams{ PageLimit: int64(req.QueryLimit()), } @@ -392,9 +392,9 @@ func buildWalletInfo(row walletRowParams) (*db.WalletInfo, error) { // buildUpdateSyncParams constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. func buildUpdateSyncParams( - params db.UpdateWalletParams) sqlcsqlite.UpdateWalletSyncStateParams { + params db.UpdateWalletParams) sqlc.UpdateWalletSyncStateParams { - syncParams := sqlcsqlite.UpdateWalletSyncStateParams{ + syncParams := sqlc.UpdateWalletSyncStateParams{ WalletID: int64(params.WalletID), } diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go index ee34c55681..bdc3cbbdf9 100644 --- a/wallet/internal/db/tx.go +++ b/wallet/internal/db/tx.go @@ -10,7 +10,7 @@ import ( // the transaction lifecycle: begin, commit, and rollback on error. // // This is a helper function used by the public ExecuteTx methods on -// PostgresStore and SqliteStore. It guarantees that the transaction +// PostgresStore and sqlite.Store. It guarantees that the transaction // will be either committed (on success) or rolled back (on error or panic). func ExecInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) From bb7142479ae5cfd7d7143d360c743f7c43874e1d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 07:04:13 +0800 Subject: [PATCH 538/691] db/pg: rename exported store API Rename the pg backend's exported store API to the package-local Store, Config, and NewStore names now that the backend lives in its own package. The postgres config type and tests also move into the pg package so callers import the backend package for its concrete constructor surface. This removes package stutter at pg call sites while keeping the shared db package focused on backend-neutral helpers and errors. --- wallet/internal/db/config.go | 33 ------ wallet/internal/db/db_connectors_test.go | 16 +-- wallet/internal/db/itest/fixtures_pg_test.go | 34 +++--- wallet/internal/db/itest/pg_test.go | 30 ++--- .../db/itest/tx_corruption_pg_test.go | 11 +- .../db/itest/tx_store_corruption_test.go | 4 +- .../db/itest/utxo_store_edge_cases_test.go | 2 +- wallet/internal/db/pg/accounts.go | 88 +++++++------- wallet/internal/db/pg/address_types.go | 10 +- wallet/internal/db/pg/addresses.go | 108 +++++++++--------- wallet/internal/db/pg/backend_error_test.go | 10 +- wallet/internal/db/pg/backend_rows_test.go | 16 +-- wallet/internal/db/pg/block.go | 10 +- wallet/internal/db/pg/config.go | 36 ++++++ wallet/internal/db/{ => pg}/config_test.go | 29 ++--- wallet/internal/db/pg/doc.go | 2 + wallet/internal/db/pg/itest.go | 9 +- wallet/internal/db/pg/store.go | 23 ++-- wallet/internal/db/pg/testhelpers_test.go | 9 -- wallet/internal/db/pg/txstore_createtx.go | 46 ++++---- wallet/internal/db/pg/txstore_deletetx.go | 34 +++--- wallet/internal/db/pg/txstore_gettx.go | 8 +- .../db/pg/txstore_invalidateunmined.go | 26 +++-- wallet/internal/db/pg/txstore_listtxns.go | 12 +- wallet/internal/db/pg/txstore_rollback.go | 22 ++-- wallet/internal/db/pg/txstore_updatetx.go | 16 +-- wallet/internal/db/pg/utxostore_balance.go | 8 +- wallet/internal/db/pg/utxostore_getutxo.go | 8 +- .../internal/db/pg/utxostore_leaseoutput.go | 14 +-- .../db/pg/utxostore_listleasedoutputs.go | 8 +- wallet/internal/db/pg/utxostore_listutxos.go | 10 +- .../internal/db/pg/utxostore_releaseoutput.go | 16 +-- wallet/internal/db/pg/wallet.go | 42 +++---- wallet/internal/db/tx.go | 2 +- 34 files changed, 376 insertions(+), 376 deletions(-) create mode 100644 wallet/internal/db/pg/config.go rename wallet/internal/db/{ => pg}/config_test.go (70%) create mode 100644 wallet/internal/db/pg/doc.go diff --git a/wallet/internal/db/config.go b/wallet/internal/db/config.go index 43363a14ab..27f6a9826b 100644 --- a/wallet/internal/db/config.go +++ b/wallet/internal/db/config.go @@ -2,10 +2,7 @@ package db import ( "errors" - "fmt" "time" - - "github.com/jackc/pgx/v5" ) const ( @@ -35,33 +32,3 @@ var ( // ErrEmptyDSN is returned when the DSN string is empty. ErrEmptyDSN = errors.New("DSN is required") ) - -// PostgresConfig holds the configuration for the PostgreSQL database. -type PostgresConfig struct { - // Dsn is the database connection string. - Dsn string - - // MaxConnections is the maximum number of open connections to the - // database. Set to zero to use DefaultMaxConnections. - MaxConnections int -} - -// Validate checks that the PostgresConfig values are valid. -func (c *PostgresConfig) Validate() error { - if c.Dsn == "" { - return ErrEmptyDSN - } - - // Parse the DSN using pgx to ensure it's a valid PostgreSQL - // connection string. - _, err := pgx.ParseConfig(c.Dsn) - if err != nil { - return fmt.Errorf("invalid DSN: %w", err) - } - - if c.MaxConnections < 0 { - return ErrNegativeMaxConns - } - - return nil -} diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index 49f1dcdf1a..9185bf8d74 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -10,24 +10,24 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewPostgresStoreValidateConfig(t *testing.T) { +func TestPostgresNewStoreValidateConfig(t *testing.T) { t.Parallel() tests := []struct { name string - cfg db.PostgresConfig + cfg dbpg.Config wantErr error }{ { name: "empty DSN", - cfg: db.PostgresConfig{ + cfg: dbpg.Config{ Dsn: "", }, wantErr: db.ErrEmptyDSN, }, { name: "negative max connections", - cfg: db.PostgresConfig{ + cfg: dbpg.Config{ Dsn: "postgres://test", MaxConnections: -1, }, @@ -39,22 +39,22 @@ func TestNewPostgresStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := dbpg.NewPostgresStore(t.Context(), tc.cfg) + store, err := dbpg.NewStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) } } -func TestNewPostgresStoreConnectionFailure(t *testing.T) { +func TestPostgresNewStoreConnectionFailure(t *testing.T) { t.Parallel() // Valid config, but hits a connection failure. - cfg := db.PostgresConfig{ + cfg := dbpg.Config{ Dsn: "postgres://localhost:1/testdb", } - store, err := dbpg.NewPostgresStore(t.Context(), cfg) + store, err := dbpg.NewStore(t.Context(), cfg) require.Error(t, err) require.ErrorContains(t, err, "ping database") require.NotErrorIs(t, err, db.ErrEmptyDSN) diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 540d1443ac..924e1ae3c5 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -11,18 +11,18 @@ import ( "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) // CreateBlockFixture inserts a test block into the database and returns it. -func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, +func CreateBlockFixture(t *testing.T, queries *sqlc.Queries, height uint32) db.Block { t.Helper() block := NewBlockFixture(height) err := queries.InsertBlock( - t.Context(), sqlcpg.InsertBlockParams{ + t.Context(), sqlc.InsertBlockParams{ BlockHeight: int32(block.Height), HeaderHash: block.Hash[:], BlockTimestamp: block.Timestamp.Unix(), @@ -35,12 +35,12 @@ func CreateBlockFixture(t *testing.T, queries *sqlcpg.Queries, // CreateAccountWithNumber creates an account with a specific account number. // Used to test account number overflow without creating billions of accounts. -func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, +func CreateAccountWithNumber(t *testing.T, queries *sqlc.Queries, scopeID int64, accountNumber uint32, name string) { t.Helper() _, err := queries.CreateDerivedAccountWithNumber( - t.Context(), sqlcpg.CreateDerivedAccountWithNumberParams{ + t.Context(), sqlc.CreateDerivedAccountWithNumberParams{ ScopeID: scopeID, AccountNumber: sql.NullInt64{Int64: int64(accountNumber), Valid: true}, AccountName: name, @@ -54,12 +54,12 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlcpg.Queries, // CreateAddressWithIndex creates a derived address with a specific address // index. Used to test address index overflow without creating billions of // addresses. -func CreateAddressWithIndex(t *testing.T, queries *sqlcpg.Queries, +func CreateAddressWithIndex(t *testing.T, queries *sqlc.Queries, accountID int64, branch int16, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( - t.Context(), sqlcpg.CreateDerivedAddressParams{ + t.Context(), sqlc.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int16(db.WitnessPubKey), @@ -99,12 +99,12 @@ func UpdateAccountNextInternalIndex(t *testing.T, dbConn *sql.DB, } // GetKeyScopeID retrieves the scope ID for a given wallet and key scope. -func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, +func GetKeyScopeID(t *testing.T, queries *sqlc.Queries, walletID uint32, scope db.KeyScope) int64 { t.Helper() row, err := queries.GetKeyScopeByWalletAndScope( - t.Context(), sqlcpg.GetKeyScopeByWalletAndScopeParams{ + t.Context(), sqlc.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), @@ -116,13 +116,13 @@ func GetKeyScopeID(t *testing.T, queries *sqlcpg.Queries, } // GetAccountID retrieves the account ID for a given scope and account name. -func GetAccountID(t *testing.T, queries *sqlcpg.Queries, +func GetAccountID(t *testing.T, queries *sqlc.Queries, scopeID int64, accountName string) int64 { t.Helper() row, err := queries.GetAccountByScopeAndName( t.Context(), - sqlcpg.GetAccountByScopeAndNameParams{ + sqlc.GetAccountByScopeAndNameParams{ ScopeID: scopeID, AccountName: accountName, }, @@ -132,12 +132,12 @@ func GetAccountID(t *testing.T, queries *sqlcpg.Queries, return row.ID } -func getAddressID(t *testing.T, queries *sqlcpg.Queries, scriptPubKey []byte, +func getAddressID(t *testing.T, queries *sqlc.Queries, scriptPubKey []byte, walletID uint32) int64 { t.Helper() addr, err := queries.GetAddressByScriptPubKey( - t.Context(), sqlcpg.GetAddressByScriptPubKeyParams{ + t.Context(), sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: scriptPubKey, WalletID: int64(walletID), }, @@ -147,8 +147,8 @@ func getAddressID(t *testing.T, queries *sqlcpg.Queries, scriptPubKey []byte, return addr.ID } -func GetAddressSecret(t *testing.T, queries *sqlcpg.Queries, - addressID int64) (sqlcpg.GetAddressSecretRow, error) { +func GetAddressSecret(t *testing.T, queries *sqlc.Queries, + addressID int64) (sqlc.GetAddressSecretRow, error) { t.Helper() return queries.GetAddressSecret(t.Context(), addressID) @@ -190,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &dbpg.PostgresStore{}, store) + require.IsType(t, &dbpg.Store{}, store) - pgStore := store.(*dbpg.PostgresStore) + pgStore := store.(*dbpg.Store) queries := pgStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index b7e1f8b0aa..ac528b8e98 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -21,7 +21,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -193,7 +193,7 @@ func sanitizedPgDBName(t *testing.T) string { // limit allows, exhausting the PostgreSQL connection pool. Avoid this by // creating NewTestStore inside each parallel subtest so its lifecycle is tied // to the subtest's parallel slot. -func NewTestStore(t *testing.T) *dbpg.PostgresStore { +func NewTestStore(t *testing.T) *dbpg.Store { t.Helper() ctx := t.Context() @@ -223,12 +223,12 @@ func NewTestStore(t *testing.T) *dbpg.PostgresStore { // Build the connection string for the test database. testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) - cfg := db.PostgresConfig{ + cfg := dbpg.Config{ Dsn: testConnStr, MaxConnections: 0, } - store, err := dbpg.NewPostgresStore(t.Context(), cfg) + store, err := dbpg.NewStore(t.Context(), cfg) require.NoError(t, err, "failed to create postgres store") t.Cleanup(func() { @@ -240,14 +240,14 @@ func NewTestStore(t *testing.T) *dbpg.PostgresStore { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *dbpg.PostgresStore, +func childSpendingTxIDs(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash) []int64 { t.Helper() meta, err := store.Queries().GetTransactionMetaByHash( - t.Context(), sqlcpg.GetTransactionMetaByHashParams{ + t.Context(), sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -255,7 +255,7 @@ func childSpendingTxIDs(t *testing.T, store *dbpg.PostgresStore, require.NoError(t, err) childIDs, err := store.Queries().ListSpendingTxIDsByParentTxID( - t.Context(), sqlcpg.ListSpendingTxIDsByParentTxIDParams{ + t.Context(), sqlc.ListSpendingTxIDsByParentTxIDParams{ WalletID: int64(walletID), TxID: meta.ID, }, @@ -273,13 +273,13 @@ func childSpendingTxIDs(t *testing.T, store *dbpg.PostgresStore, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, +func txIDByHash(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() meta, err := store.Queries().GetTransactionMetaByHash( - t.Context(), sqlcpg.GetTransactionMetaByHashParams{ + t.Context(), sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -297,13 +297,13 @@ func txIDByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, +func rawTxByHash(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() row, err := store.Queries().GetTransactionByHash( - t.Context(), sqlcpg.GetTransactionByHashParams{ + t.Context(), sqlc.GetTransactionByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -315,7 +315,7 @@ func rawTxByHash(t *testing.T, store *dbpg.PostgresStore, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, +func setTxStatus(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -324,7 +324,7 @@ func setTxStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, require.True(t, ok) rows, err := store.Queries().UpdateTransactionStatusByIDs( - t.Context(), sqlcpg.UpdateTransactionStatusByIDsParams{ + t.Context(), sqlc.UpdateTransactionStatusByIDsParams{ WalletID: int64(walletID), Status: int16(status), TxIds: []int64{txID}, @@ -336,14 +336,14 @@ func setTxStatus(t *testing.T, store *dbpg.PostgresStore, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *dbpg.PostgresStore, +func walletUtxoExists(t *testing.T, store *dbpg.Store, walletID uint32, outPoint wire.OutPoint) bool { t.Helper() _, err := store.Queries().GetUtxoIDByOutpoint( - t.Context(), sqlcpg.GetUtxoIDByOutpointParams{ + t.Context(), sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(walletID), TxHash: outPoint.Hash[:], OutputIndex: int32(outPoint.Index), diff --git a/wallet/internal/db/itest/tx_corruption_pg_test.go b/wallet/internal/db/itest/tx_corruption_pg_test.go index 4654452ec8..43884520b3 100644 --- a/wallet/internal/db/itest/tx_corruption_pg_test.go +++ b/wallet/internal/db/itest/tx_corruption_pg_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcwallet/wallet/internal/db" dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -20,7 +19,7 @@ import ( // corruptTransactionStatus writes an invalid tx status after dropping the // validating constraints that normally reject it. The corruption itests use // this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *dbpg.PostgresStore, +func corruptTransactionStatus(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -50,7 +49,7 @@ func corruptTransactionStatus(t *testing.T, store *dbpg.PostgresStore, // corruptTransactionHash writes malformed tx-hash bytes after dropping the // fixed-length hash check. The corruption itests then verify that hash // decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *dbpg.PostgresStore, +func corruptTransactionHash(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -76,7 +75,7 @@ func corruptTransactionHash(t *testing.T, store *dbpg.PostgresStore, // the non-negative height check and creating a matching block row. The // corruption itests use this to verify that reads reject impossible // confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *dbpg.PostgresStore, +func corruptTransactionBlockHeight(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -111,7 +110,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *dbpg.PostgresStore, // corruptUtxoOutputIndex writes an invalid output index after dropping the // non-negative output-index check. The corruption itests then verify that UTXO // decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *dbpg.PostgresStore, +func corruptUtxoOutputIndex(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -137,7 +136,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *dbpg.PostgresStore, // corruptActiveLeaseLockID writes an invalid lease lock ID after dropping the // fixed-length lock-id check. The corruption itests use this to verify that // lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *dbpg.PostgresStore, +func corruptActiveLeaseLockID(t *testing.T, store *dbpg.Store, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go index f25d28de23..d77a3eb7ee 100644 --- a/wallet/internal/db/itest/tx_store_corruption_test.go +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -22,7 +22,7 @@ func dropTableForCorruption(t *testing.T, store interface{ DB() *sql.DB }, t.Helper() stmt := fmt.Sprintf("DROP TABLE %s", table) - if _, ok := any(store).(*dbpg.PostgresStore); ok { + if _, ok := any(store).(*dbpg.Store); ok { stmt += " CASCADE" } @@ -492,7 +492,7 @@ func TestRollbackToBlockReturnsQueryErrorWhenBlocksTableMissing(t *testing.T) { // wallet_sync_states keeps direct block references with ON DELETE RESTRICT. // PostgreSQL drops those dependent rows with CASCADE when the blocks table is // removed, so rollback gets far enough to fail on the block delete instead. - _, ok := any(store).(*db.PostgresStore) + _, ok := any(store).(*dbpg.Store) if ok { require.ErrorContains(t, err, "delete blocks at or above height") return diff --git a/wallet/internal/db/itest/utxo_store_edge_cases_test.go b/wallet/internal/db/itest/utxo_store_edge_cases_test.go index 0d40418875..563a7c3910 100644 --- a/wallet/internal/db/itest/utxo_store_edge_cases_test.go +++ b/wallet/internal/db/itest/utxo_store_edge_cases_test.go @@ -258,7 +258,7 @@ func TestGetUtxoAndLeaseRejectLargeOutputIndex(t *testing.T) { }, ) - if _, ok := any(store).(*dbpg.PostgresStore); ok { + if _, ok := any(store).(*dbpg.Store); ok { require.ErrorContains(t, err, "convert output index") require.ErrorContains(t, leaseErr, "convert output index") require.ErrorContains(t, releaseErr, "could not cast") diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index cda543c54f..7e1503b789 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -4,27 +4,29 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -// Ensure PostgresStore satisfies the AccountStore interface. -var _ db.AccountStore = (*PostgresStore)(nil) +// Ensure Store satisfies the AccountStore interface. +var _ db.AccountStore = (*Store)(nil) // GetAccount retrieves information about a specific account, identified by its // name or account number within a given key scope. -func (s *PostgresStore) GetAccount(ctx context.Context, +func (s *Store) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { getQueries := accountGetQueries{q: s.queries} - return db.GetAccountByQuery(ctx, query, getQueries.byNumber, getQueries.byName) + return db.GetAccountByQuery( + ctx, query, getQueries.byNumber, getQueries.byName, + ) } // ListAccounts returns a slice of AccountInfo for all accounts, optionally // filtered by name or key scope. -func (s *PostgresStore) ListAccounts(ctx context.Context, +func (s *Store) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { listQueries := accountListQueries{q: s.queries} @@ -36,7 +38,7 @@ func (s *PostgresStore) ListAccounts(ctx context.Context, // RenameAccount changes the name of an account. The account can be identified // by its old name or its account number. -func (s *PostgresStore) RenameAccount(ctx context.Context, +func (s *Store) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { renameQueries := accountRenameQueries{q: s.queries} @@ -49,7 +51,7 @@ func (s *PostgresStore) RenameAccount(ctx context.Context, // CreateDerivedAccount creates a new derived account with the given name and // scope. If the key scope does not exist, it is created with NULL encrypted // keys using the address schema provided by the caller. -func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, +func (s *Store) CreateDerivedAccount(ctx context.Context, params db.CreateDerivedAccountParams) (*db.AccountInfo, error) { paramsErr := params.Validate() @@ -59,7 +61,7 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo - err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -78,7 +80,7 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, } row, err := qtx.CreateDerivedAccount( - ctx, sqlcpg.CreateDerivedAccountParams{ + ctx, sqlc.CreateDerivedAccountParams{ ScopeID: scopeID, AccountName: params.Name, OriginID: int16(db.DerivedAccount), @@ -118,12 +120,12 @@ func (s *PostgresStore) CreateDerivedAccount(ctx context.Context, // public key. If the key scope does not exist, it is created with NULL // encrypted keys using the address schema provided by the caller. Imported // accounts have NULL account_number since they don't follow BIP44 derivation. -func (s *PostgresStore) CreateImportedAccount(ctx context.Context, +func (s *Store) CreateImportedAccount(ctx context.Context, params db.CreateImportedAccountParams) (*db.AccountProperties, error) { var props *db.AccountProperties - err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { var err error props, err = db.CreateImportedAccount( @@ -131,7 +133,7 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, return ensureKeyScope(ctx, qtx, params.WalletID, params.Scope) }, qtx.CreateImportedAccount, buildCreateImportedAccountArgs(params), - func(row sqlcpg.CreateImportedAccountRow) int64 { return row.ID }, + func(row sqlc.CreateImportedAccountRow) int64 { return row.ID }, qtx.CreateAccountSecret, buildCreateAccountSecretArgs(params), func(accountID int64) (*db.AccountProperties, error) { return getAccountProps(ctx, qtx, accountID) @@ -151,12 +153,12 @@ func (s *PostgresStore) CreateImportedAccount(ctx context.Context, // CreateImportedAccountParams for PostgreSQL. func buildCreateImportedAccountArgs( params db.CreateImportedAccountParams, -) func(int64, bool) sqlcpg.CreateImportedAccountParams { +) func(int64, bool) sqlc.CreateImportedAccountParams { return func(scopeID int64, - isWatchOnly bool) sqlcpg.CreateImportedAccountParams { + isWatchOnly bool) sqlc.CreateImportedAccountParams { - return sqlcpg.CreateImportedAccountParams{ + return sqlc.CreateImportedAccountParams{ ScopeID: scopeID, AccountName: params.Name, OriginID: int16(db.ImportedAccount), @@ -174,10 +176,10 @@ func buildCreateImportedAccountArgs( // CreateAccountSecretParams for PostgreSQL. func buildCreateAccountSecretArgs( params db.CreateImportedAccountParams, -) func(int64) sqlcpg.CreateAccountSecretParams { +) func(int64) sqlc.CreateAccountSecretParams { - return func(accountID int64) sqlcpg.CreateAccountSecretParams { - return sqlcpg.CreateAccountSecretParams{ + return func(accountID int64) sqlc.CreateAccountSecretParams { + return sqlc.CreateAccountSecretParams{ AccountID: accountID, EncryptedPrivateKey: params.EncryptedPrivateKey, } @@ -186,7 +188,7 @@ func buildCreateAccountSecretArgs( // getAccountProps fetches full account properties from the database and // converts the row to AccountProperties. -func getAccountProps(ctx context.Context, qtx *sqlcpg.Queries, +func getAccountProps(ctx context.Context, qtx *sqlc.Queries, accountID int64) (*db.AccountProperties, error) { row, err := qtx.GetAccountPropsById(ctx, accountID) @@ -216,18 +218,18 @@ func getAccountProps(ctx context.Context, qtx *sqlcpg.Queries, // ensureKeyScope retrieves an existing key scope or creates it if missing for // PostgreSQL. It returns the scope ID once available. -func ensureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, +func ensureKeyScope(ctx context.Context, qtx *sqlc.Queries, walletID uint32, scope db.KeyScope) (int64, error) { return db.EnsureKeyScope( ctx, qtx.GetKeyScopeByWalletAndScope, - sqlcpg.GetKeyScopeByWalletAndScopeParams{ + sqlc.GetKeyScopeByWalletAndScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), }, qtx.CreateKeyScope, - func(addrSchema db.ScopeAddrSchema) sqlcpg.CreateKeyScopeParams { - return sqlcpg.CreateKeyScopeParams{ + func(addrSchema db.ScopeAddrSchema) sqlc.CreateKeyScopeParams { + return sqlc.CreateKeyScopeParams{ WalletID: int64(walletID), Purpose: int64(scope.Purpose), CoinType: int64(scope.Coin), @@ -240,7 +242,7 @@ func ensureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, ), } }, - func(row sqlcpg.KeyScope) int64 { + func(row sqlc.KeyScope) int64 { return row.ID }, scope, ) @@ -250,13 +252,13 @@ func ensureKeyScope(ctx context.Context, qtx *sqlcpg.Queries, walletID uint32, // that share the same field structure. This enables a single generic conversion // function to handle all account query result types. type accountInfoRow interface { - sqlcpg.GetAccountByScopeAndNameRow | - sqlcpg.GetAccountByScopeAndNumberRow | - sqlcpg.GetAccountByWalletScopeAndNameRow | - sqlcpg.GetAccountByWalletScopeAndNumberRow | - sqlcpg.ListAccountsByWalletRow | - sqlcpg.ListAccountsByWalletScopeRow | - sqlcpg.ListAccountsByWalletAndNameRow + sqlc.GetAccountByScopeAndNameRow | + sqlc.GetAccountByScopeAndNumberRow | + sqlc.GetAccountByWalletScopeAndNameRow | + sqlc.GetAccountByWalletScopeAndNumberRow | + sqlc.ListAccountsByWalletRow | + sqlc.ListAccountsByWalletScopeRow | + sqlc.ListAccountsByWalletAndNameRow } // accountRowToInfo converts a PostgreSQL account row to an AccountInfo @@ -265,7 +267,7 @@ type accountInfoRow interface { func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. - base := sqlcpg.GetAccountByScopeAndNameRow(row) + base := sqlc.GetAccountByScopeAndNameRow(row) return db.AccountRowToInfo(db.AccountInfoRow[int16]{ AccountNumber: base.AccountNumber, @@ -284,7 +286,7 @@ func accountRowToInfo[T accountInfoRow](row T) (*db.AccountInfo, error) { // accountListQueries groups PostgreSQL account listing query methods. type accountListQueries struct { - q *sqlcpg.Queries + q *sqlc.Queries } // byScope lists accounts filtered by wallet ID and key scope. @@ -293,7 +295,7 @@ func (p accountListQueries) byScope(ctx context.Context, return db.ListAccounts( ctx, p.q.ListAccountsByWalletScope, - sqlcpg.ListAccountsByWalletScopeParams{ + sqlc.ListAccountsByWalletScopeParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -307,7 +309,7 @@ func (p accountListQueries) byName(ctx context.Context, return db.ListAccounts( ctx, p.q.ListAccountsByWalletAndName, - sqlcpg.ListAccountsByWalletAndNameParams{ + sqlc.ListAccountsByWalletAndNameParams{ WalletID: int64(query.WalletID), AccountName: *query.Name, }, accountRowToInfo, @@ -326,7 +328,7 @@ func (p accountListQueries) all(ctx context.Context, // accountGetQueries groups PostgreSQL account retrieval query methods. type accountGetQueries struct { - q *sqlcpg.Queries + q *sqlc.Queries } // byNumber retrieves an account by wallet ID, scope, and account number. @@ -335,7 +337,7 @@ func (p accountGetQueries) byNumber(ctx context.Context, return db.GetAccount( ctx, p.q.GetAccountByWalletScopeAndNumber, - sqlcpg.GetAccountByWalletScopeAndNumberParams{ + sqlc.GetAccountByWalletScopeAndNumberParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -350,7 +352,7 @@ func (p accountGetQueries) byName(ctx context.Context, return db.GetAccount( ctx, p.q.GetAccountByWalletScopeAndName, - sqlcpg.GetAccountByWalletScopeAndNameParams{ + sqlc.GetAccountByWalletScopeAndNameParams{ WalletID: int64(query.WalletID), Purpose: int64(query.Scope.Purpose), CoinType: int64(query.Scope.Coin), @@ -361,7 +363,7 @@ func (p accountGetQueries) byName(ctx context.Context, // accountRenameQueries groups PostgreSQL account rename query methods. type accountRenameQueries struct { - q *sqlcpg.Queries + q *sqlc.Queries } // byNumber renames an account identified by wallet ID, scope, and account @@ -371,7 +373,7 @@ func (p accountRenameQueries) byNumber(ctx context.Context, return db.RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndNumber, - sqlcpg.UpdateAccountNameByWalletScopeAndNumberParams{ + sqlc.UpdateAccountNameByWalletScopeAndNumberParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), @@ -388,7 +390,7 @@ func (p accountRenameQueries) byName(ctx context.Context, return db.RenameAccount( ctx, p.q.UpdateAccountNameByWalletScopeAndName, - sqlcpg.UpdateAccountNameByWalletScopeAndNameParams{ + sqlc.UpdateAccountNameByWalletScopeAndNameParams{ NewName: params.NewName, WalletID: int64(params.WalletID), Purpose: int64(params.Scope.Purpose), diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index 85346c1f35..fbbc06f9bf 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -2,14 +2,14 @@ package pg import ( "context" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // addressTypeRowToInfo converts a PostgreSQL address type row to an // AddressTypeInfo struct. -func addressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { +func addressTypeRowToInfo(row sqlc.AddressType) (db.AddressTypeInfo, error) { addrType, err := db.IDToAddressType(row.ID) if err != nil { return db.AddressTypeInfo{}, err @@ -23,7 +23,7 @@ func addressTypeRowToInfo(row sqlcpg.AddressType) (db.AddressTypeInfo, error) { // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. -func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( +func (s *Store) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { return db.ListAddressTypes( @@ -33,7 +33,7 @@ func (s *PostgresStore) ListAddressTypes(ctx context.Context) ( // GetAddressType returns the AddressTypeInfo associated with the given address // type identifier. An error is returned if the type is unknown. -func (s *PostgresStore) GetAddressType(ctx context.Context, +func (s *Store) GetAddressType(ctx context.Context, id db.AddressType) (db.AddressTypeInfo, error) { return db.GetAddressTypeByID( diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 2e9b13b736..b21b9fe96c 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -9,22 +9,22 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -var _ db.AddressStore = (*PostgresStore)(nil) +var _ db.AddressStore = (*Store)(nil) // GetAddress retrieves information about a specific address, identified by // its script pubkey. -func (s *PostgresStore) GetAddress(ctx context.Context, +func (s *Store) GetAddress(ctx context.Context, query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, q db.GetAddressQuery) (*db.AddressInfo, - error) { + getByScript := func(ctx context.Context, + q db.GetAddressQuery) (*db.AddressInfo, error) { return db.GetAddress( ctx, s.queries.GetAddressByScriptPubKey, - sqlcpg.GetAddressByScriptPubKeyParams{ + sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: q.ScriptPubKey, WalletID: int64(q.WalletID), }, addressRowToInfo, @@ -35,7 +35,7 @@ func (s *PostgresStore) GetAddress(ctx context.Context, } // ListAddresses returns a page of addresses matching the given query. -func (s *PostgresStore) ListAddresses(ctx context.Context, +func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { items, err := listAddressesByAccount(ctx, s.queries, query) @@ -54,7 +54,7 @@ func (s *PostgresStore) ListAddresses(ctx context.Context, } // IterAddresses returns an iterator over paginated address results. -func (s *PostgresStore) IterAddresses(ctx context.Context, +func (s *Store) IterAddresses(ctx context.Context, query db.ListAddressesQuery) iter.Seq2[db.AddressInfo, error] { return page.Iter( @@ -63,7 +63,7 @@ func (s *PostgresStore) IterAddresses(ctx context.Context, } // GetAddressSecret retrieves the encrypted secret information for an address. -func (s *PostgresStore) GetAddressSecret(ctx context.Context, +func (s *Store) GetAddressSecret(ctx context.Context, addressID uint32) (*db.AddressSecret, error) { return db.GetAddressSecret( @@ -73,15 +73,15 @@ func (s *PostgresStore) GetAddressSecret(ctx context.Context, // NewDerivedAddress creates a new address for a given account and key // scope. -func (s *PostgresStore) NewDerivedAddress(ctx context.Context, +func (s *Store) NewDerivedAddress(ctx context.Context, params db.NewDerivedAddressParams, deriveFn db.AddressDerivationFunc) (*db.AddressInfo, error) { adapters := db.DerivedAddressAdapters[ - *sqlcpg.Queries, - sqlcpg.GetAccountByWalletScopeAndNameRow, + *sqlc.Queries, + sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, - sqlcpg.CreateDerivedAddressRow]{ + sqlc.CreateDerivedAddressRow]{ GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromParams, GetAccountID: derivedAddressGetAccountID, @@ -92,20 +92,22 @@ func (s *PostgresStore) NewDerivedAddress(ctx context.Context, RowCreatedAt: derivedAddressRowCreatedAt, } - return db.NewDerivedAddressWithTx(ctx, params, s.ExecuteTx, adapters, deriveFn) + return db.NewDerivedAddressWithTx( + ctx, params, s.ExecuteTx, adapters, deriveFn, + ) } // NewImportedAddress imports a new address, script, or private key. -func (s *PostgresStore) NewImportedAddress(ctx context.Context, +func (s *Store) NewImportedAddress(ctx context.Context, params db.NewImportedAddressParams) (*db.AddressInfo, error) { adapters := db.ImportedAddressAdapters[ - *sqlcpg.Queries, - sqlcpg.GetAccountByWalletScopeAndNameRow, + *sqlc.Queries, + sqlc.GetAccountByWalletScopeAndNameRow, db.AccountLookupKey, - sqlcpg.CreateImportedAddressParams, - sqlcpg.CreateImportedAddressRow, - sqlcpg.InsertAddressSecretParams]{ + sqlc.CreateImportedAddressParams, + sqlc.CreateImportedAddressRow, + sqlc.InsertAddressSecretParams]{ GetAccount: getAccountFromKey(s.queries), AccountParams: db.AccountKeyFromImportedParams, GetAccountID: importedAddressGetAccountID, @@ -121,15 +123,15 @@ func (s *PostgresStore) NewImportedAddress(ctx context.Context, } // getAccountFromKey returns a helper to look up accounts by key. -func getAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, - db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, error) { +func getAccountFromKey(qtx *sqlc.Queries) func(context.Context, + db.AccountLookupKey) (sqlc.GetAccountByWalletScopeAndNameRow, error) { return func(ctx context.Context, - key db.AccountLookupKey) (sqlcpg.GetAccountByWalletScopeAndNameRow, + key db.AccountLookupKey) (sqlc.GetAccountByWalletScopeAndNameRow, error) { return qtx.GetAccountByWalletScopeAndName( - ctx, sqlcpg.GetAccountByWalletScopeAndNameParams{ + ctx, sqlc.GetAccountByWalletScopeAndNameParams{ WalletID: key.WalletID, Purpose: key.Purpose, CoinType: key.CoinType, @@ -141,43 +143,43 @@ func getAccountFromKey(qtx *sqlcpg.Queries) func(context.Context, // derivedAddressGetAccountID extracts the account ID from a row. func derivedAddressGetAccountID( - row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { + row sqlc.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } // derivedAddressGetExtIndex returns the external index query. -func derivedAddressGetExtIndex(qtx *sqlcpg.Queries) func(context.Context, +func derivedAddressGetExtIndex(qtx *sqlc.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextExternalIndex } // derivedAddressGetIntIndex returns the internal index query. -func derivedAddressGetIntIndex(qtx *sqlcpg.Queries) func(context.Context, +func derivedAddressGetIntIndex(qtx *sqlc.Queries) func(context.Context, int64) (int64, error) { return qtx.GetAndIncrementNextInternalIndex } // derivedAddressCreateAddr returns the derived address insert helper. -func derivedAddressCreateAddr(qtx *sqlcpg.Queries) func(context.Context, - int64, db.AddressType, uint32, uint32, []byte) (sqlcpg.CreateDerivedAddressRow, - error) { +func derivedAddressCreateAddr(qtx *sqlc.Queries) func( + context.Context, int64, db.AddressType, uint32, uint32, []byte, +) (sqlc.CreateDerivedAddressRow, error) { return func(ctx context.Context, accountID int64, addrType db.AddressType, branch uint32, index uint32, - scriptPubKey []byte) (sqlcpg.CreateDerivedAddressRow, error) { + scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { branchNum, err := db.Uint32ToInt16(branch) if err != nil { - return sqlcpg.CreateDerivedAddressRow{}, fmt.Errorf( + return sqlc.CreateDerivedAddressRow{}, fmt.Errorf( "address branch: %w", err, ) } return qtx.CreateDerivedAddress( - ctx, sqlcpg.CreateDerivedAddressParams{ + ctx, sqlc.CreateDerivedAddressParams{ AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int16(addrType), @@ -196,44 +198,44 @@ func derivedAddressCreateAddr(qtx *sqlcpg.Queries) func(context.Context, } // derivedAddressRowID returns the created address ID. -func derivedAddressRowID(row sqlcpg.CreateDerivedAddressRow) int64 { +func derivedAddressRowID(row sqlc.CreateDerivedAddressRow) int64 { return row.ID } // derivedAddressRowCreatedAt returns the CreatedAt timestamp. func derivedAddressRowCreatedAt( - row sqlcpg.CreateDerivedAddressRow) time.Time { + row sqlc.CreateDerivedAddressRow) time.Time { return row.CreatedAt } // importedAddressGetAccountID extracts the account ID from a row. func importedAddressGetAccountID( - row sqlcpg.GetAccountByWalletScopeAndNameRow) int64 { + row sqlc.GetAccountByWalletScopeAndNameRow) int64 { return row.ID } // createImportedAddress returns the imported address insert helper. -func createImportedAddress(qtx *sqlcpg.Queries) func(context.Context, - sqlcpg.CreateImportedAddressParams) (sqlcpg.CreateImportedAddressRow, +func createImportedAddress(qtx *sqlc.Queries) func(context.Context, + sqlc.CreateImportedAddressParams) (sqlc.CreateImportedAddressRow, error) { return qtx.CreateImportedAddress } // insertAddressSecret returns the secret insert helper. -func insertAddressSecret(qtx *sqlcpg.Queries) func(context.Context, - sqlcpg.InsertAddressSecretParams) error { +func insertAddressSecret(qtx *sqlc.Queries) func(context.Context, + sqlc.InsertAddressSecretParams) error { return qtx.InsertAddressSecret } // createImportedAddressParams maps imported params to sqlc params. func createImportedAddressParams(accountID int64, - params db.NewImportedAddressParams) sqlcpg.CreateImportedAddressParams { + params db.NewImportedAddressParams) sqlc.CreateImportedAddressParams { - return sqlcpg.CreateImportedAddressParams{ + return sqlc.CreateImportedAddressParams{ AccountID: accountID, ScriptPubKey: params.ScriptPubKey, TypeID: int16(params.AddressType), @@ -243,9 +245,9 @@ func createImportedAddressParams(accountID int64, // insertAddressSecretParams maps imported params to secret params. func insertAddressSecretParams(addressID int64, - params db.NewImportedAddressParams) sqlcpg.InsertAddressSecretParams { + params db.NewImportedAddressParams) sqlc.InsertAddressSecretParams { - return sqlcpg.InsertAddressSecretParams{ + return sqlc.InsertAddressSecretParams{ AddressID: addressID, EncryptedPrivKey: params.EncryptedPrivateKey, EncryptedScript: params.EncryptedScript, @@ -253,13 +255,13 @@ func insertAddressSecretParams(addressID int64, } // importedAddressRowID returns the created address ID. -func importedAddressRowID(row sqlcpg.CreateImportedAddressRow) int64 { +func importedAddressRowID(row sqlc.CreateImportedAddressRow) int64 { return row.ID } // importedAddressRowCreatedAt returns the CreatedAt timestamp. func importedAddressRowCreatedAt( - row sqlcpg.CreateImportedAddressRow) time.Time { + row sqlc.CreateImportedAddressRow) time.Time { return row.CreatedAt } @@ -267,7 +269,7 @@ func importedAddressRowCreatedAt( // addressSecretRowToSecret converts a PostgreSQL address secret row to an // AddressSecret struct. func addressSecretRowToSecret( - row sqlcpg.GetAddressSecretRow) (*db.AddressSecret, error) { + row sqlc.GetAddressSecretRow) (*db.AddressSecret, error) { return db.AddressSecretRowToSecret(db.AddressSecretRow{ AddressID: row.AddressID, @@ -280,8 +282,8 @@ func addressSecretRowToSecret( // row types that share the same field structure. This enables a single // generic conversion function to handle all address query result types. type addressInfoRow interface { - sqlcpg.GetAddressByScriptPubKeyRow | - sqlcpg.ListAddressesByAccountRow + sqlc.GetAddressByScriptPubKeyRow | + sqlc.ListAddressesByAccountRow } // addressRowToInfo converts a PostgreSQL address row to an AddressInfo @@ -289,7 +291,7 @@ type addressInfoRow interface { func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // Direct conversion works only because all constraint types have // identical fields. If sqlc types diverge, compilation will fail. - base := sqlcpg.GetAddressByScriptPubKeyRow(row) + base := sqlc.GetAddressByScriptPubKeyRow(row) info, err := db.AddressRowToInfo(db.AddressInfoRow[int16, int16]{ ID: base.ID, @@ -318,7 +320,7 @@ func addressRowToInfo[T addressInfoRow](row T) (*db.AddressInfo, error) { // listAddressesByAccount lists addresses filtered by wallet ID, key scope, // and account name, with pagination support. -func listAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, +func listAddressesByAccount(ctx context.Context, q *sqlc.Queries, query db.ListAddressesQuery) ([]db.AddressInfo, error) { rows, err := q.ListAddressesByAccount( @@ -346,9 +348,9 @@ func listAddressesByAccount(ctx context.Context, q *sqlcpg.Queries, // buildAddressPageParams translates a ListAddresses query to // ListAddressesByAccount parameters, handling pagination cursors. func buildAddressPageParams( - q db.ListAddressesQuery) sqlcpg.ListAddressesByAccountParams { + q db.ListAddressesQuery) sqlc.ListAddressesByAccountParams { - params := sqlcpg.ListAddressesByAccountParams{ + params := sqlc.ListAddressesByAccountParams{ WalletID: int64(q.WalletID), Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), diff --git a/wallet/internal/db/pg/backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go index 4bc4c373a0..d2e9066d5e 100644 --- a/wallet/internal/db/pg/backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -4,13 +4,13 @@ import ( "context" "database/sql" "errors" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "testing" "time" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) @@ -56,7 +56,7 @@ func (e errorDBTX) QueryRowContext(context.Context, string, func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { t.Parallel() - qtx := sqlcpg.New(errorDBTX{ + qtx := sqlc.New(errorDBTX{ execErr: errDummy, queryErr: errDummy, }) @@ -90,7 +90,7 @@ func TestPgDeleteAndRollbackOpsWrapBackendErrors(t *testing.T) { func TestPgTxStoreOpsWrapBackendErrors(t *testing.T) { t.Parallel() - qtx := sqlcpg.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) + qtx := sqlc.New(errorDBTX{execErr: errDummy, queryErr: errDummy}) createOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{qtx: qtx}, } @@ -208,7 +208,7 @@ func TestPgBackendHelpersRejectOverflow(t *testing.T) { ) require.ErrorContains(t, err, "convert rollback height") - _, _, err = buildConflictRoots([]sqlcpg.ListUnminedTransactionsRow{{ + _, _, err = buildConflictRoots([]sqlc.ListUnminedTransactionsRow{{ ID: 1, TxHash: []byte{1}, TxStatus: 0, diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index 2f17fceb61..f36e764a1c 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -3,12 +3,12 @@ package pg import ( "context" "database/sql" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "testing" "time" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) @@ -94,7 +94,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() loadOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcpg.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), }), }, @@ -110,7 +110,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { } confirmOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcpg.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newSQLiteRow( t, "SELECT ?, ?, ?", int64(block.Height), block.Hash[:], @@ -128,7 +128,7 @@ func TestPgCreateTxOpsAdditionalBranches(t *testing.T) { conflictOps := &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ - qtx: sqlcpg.New(rowDBTX{ + qtx: sqlc.New(rowDBTX{ row: newSQLiteRow(t, "SELECT ?", int64(5)), queryErr: errDummy, }), @@ -146,15 +146,15 @@ func TestPgUpdateTxOpsAdditionalBranches(t *testing.T) { ctx := context.Background() txHash := chainhash.Hash{9} - loadOps := &updateTxOps{qtx: sqlcpg.New(rowDBTX{ + loadOps := &updateTxOps{qtx: sqlc.New(rowDBTX{ row: newSQLiteRow(t, "SELECT 1 FROM missing_table"), })} stateOps := &updateTxOps{ - qtx: sqlcpg.New(rowDBTX{rows: 0}), + qtx: sqlc.New(rowDBTX{rows: 0}), blockHeight: sql.NullInt32{}, status: int16(db.TxStatusPublished), } - labelOps := &updateTxOps{qtx: sqlcpg.New(rowDBTX{rows: 0})} + labelOps := &updateTxOps{qtx: sqlc.New(rowDBTX{rows: 0})} _, err := loadOps.LoadIsCoinbase(ctx, 1, txHash) require.ErrorContains(t, err, "get tx metadata") diff --git a/wallet/internal/db/pg/block.go b/wallet/internal/db/pg/block.go index 16d29c6476..8f906eb2f7 100644 --- a/wallet/internal/db/pg/block.go +++ b/wallet/internal/db/pg/block.go @@ -6,9 +6,9 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // buildBlock constructs a Block from the given PostgreSQL block @@ -25,7 +25,7 @@ func buildBlock(height sql.NullInt32, hash []byte, } // ensureBlockExists ensures that a block exists in the database. -func ensureBlockExists(ctx context.Context, qtx *sqlcpg.Queries, +func ensureBlockExists(ctx context.Context, qtx *sqlc.Queries, block *db.Block) error { height, err := db.Uint32ToInt32(block.Height) @@ -33,7 +33,7 @@ func ensureBlockExists(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("convert block height: %w", err) } - blockParams := sqlcpg.InsertBlockParams{ + blockParams := sqlc.InsertBlockParams{ BlockHeight: height, HeaderHash: block.Hash[:], BlockTimestamp: block.Timestamp.Unix(), @@ -49,7 +49,7 @@ func ensureBlockExists(ctx context.Context, qtx *sqlcpg.Queries, // requireBlockMatches loads the shared block row for the provided height and // verifies that its stored metadata matches the supplied block reference. -func requireBlockMatches(ctx context.Context, qtx *sqlcpg.Queries, +func requireBlockMatches(ctx context.Context, qtx *sqlc.Queries, block *db.Block) (int32, error) { height, err := db.Uint32ToInt32(block.Height) diff --git a/wallet/internal/db/pg/config.go b/wallet/internal/db/pg/config.go new file mode 100644 index 0000000000..3046c040c6 --- /dev/null +++ b/wallet/internal/db/pg/config.go @@ -0,0 +1,36 @@ +package pg + +import ( + "fmt" + + db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/jackc/pgx/v5" +) + +// Config holds the configuration for the PostgreSQL database. +type Config struct { + // Dsn is the database connection string. + Dsn string + + // MaxConnections is the maximum number of open connections to the + // database. Set to zero to use db.DefaultMaxConnections. + MaxConnections int +} + +// Validate checks that the Config values are valid. +func (c *Config) Validate() error { + if c.Dsn == "" { + return db.ErrEmptyDSN + } + + _, err := pgx.ParseConfig(c.Dsn) + if err != nil { + return fmt.Errorf("invalid DSN: %w", err) + } + + if c.MaxConnections < 0 { + return db.ErrNegativeMaxConns + } + + return nil +} diff --git a/wallet/internal/db/config_test.go b/wallet/internal/db/pg/config_test.go similarity index 70% rename from wallet/internal/db/config_test.go rename to wallet/internal/db/pg/config_test.go index 5981fba9a1..1cf5fdcf93 100644 --- a/wallet/internal/db/config_test.go +++ b/wallet/internal/db/pg/config_test.go @@ -1,29 +1,30 @@ -package db +package pg import ( "testing" + db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) -// TestPostgresConfigValidateSuccess tests valid PostgresConfig scenarios. -func TestPostgresConfigValidateSuccess(t *testing.T) { +// TestConfigValidateSuccess tests valid Config scenarios. +func TestConfigValidateSuccess(t *testing.T) { t.Parallel() tests := []struct { name string - config PostgresConfig + config Config }{ { name: "valid config with all fields set", - config: PostgresConfig{ + config: Config{ Dsn: "postgres://user:pass@localhost/db", MaxConnections: 25, }, }, { name: "valid config with zero max connections", - config: PostgresConfig{ + config: Config{ Dsn: "postgres://localhost/db", MaxConnections: 0, }, @@ -40,27 +41,27 @@ func TestPostgresConfigValidateSuccess(t *testing.T) { } } -// TestPostgresConfigValidateErrors tests PostgresConfig validation errors. -func TestPostgresConfigValidateErrors(t *testing.T) { +// TestConfigValidateErrors tests Config validation errors. +func TestConfigValidateErrors(t *testing.T) { t.Parallel() tests := []struct { name string - config PostgresConfig + config Config expectedErr error expectAnyError bool }{ { name: "empty DSN", - config: PostgresConfig{ + config: Config{ Dsn: "", MaxConnections: 10, }, - expectedErr: ErrEmptyDSN, + expectedErr: db.ErrEmptyDSN, }, { name: "invalid DSN format", - config: PostgresConfig{ + config: Config{ Dsn: "://invalid", MaxConnections: 10, }, @@ -68,11 +69,11 @@ func TestPostgresConfigValidateErrors(t *testing.T) { }, { name: "negative max connections", - config: PostgresConfig{ + config: Config{ Dsn: "postgres://localhost/db", MaxConnections: -5, }, - expectedErr: ErrNegativeMaxConns, + expectedErr: db.ErrNegativeMaxConns, }, } diff --git a/wallet/internal/db/pg/doc.go b/wallet/internal/db/pg/doc.go new file mode 100644 index 0000000000..9f65a8dc42 --- /dev/null +++ b/wallet/internal/db/pg/doc.go @@ -0,0 +1,2 @@ +// Package pg implements the PostgreSQL wallet store backend. +package pg diff --git a/wallet/internal/db/pg/itest.go b/wallet/internal/db/pg/itest.go index f9f9b1a441..2e66940b9b 100644 --- a/wallet/internal/db/pg/itest.go +++ b/wallet/internal/db/pg/itest.go @@ -1,20 +1,19 @@ //go:build itest -package db +package pg import ( "database/sql" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. -func (s *PostgresStore) DB() *sql.DB { +func (s *Store) DB() *sql.DB { return s.db } // Queries returns the underlying sqlc queries for integration testing. -func (s *PostgresStore) Queries() *sqlcpg.Queries { +func (s *Store) Queries() *sqlc.Queries { return s.queries } diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index 7f0e131d73..2b7e4c3a0a 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -7,22 +7,21 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" - - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) -// PostgresStore is the PostgreSQL implementation of the +// Store is the PostgreSQL implementation of the // WalletStore interface. -type PostgresStore struct { +type Store struct { db *sql.DB - queries *sqlcpg.Queries + queries *sqlc.Queries } -// NewPostgresStore creates a new PostgreSQL-based WalletStore. It handles +// NewStore creates a new PostgreSQL-based WalletStore. It handles // the full connection setup including config validation, connection opening, // health checks, connection pool configuration, and migration application. -func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStore, +func NewStore(ctx context.Context, cfg Config) (*Store, error) { err := cfg.Validate() @@ -53,7 +52,7 @@ func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStor dbConn.SetMaxIdleConns(maxConns) dbConn.SetConnMaxIdleTime(db.DefaultConnIdleLifetime) - queries := sqlcpg.New(dbConn) + queries := sqlc.New(dbConn) err = sqlassetpg.ApplyMigrations(dbConn) if err != nil { @@ -61,14 +60,14 @@ func NewPostgresStore(ctx context.Context, cfg db.PostgresConfig) (*PostgresStor return nil, fmt.Errorf("apply migrations: %w", err) } - return &PostgresStore{ + return &Store{ db: dbConn, queries: queries, }, nil } // Close closes the database connection. -func (s *PostgresStore) Close() error { +func (s *Store) Close() error { err := s.db.Close() if err != nil { return fmt.Errorf("close database: %w", err) @@ -81,8 +80,8 @@ func (s *PostgresStore) Close() error { // receives a transactional query executor and should perform all database // operations using it. The transaction will be automatically committed on // success or rolled back on error. -func (s *PostgresStore) ExecuteTx(ctx context.Context, - fn func(*sqlcpg.Queries) error) error { +func (s *Store) ExecuteTx(ctx context.Context, + fn func(*sqlc.Queries) error) error { return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { qtx := s.queries.WithTx(tx) diff --git a/wallet/internal/db/pg/testhelpers_test.go b/wallet/internal/db/pg/testhelpers_test.go index 7763707193..743698f5d1 100644 --- a/wallet/internal/db/pg/testhelpers_test.go +++ b/wallet/internal/db/pg/testhelpers_test.go @@ -11,15 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -// testBlock builds one deterministic test block. -func testBlock(height uint32) *db.Block { - return &db.Block{ - Hash: chainhash.Hash{byte(height), 1, 2, 3}, - Height: height, - Timestamp: time.Unix(int64(height), 0), - } -} - // testRegularMsgTx builds one simple non-coinbase transaction fixture. func testRegularMsgTx() *wire.MsgTx { return &wire.MsgTx{ diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index 83e08cd100..687060f16c 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -5,11 +5,11 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its @@ -21,7 +21,7 @@ import ( // Insert. When the wallet already stores the same unmined transaction hash, // CreateTx may promote that existing row to confirmed state instead of // inserting a duplicate. -func (s *PostgresStore) CreateTx(ctx context.Context, +func (s *Store) CreateTx(ctx context.Context, params db.CreateTxParams) error { req, err := db.NewCreateTxRequest(params) @@ -29,7 +29,7 @@ func (s *PostgresStore) CreateTx(ctx context.Context, return err } - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.CreateTxWithOps(ctx, req, &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, @@ -53,7 +53,7 @@ func (o *createTxOps) LoadExisting(ctx context.Context, meta, err := o.qtx.GetTransactionMetaByHash( ctx, - sqlcpg.GetTransactionMetaByHashParams{ + sqlc.GetTransactionMetaByHashParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], }, @@ -90,7 +90,7 @@ func (o *createTxOps) ConfirmExisting(ctx context.Context, } rows, err := o.qtx.UpdateTransactionStateByHash( - ctx, sqlcpg.UpdateTransactionStateByHashParams{ + ctx, sqlc.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{Int32: blockHeight, Valid: true}, Status: int16(db.TxStatusPublished), WalletID: int64(req.Params.WalletID), @@ -153,7 +153,7 @@ func (o *createTxOps) ListConflictTxns(ctx context.Context, // collectConflictRootIDs returns the active unmined spender row IDs // that currently own any wallet-controlled input spent by the incoming tx. -func collectConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, +func collectConflictRootIDs(ctx context.Context, qtx *sqlc.Queries, req db.CreateTxRequest) (map[int64]struct{}, error) { if blockchain.IsCoinBaseTx(req.Params.Tx) { @@ -169,7 +169,7 @@ func collectConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, } spentByTxID, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(req.Params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: outputIndex, @@ -196,7 +196,7 @@ func collectConflictRootIDs(ctx context.Context, qtx *sqlcpg.Queries, // buildConflictRoots maps the selected unmined rows into ordered root IDs and // the matching root hashes used for descendant discovery. -func buildConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, +func buildConflictRoots(rows []sqlc.ListUnminedTransactionsRow, rootIDSet map[int64]struct{}) ( []int64, []chainhash.Hash, error) { @@ -224,7 +224,7 @@ func buildConflictRoots(rows []sqlcpg.ListUnminedTransactionsRow, func (o *createTxOps) Insert(ctx context.Context, req db.CreateTxRequest) (int64, error) { - txID, err := o.qtx.InsertTransaction(ctx, sqlcpg.InsertTransactionParams{ + txID, err := o.qtx.InsertTransaction(ctx, sqlc.InsertTransactionParams{ WalletID: int64(req.Params.WalletID), TxHash: req.TxHash[:], RawTx: req.RawTx, @@ -261,7 +261,7 @@ func (o *createTxOps) MarkTxnsReplaced( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int16(db.TxStatusReplaced), TxIds: txIDs, @@ -282,7 +282,7 @@ func (o *createTxOps) InsertReplacementEdges( for _, replacedTxID := range replacedTxIDs { _, err := o.qtx.InsertTxReplacementEdge( - ctx, sqlcpg.InsertTxReplacementEdgeParams{ + ctx, sqlc.InsertTxReplacementEdgeParams{ WalletID: walletID, ReplacedTxID: replacedTxID, ReplacementTxID: replacementTxID, @@ -299,7 +299,7 @@ func (o *createTxOps) InsertReplacementEdges( // insertCredits inserts one wallet-owned UTXO row for each credited output of // the transaction being stored. -func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, +func insertCredits(ctx context.Context, qtx *sqlc.Queries, params db.CreateTxParams, txID int64) error { for index := range params.Credits { @@ -317,7 +317,7 @@ func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, pkScript := params.Tx.TxOut[index].PkScript addrRow, err := qtx.GetAddressByScriptPubKey( - ctx, sqlcpg.GetAddressByScriptPubKeyParams{ + ctx, sqlc.GetAddressByScriptPubKeyParams{ ScriptPubKey: pkScript, WalletID: int64(params.WalletID), }, @@ -336,7 +336,7 @@ func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("convert credit index %d: %w", index, err) } - _, err = qtx.InsertUtxo(ctx, sqlcpg.InsertUtxoParams{ + _, err = qtx.InsertUtxo(ctx, sqlc.InsertUtxoParams{ WalletID: int64(params.WalletID), TxID: txID, OutputIndex: outputIndex, @@ -353,7 +353,7 @@ func insertCredits(ctx context.Context, qtx *sqlcpg.Queries, // creditExists reports whether the wallet already has a UTXO row for the // given credited output, even if that output is now spent by a child tx. -func creditExists(ctx context.Context, qtx *sqlcpg.Queries, +func creditExists(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex uint32) (bool, error) { convertedIndex, err := db.Uint32ToInt32(outputIndex) @@ -363,7 +363,7 @@ func creditExists(ctx context.Context, qtx *sqlcpg.Queries, } _, err = qtx.GetUtxoSpendByOutpoint( - ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: convertedIndex, @@ -389,7 +389,7 @@ func creditExists(ctx context.Context, qtx *sqlcpg.Queries, // instead of silently storing a second spender. Inputs that reference a // wallet-owned output whose parent transaction is already invalid fail with // ErrTxInputInvalidParent. -func markInputsSpent(ctx context.Context, qtx *sqlcpg.Queries, +func markInputsSpent(ctx context.Context, qtx *sqlc.Queries, params db.CreateTxParams, txID int64) error { if blockchain.IsCoinBaseTx(params.Tx) { @@ -408,7 +408,7 @@ func markInputsSpent(ctx context.Context, qtx *sqlcpg.Queries, return fmt.Errorf("convert input index %d: %w", inputIndex, err) } - rowsAffected, err := qtx.MarkUtxoSpent(ctx, sqlcpg.MarkUtxoSpentParams{ + rowsAffected, err := qtx.MarkUtxoSpent(ctx, sqlc.MarkUtxoSpentParams{ WalletID: int64(params.WalletID), TxHash: txIn.PreviousOutPoint.Hash[:], OutputIndex: outputIndex, @@ -437,12 +437,12 @@ func markInputsSpent(ctx context.Context, qtx *sqlcpg.Queries, // is wallet-owned, still eligible for spending, and already attached to another // transaction. If the wallet owns the parent output but that parent is already // invalid, the helper returns ErrTxInputInvalidParent instead. -func ensureSpendConflict(ctx context.Context, qtx *sqlcpg.Queries, +func ensureSpendConflict(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int32, txID int64) error { spendByTxID, err := qtx.GetUtxoSpendByOutpoint( - ctx, sqlcpg.GetUtxoSpendByOutpointParams{ + ctx, sqlc.GetUtxoSpendByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: outputIndex, @@ -467,11 +467,11 @@ func ensureSpendConflict(ctx context.Context, qtx *sqlcpg.Queries, // ensureWalletParentValid reports ErrTxInputInvalidParent when the wallet // owns the referenced outpoint but its parent transaction is already invalid. -func ensureWalletParentValid(ctx context.Context, qtx *sqlcpg.Queries, +func ensureWalletParentValid(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, outputIndex int32) error { hasInvalid, err := qtx.HasInvalidWalletUtxoByOutpoint( - ctx, sqlcpg.HasInvalidWalletUtxoByOutpointParams{ + ctx, sqlc.HasInvalidWalletUtxoByOutpointParams{ WalletID: int64(walletID), TxHash: txHash[:], OutputIndex: outputIndex, diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index 25fefff1a8..d37ed033aa 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet @@ -18,17 +18,17 @@ import ( // terminal invalid-history rows remain part of the wallet timeline. The // transaction must also be a leaf among the wallet's unmined transactions so // the delete cannot detach child spenders from their parent history. -func (s *PostgresStore) DeleteTx(ctx context.Context, +func (s *Store) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } // deleteTxOps adapts postgres sqlc queries to the shared DeleteTx flow. type deleteTxOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.DeleteTxOps = (*deleteTxOps)(nil) @@ -61,7 +61,7 @@ func (o deleteTxOps) ClearSpentUtxos(ctx context.Context, walletID uint32, _, err := o.qtx.ClearUtxosSpentByTxID( ctx, - sqlcpg.ClearUtxosSpentByTxIDParams{ + sqlc.ClearUtxosSpentByTxIDParams{ WalletID: int64(walletID), SpentByTxID: sql.NullInt64{Int64: txID, Valid: true}, }, @@ -80,7 +80,7 @@ func (o deleteTxOps) DeleteCreatedUtxos(ctx context.Context, _, err := o.qtx.DeleteUtxosByTxID( ctx, - sqlcpg.DeleteUtxosByTxIDParams{ + sqlc.DeleteUtxosByTxIDParams{ WalletID: int64(walletID), TxID: txID, }, @@ -99,7 +99,7 @@ func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, rows, err := o.qtx.DeleteUnminedTransactionByHash( ctx, - sqlcpg.DeleteUnminedTransactionByHashParams{ + sqlc.DeleteUnminedTransactionByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -114,7 +114,7 @@ func (o deleteTxOps) DeleteUnminedTransaction(ctx context.Context, // ensureDeleteLeaf rejects DeleteTx requests for transactions that still have // direct unmined child spenders, including children that spend non-credit // parent outputs. -func ensureDeleteLeaf(ctx context.Context, qtx *sqlcpg.Queries, +func ensureDeleteLeaf(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash, txID int64) error { rows, err := qtx.ListUnminedTransactions(ctx, int64(walletID)) @@ -123,7 +123,7 @@ func ensureDeleteLeaf(ctx context.Context, qtx *sqlcpg.Queries, } candidates, err := db.BuildUnminedTxRecords(rows, - func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + func(row sqlc.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) @@ -150,33 +150,33 @@ func ensureDeleteLeaf(ctx context.Context, qtx *sqlcpg.Queries, // getDeleteTxMeta loads the transaction metadata DeleteTx needs and enforces // the unmined precondition up front. -func getDeleteTxMeta(ctx context.Context, qtx *sqlcpg.Queries, +func getDeleteTxMeta(ctx context.Context, qtx *sqlc.Queries, walletID uint32, txHash chainhash.Hash) ( - sqlcpg.GetTransactionMetaByHashRow, error) { + sqlc.GetTransactionMetaByHashRow, error) { meta, err := qtx.GetTransactionMetaByHash( - ctx, sqlcpg.GetTransactionMetaByHashParams{ + ctx, sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return sqlcpg.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("tx %s: %w", txHash, db.ErrTxNotFound) } - return sqlcpg.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("get tx metadata: %w", err) } status, err := db.ParseTxStatus(int64(meta.TxStatus)) if err != nil { - return sqlcpg.GetTransactionMetaByHashRow{}, err + return sqlc.GetTransactionMetaByHashRow{}, err } if meta.BlockHeight.Valid || !db.IsUnminedStatus(status) { - return sqlcpg.GetTransactionMetaByHashRow{}, + return sqlc.GetTransactionMetaByHashRow{}, fmt.Errorf("delete tx %s: %w", txHash, db.ErrDeleteRequiresUnmined) } diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index bb3c10dee4..50a117a789 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -5,21 +5,21 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. // // The returned TxInfo is rebuilt from normalized SQL columns; missing rows map // to ErrTxNotFound for the requested wallet/hash pair. -func (s *PostgresStore) GetTx(ctx context.Context, +func (s *Store) GetTx(ctx context.Context, query db.GetTxQuery) (*db.TxInfo, error) { row, err := s.queries.GetTransactionByHash( - ctx, sqlcpg.GetTransactionByHashParams{ + ctx, sqlc.GetTransactionByHashParams{ WalletID: int64(query.WalletID), TxHash: query.Txid[:], }, diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index cc3d8140c8..82ca2308d7 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -5,18 +5,18 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined // transaction branch and marks the root plus descendants failed. -func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, +func (s *Store) InvalidateUnminedTx(ctx context.Context, params db.InvalidateUnminedTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.InvalidateUnminedTxWithOps( ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) @@ -26,7 +26,7 @@ func (s *PostgresStore) InvalidateUnminedTx(ctx context.Context, // invalidateUnminedTxOps adapts postgres sqlc queries to the shared // InvalidateUnminedTx workflow. type invalidateUnminedTxOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) @@ -34,18 +34,20 @@ var _ db.InvalidateUnminedTxOps = (*invalidateUnminedTxOps)(nil) // LoadInvalidateTarget loads the root tx metadata used by the shared // invalidation workflow. func (o invalidateUnminedTxOps) LoadInvalidateTarget(ctx context.Context, - walletID uint32, txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { + walletID uint32, + txHash chainhash.Hash) (db.InvalidateUnminedTxTarget, error) { row, err := o.qtx.GetTransactionMetaByHash( - ctx, sqlcpg.GetTransactionMetaByHashParams{ + ctx, sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return db.InvalidateUnminedTxTarget{}, fmt.Errorf("tx %s: %w", txHash, - db.ErrTxNotFound) + return db.InvalidateUnminedTxTarget{}, fmt.Errorf( + "tx %s: %w", txHash, db.ErrTxNotFound, + ) } return db.InvalidateUnminedTxTarget{}, fmt.Errorf("get tx metadata: %w", @@ -77,7 +79,7 @@ func (o invalidateUnminedTxOps) ListUnminedTxRecords( } return db.BuildUnminedTxRecords(rows, - func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + func(row sqlc.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) @@ -89,7 +91,7 @@ func (o invalidateUnminedTxOps) ClearSpentUtxos(ctx context.Context, walletID int64, txID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( - ctx, sqlcpg.ClearUtxosSpentByTxIDParams{ + ctx, sqlc.ClearUtxosSpentByTxIDParams{ WalletID: walletID, SpentByTxID: sql.NullInt64{ Int64: txID, @@ -110,7 +112,7 @@ func (o invalidateUnminedTxOps) MarkTxnsFailed( ctx context.Context, walletID int64, txIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int16(db.TxStatusFailed), TxIds: txIDs, diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index 8a37df6e42..c74bbb934f 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -4,9 +4,9 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range @@ -15,7 +15,7 @@ import ( // The no-confirming-block path returns every row without a confirming block, // including retained invalid history such as orphaned or failed transactions, // while the confirmed path is bounded by the requested height range. -func (s *PostgresStore) ListTxns(ctx context.Context, +func (s *Store) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { if query.UnminedOnly { @@ -29,7 +29,7 @@ func (s *PostgresStore) ListTxns(ctx context.Context, // confirming block. This includes the active unmined set together with any // retained invalid history that rollback or invalidation flows left without a // confirming block. -func (s *PostgresStore) listTxnsWithoutBlock(ctx context.Context, +func (s *Store) listTxnsWithoutBlock(ctx context.Context, walletID uint32) ([]db.TxInfo, error) { rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) @@ -55,7 +55,7 @@ func (s *PostgresStore) listTxnsWithoutBlock(ctx context.Context, // listConfirmedTxns loads the confirmed height-range view used by ListTxns // when callers query mined history. -func (s *PostgresStore) listConfirmedTxns(ctx context.Context, +func (s *Store) listConfirmedTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { startHeight, err := db.Uint32ToInt32(query.StartHeight) @@ -69,7 +69,7 @@ func (s *PostgresStore) listConfirmedTxns(ctx context.Context, } rows, err := s.queries.ListTransactionsByHeightRange( - ctx, sqlcpg.ListTransactionsByHeightRangeParams{ + ctx, sqlc.ListTransactionsByHeightRangeParams{ WalletID: int64(query.WalletID), StartHeight: startHeight, EndHeight: endHeight, diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index 1acd187053..8741fb93ab 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -4,19 +4,19 @@ import ( "context" "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided // height and rewrites wallet sync-state references so the block delete can // succeed. -func (s *PostgresStore) RollbackToBlock(ctx context.Context, +func (s *Store) RollbackToBlock(ctx context.Context, height uint32) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.RollbackToBlockWithOps(ctx, height, rollbackToBlockOps{qtx: qtx}) }) @@ -25,7 +25,7 @@ func (s *PostgresStore) RollbackToBlock(ctx context.Context, // rollbackToBlockOps adapts postgres sqlc queries to the shared rollback // sequence. type rollbackToBlockOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.RollbackToBlockOps = (*rollbackToBlockOps)(nil) @@ -75,7 +75,7 @@ func (o rollbackToBlockOps) RewindWalletSyncStateHeights( } _, err = o.qtx.RewindWalletSyncStateHeightsForRollback( - ctx, sqlcpg.RewindWalletSyncStateHeightsForRollbackParams{ + ctx, sqlc.RewindWalletSyncStateHeightsForRollbackParams{ RollbackHeight: rollbackHeight, NewHeight: newHeight, }, @@ -116,7 +116,7 @@ func (o rollbackToBlockOps) MarkTxRootsOrphaned(ctx context.Context, // block reference and become orphaned in the same // row-local state patch. rows, err := o.qtx.UpdateTransactionStateByHash( - ctx, sqlcpg.UpdateTransactionStateByHashParams{ + ctx, sqlc.UpdateTransactionStateByHashParams{ BlockHeight: sql.NullInt32{}, Status: int16(db.TxStatusOrphaned), WalletID: int64(walletID), @@ -146,7 +146,7 @@ func (o rollbackToBlockOps) ListUnminedTxRecords( } return db.BuildUnminedTxRecords(rows, - func(row sqlcpg.ListUnminedTransactionsRow) (int64, []byte, []byte) { + func(row sqlc.ListUnminedTransactionsRow) (int64, []byte, []byte) { return row.ID, row.TxHash, row.RawTx }, ) @@ -158,7 +158,7 @@ func (o rollbackToBlockOps) ClearDescendantSpends( ctx context.Context, walletID int64, descendantID int64) error { _, err := o.qtx.ClearUtxosSpentByTxID( - ctx, sqlcpg.ClearUtxosSpentByTxIDParams{ + ctx, sqlc.ClearUtxosSpentByTxIDParams{ WalletID: walletID, SpentByTxID: sql.NullInt64{ Int64: descendantID, @@ -179,7 +179,7 @@ func (o rollbackToBlockOps) MarkDescendantsFailed( ctx context.Context, walletID int64, descendantIDs []int64) error { _, err := o.qtx.UpdateTransactionStatusByIDs( - ctx, sqlcpg.UpdateTransactionStatusByIDsParams{ + ctx, sqlc.UpdateTransactionStatusByIDsParams{ WalletID: walletID, Status: int16(db.TxStatusFailed), TxIds: descendantIDs, @@ -194,7 +194,7 @@ func (o rollbackToBlockOps) MarkDescendantsFailed( // groupRollbackCoinbaseRoots groups rollback-affected coinbase hashes by // wallet while preserving the query order inside each wallet bucket. -func groupRollbackCoinbaseRoots(rows []sqlcpg.ListRollbackCoinbaseRootsRow) ( +func groupRollbackCoinbaseRoots(rows []sqlc.ListRollbackCoinbaseRootsRow) ( map[uint32][]chainhash.Hash, error) { rootHashesByWallet := make( diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index 139679323f..3e7e94608b 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcd/chaincfg/chainhash" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. @@ -17,10 +17,10 @@ import ( // one SQL transaction. Immutable transaction facts such as raw_tx, credits, and // spent-input edges stay owned by CreateTx and the internal rollback/delete // flows. -func (s *PostgresStore) UpdateTx(ctx context.Context, +func (s *Store) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } @@ -28,7 +28,7 @@ func (s *PostgresStore) UpdateTx(ctx context.Context, // updateTxOps adapts postgres sqlc queries to the shared UpdateTx flow. type updateTxOps struct { // qtx is the transaction-scoped postgres query set used by UpdateTx. - qtx *sqlcpg.Queries + qtx *sqlc.Queries // blockHeight caches the validated postgres block-height wrapper prepared // for the later state update query. @@ -48,7 +48,7 @@ func (o *updateTxOps) LoadIsCoinbase(ctx context.Context, walletID uint32, meta, err := o.qtx.GetTransactionMetaByHash( ctx, - sqlcpg.GetTransactionMetaByHashParams{ + sqlc.GetTransactionMetaByHashParams{ WalletID: int64(walletID), TxHash: txHash[:], }, @@ -93,7 +93,7 @@ func (o *updateTxOps) UpdateState(ctx context.Context, walletID uint32, rows, err := o.qtx.UpdateTransactionStateByHash( ctx, - sqlcpg.UpdateTransactionStateByHashParams{ + sqlc.UpdateTransactionStateByHashParams{ BlockHeight: o.blockHeight, Status: o.status, WalletID: int64(walletID), @@ -117,7 +117,7 @@ func (o *updateTxOps) UpdateLabel(ctx context.Context, walletID uint32, rows, err := o.qtx.UpdateTransactionLabelByHash( ctx, - sqlcpg.UpdateTransactionLabelByHashParams{ + sqlc.UpdateTransactionLabelByHashParams{ Label: label, WalletID: int64(walletID), TxHash: txHash[:], diff --git a/wallet/internal/db/pg/utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go index 6865b2af8c..223c7ff397 100644 --- a/wallet/internal/db/pg/utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -3,20 +3,20 @@ package pg import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" "github.com/btcsuite/btcd/btcutil" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. -func (s *PostgresStore) Balance(ctx context.Context, +func (s *Store) Balance(ctx context.Context, params db.BalanceParams) (db.BalanceResult, error) { nowUTC := time.Now().UTC() - balance, err := s.queries.Balance(ctx, sqlcpg.BalanceParams{ + balance, err := s.queries.Balance(ctx, sqlc.BalanceParams{ NowUtc: nowUTC, WalletID: int64(params.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(params.Account), diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index c12bad5a0c..03ecb4a8da 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -5,17 +5,17 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. // // The output must still be unspent and its creating transaction must still be // in `pending` or `published` status. -func (s *PostgresStore) GetUtxo(ctx context.Context, +func (s *Store) GetUtxo(ctx context.Context, query db.GetUtxoQuery) (*db.UtxoInfo, error) { outputIndex, err := db.Uint32ToInt32(query.OutPoint.Index) @@ -24,7 +24,7 @@ func (s *PostgresStore) GetUtxo(ctx context.Context, } row, err := s.queries.GetUtxoByOutpoint( - ctx, sqlcpg.GetUtxoByOutpointParams{ + ctx, sqlc.GetUtxoByOutpointParams{ WalletID: int64(query.WalletID), TxHash: query.OutPoint.Hash[:], OutputIndex: outputIndex, diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index d40a7cfdc7..211afd2123 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. @@ -16,12 +16,12 @@ import ( // The lease lookup and acquisition run in one transaction so competing calls // cannot observe a partially-written lease. Expiration timestamps are // normalized to UTC before Insert. -func (s *PostgresStore) LeaseOutput(ctx context.Context, +func (s *Store) LeaseOutput(ctx context.Context, params db.LeaseOutputParams) (*db.LeasedOutput, error) { var lease *db.LeasedOutput - err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &leaseOutputOps{qtx: qtx}, ) @@ -43,7 +43,7 @@ func (s *PostgresStore) LeaseOutput(ctx context.Context, // leaseOutputOps adapts postgres sqlc queries to the shared LeaseOutput // workflow. type leaseOutputOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.LeaseOutputOps = (*leaseOutputOps)(nil) @@ -60,7 +60,7 @@ func (o *leaseOutputOps) Acquire(ctx context.Context, } expiration, err := o.qtx.AcquireUtxoLease( - ctx, sqlcpg.AcquireUtxoLeaseParams{ + ctx, sqlc.AcquireUtxoLeaseParams{ WalletID: int64(params.WalletID), LockID: params.ID[:], ExpiresAt: expiresAt, @@ -91,7 +91,7 @@ func (o *leaseOutputOps) HasUtxo(ctx context.Context, } _, err = o.qtx.GetUtxoIDByOutpoint( - ctx, sqlcpg.GetUtxoIDByOutpointParams{ + ctx, sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(params.WalletID), TxHash: params.OutPoint.Hash[:], OutputIndex: outputIndex, diff --git a/wallet/internal/db/pg/utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go index 926175b55f..0d17605633 100644 --- a/wallet/internal/db/pg/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -3,20 +3,20 @@ package pg import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. -func (s *PostgresStore) ListLeasedOutputs(ctx context.Context, +func (s *Store) ListLeasedOutputs(ctx context.Context, walletID uint32) ([]db.LeasedOutput, error) { nowUTC := time.Now().UTC() rows, err := s.queries.ListActiveUtxoLeases( - ctx, sqlcpg.ListActiveUtxoLeasesParams{ + ctx, sqlc.ListActiveUtxoLeasesParams{ WalletID: int64(walletID), NowUtc: nowUTC, }, diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index 2f1f65e828..9673ca15bf 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -3,16 +3,16 @@ package pg import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. // // The result set is already constrained to outputs whose creating // transactions are still in `pending` or `published` status. -func (s *PostgresStore) ListUTXOs(ctx context.Context, +func (s *Store) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { rows, err := s.queries.ListUtxos(ctx, buildListUtxosParams(query)) @@ -38,8 +38,8 @@ func (s *PostgresStore) ListUTXOs(ctx context.Context, // buildListUtxosParams prepares the typed nullable filters required by the // postgres ListUtxos query. -func buildListUtxosParams(query db.ListUtxosQuery) sqlcpg.ListUtxosParams { - return sqlcpg.ListUtxosParams{ +func buildListUtxosParams(query db.ListUtxosQuery) sqlc.ListUtxosParams { + return sqlc.ListUtxosParams{ WalletID: int64(query.WalletID), AccountNumber: db.NullableUint32ToSQLInt64(query.Account), MinConfirms: db.NullableInt32ToSQLInt32(query.MinConfs), diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index ed87995902..0876102448 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -5,10 +5,10 @@ import ( "database/sql" "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" "time" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + db "github.com/btcsuite/btcwallet/wallet/internal/db" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the @@ -16,10 +16,10 @@ import ( // // The ownership check and lease deletion run in one transaction so callers // cannot unlock a UTXO using stale state from a separate read. -func (s *PostgresStore) ReleaseOutput(ctx context.Context, +func (s *Store) ReleaseOutput(ctx context.Context, params db.ReleaseOutputParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { return db.ReleaseOutputWithOps( ctx, params, &releaseOutputOps{qtx: qtx}, ) @@ -29,7 +29,7 @@ func (s *PostgresStore) ReleaseOutput(ctx context.Context, // releaseOutputOps adapts postgres sqlc queries to the shared ReleaseOutput // workflow. type releaseOutputOps struct { - qtx *sqlcpg.Queries + qtx *sqlc.Queries } var _ db.ReleaseOutputOps = (*releaseOutputOps)(nil) @@ -45,7 +45,7 @@ func (o *releaseOutputOps) LookupUtxoID(ctx context.Context, } utxoID, err := o.qtx.GetUtxoIDByOutpoint( - ctx, sqlcpg.GetUtxoIDByOutpointParams{ + ctx, sqlc.GetUtxoIDByOutpointParams{ WalletID: int64(params.WalletID), TxHash: params.OutPoint.Hash[:], OutputIndex: outputIndex, @@ -68,7 +68,7 @@ func (o *releaseOutputOps) Release(ctx context.Context, walletID uint32, utxoID int64, lockID [32]byte) (int64, error) { rows, err := o.qtx.ReleaseUtxoLease( - ctx, sqlcpg.ReleaseUtxoLeaseParams{ + ctx, sqlc.ReleaseUtxoLeaseParams{ WalletID: int64(walletID), UtxoID: utxoID, LockID: lockID[:], @@ -87,7 +87,7 @@ func (o *releaseOutputOps) ActiveLockID(ctx context.Context, walletID uint32, utxoID int64, nowUTC time.Time) ([]byte, error) { activeLockID, err := o.qtx.GetActiveUtxoLeaseLockID( - ctx, sqlcpg.GetActiveUtxoLeaseLockIDParams{ + ctx, sqlc.GetActiveUtxoLeaseLockIDParams{ WalletID: int64(walletID), UtxoID: utxoID, NowUtc: nowUTC, diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index da6c148ad1..8d7fc58135 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -9,22 +9,22 @@ import ( db "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlcpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) -// Ensure PostgresStore satisfies the WalletStore interface. -var _ db.WalletStore = (*PostgresStore)(nil) +// Ensure Store satisfies the WalletStore interface. +var _ db.WalletStore = (*Store)(nil) // CreateWallet creates a new wallet in the database with the provided // parameters. It returns the created wallet info or an error if the // creation fails. -func (s *PostgresStore) CreateWallet(ctx context.Context, +func (s *Store) CreateWallet(ctx context.Context, params db.CreateWalletParams) (*db.WalletInfo, error) { var info *db.WalletInfo - err := s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { - walletParams := sqlcpg.CreateWalletParams{ + err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + walletParams := sqlc.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, ManagerVersion: params.ManagerVersion, @@ -39,7 +39,7 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, return fmt.Errorf("create wallet: %w", err) } - secretsParams := sqlcpg.InsertWalletSecretsParams{ + secretsParams := sqlc.InsertWalletSecretsParams{ WalletID: id, MasterPrivParams: params.MasterKeyPrivParams, EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, @@ -63,7 +63,7 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, } } - syncParams := sqlcpg.InsertWalletSyncStateParams{ + syncParams := sqlc.InsertWalletSyncStateParams{ WalletID: id, SyncedHeight: sql.NullInt32{}, BirthdayHeight: sql.NullInt32{}, @@ -114,7 +114,7 @@ func (s *PostgresStore) CreateWallet(ctx context.Context, // GetWallet retrieves information about a wallet given its name. It // returns a WalletInfo struct containing the wallet's properties or an // error if the wallet is not found. -func (s *PostgresStore) GetWallet(ctx context.Context, +func (s *Store) GetWallet(ctx context.Context, name string) (*db.WalletInfo, error) { row, err := s.queries.GetWalletByName(ctx, name) @@ -144,7 +144,7 @@ func (s *PostgresStore) GetWallet(ctx context.Context, } // ListWallets returns a page of wallets matching the given query. -func (s *PostgresStore) ListWallets(ctx context.Context, +func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { rows, err := s.queries.ListWallets(ctx, listWalletsParams(query.Page)) @@ -175,7 +175,7 @@ func (s *PostgresStore) ListWallets(ctx context.Context, } // IterWallets returns an iterator over paginated wallet results. -func (s *PostgresStore) IterWallets(ctx context.Context, +func (s *Store) IterWallets(ctx context.Context, query db.ListWalletsQuery) iter.Seq2[db.WalletInfo, error] { return page.Iter( @@ -187,10 +187,10 @@ func (s *PostgresStore) IterWallets(ctx context.Context, // birthday, birthday block, or sync state. The specific fields to // update are provided in the UpdateWalletParams struct. It returns an // error if the update fails. -func (s *PostgresStore) UpdateWallet(ctx context.Context, +func (s *Store) UpdateWallet(ctx context.Context, params db.UpdateWalletParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlcpg.Queries) error { + return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExists(ctx, qtx, params.SyncedTo) @@ -233,7 +233,7 @@ func (s *PostgresStore) UpdateWallet(ctx context.Context, // Deterministic (HD) seed of the wallet. This seed is sensitive // information and is returned in its encrypted form. It returns the // encrypted seed as a byte slice or an error if the retrieval fails. -func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, +func (s *Store) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) @@ -256,10 +256,10 @@ func (s *PostgresStore) GetEncryptedHDSeed(ctx context.Context, } // UpdateWalletSecrets updates the secrets for the wallet. -func (s *PostgresStore) UpdateWalletSecrets(ctx context.Context, +func (s *Store) UpdateWalletSecrets(ctx context.Context, params db.UpdateWalletSecretsParams) error { - secretsParams := sqlcpg.UpdateWalletSecretsParams{ + secretsParams := sqlc.UpdateWalletSecretsParams{ MasterPrivParams: params.MasterPrivParams, EncryptedCryptoPrivKey: params.EncryptedCryptoPrivKey, EncryptedCryptoScriptKey: params.EncryptedCryptoScriptKey, @@ -299,7 +299,7 @@ type walletRowParams struct { // listWalletRowToInfo converts a ListWallets result row to a WalletInfo // struct for pagination. -func listWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { +func listWalletRowToInfo(row sqlc.ListWalletsRow) (*db.WalletInfo, error) { return buildWalletInfo(walletRowParams{ id: row.ID, name: row.WalletName, @@ -319,9 +319,9 @@ func listWalletRowToInfo(row sqlcpg.ListWalletsRow) (*db.WalletInfo, error) { // listWalletsParams translates a page request to ListWallets query // parameters, handling optional cursor setup for pagination. func listWalletsParams( - req page.Request[uint32]) sqlcpg.ListWalletsParams { + req page.Request[uint32]) sqlc.ListWalletsParams { - params := sqlcpg.ListWalletsParams{ + params := sqlc.ListWalletsParams{ PageLimit: int64(req.QueryLimit()), } @@ -387,9 +387,9 @@ func buildWalletInfo(row walletRowParams) (*db.WalletInfo, error) { // buildUpdateSyncParams constructs the UpdateWalletSyncStateParams from // the given UpdateWalletParams. func buildUpdateSyncParams(params db.UpdateWalletParams) ( - sqlcpg.UpdateWalletSyncStateParams, error) { + sqlc.UpdateWalletSyncStateParams, error) { - syncParams := sqlcpg.UpdateWalletSyncStateParams{ + syncParams := sqlc.UpdateWalletSyncStateParams{ WalletID: int64(params.WalletID), } diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go index bdc3cbbdf9..3a66af29e8 100644 --- a/wallet/internal/db/tx.go +++ b/wallet/internal/db/tx.go @@ -10,7 +10,7 @@ import ( // the transaction lifecycle: begin, commit, and rollback on error. // // This is a helper function used by the public ExecuteTx methods on -// PostgresStore and sqlite.Store. It guarantees that the transaction +// pg.Store and sqlite.Store. It guarantees that the transaction // will be either committed (on success) or rolled back (on error or panic). func ExecInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) From 732b5116c33e9d2e9819aadb1e70dc22b522d863 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 10:41:01 +0800 Subject: [PATCH 539/691] lint: relax split backend wrapcheck Allow wrapcheck in the split pg and sqlite backend packages while the shared helper layer still sits in wallet/internal/db. This keeps lint signal elsewhere without forcing wrapper noise into the mechanical split series. --- .golangci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 3f2e149991..435a07ba9d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -138,6 +138,13 @@ linters: # Allow returning unwrapped errors in tests. - wrapcheck + # The split backend packages intentionally forward many calls into the + # shared db helpers. Keep wrapcheck enabled elsewhere while the package + # split settles. + - path: wallet/internal/db/(pg|sqlite)/.*\.go + linters: + - wrapcheck + - path: mock* linters: - revive From 048f27e4da4d5f0f99357ea364d09ef3b61831b4 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Apr 2026 00:56:57 -0300 Subject: [PATCH 540/691] wallet: simplify migration instance creation --- wallet/internal/sql/pg/migrations.go | 33 ++++++++++++------------ wallet/internal/sql/sqlite/migrations.go | 33 ++++++++++++------------ 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/wallet/internal/sql/pg/migrations.go b/wallet/internal/sql/pg/migrations.go index a59810dcab..d046244680 100644 --- a/wallet/internal/sql/pg/migrations.go +++ b/wallet/internal/sql/pg/migrations.go @@ -7,7 +7,6 @@ import ( "fmt" gomigrate "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database" migrate "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source/iofs" ) @@ -15,25 +14,34 @@ import ( //go:embed migrations/*.sql var migrationFS embed.FS -type driverFactory func(*sql.DB) (database.Driver, error) - -// applyMigrations applies all embedded postgres migrations to one database. -func applyMigrations(db *sql.DB, newDriver driverFactory) error { +// newMigrationInstance creates a migrate instance from embedded postgres +// migrations. +func newMigrationInstance(db *sql.DB) (*gomigrate.Migrate, error) { sourceDriver, err := iofs.New(migrationFS, "migrations") if err != nil { - return fmt.Errorf("create source driver: %w", err) + return nil, fmt.Errorf("create source driver: %w", err) } - driver, err := newDriver(db) + driver, err := migrate.WithInstance(db, &migrate.Config{}) if err != nil { - return fmt.Errorf("create postgres driver: %w", err) + return nil, fmt.Errorf("create postgres driver: %w", err) } m, err := gomigrate.NewWithInstance( "iofs", sourceDriver, "postgres", driver, ) if err != nil { - return fmt.Errorf("create migrate instance: %w", err) + return nil, fmt.Errorf("create migrate instance: %w", err) + } + + return m, nil +} + +// ApplyMigrations applies all PostgreSQL migrations to the database. +func ApplyMigrations(db *sql.DB) error { + m, err := newMigrationInstance(db) + if err != nil { + return err } err = m.Up() @@ -43,10 +51,3 @@ func applyMigrations(db *sql.DB, newDriver driverFactory) error { return nil } - -// ApplyMigrations applies all PostgreSQL migrations to the database. -func ApplyMigrations(db *sql.DB) error { - return applyMigrations(db, func(db *sql.DB) (database.Driver, error) { - return migrate.WithInstance(db, &migrate.Config{}) - }) -} diff --git a/wallet/internal/sql/sqlite/migrations.go b/wallet/internal/sql/sqlite/migrations.go index 9bea555e9c..975ca08623 100644 --- a/wallet/internal/sql/sqlite/migrations.go +++ b/wallet/internal/sql/sqlite/migrations.go @@ -7,7 +7,6 @@ import ( "fmt" gomigrate "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database" migrate "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" ) @@ -15,23 +14,32 @@ import ( //go:embed migrations/*.sql var migrationFS embed.FS -type driverFactory func(*sql.DB) (database.Driver, error) - -// applyMigrations applies all embedded sqlite migrations to one database. -func applyMigrations(db *sql.DB, newDriver driverFactory) error { +// newMigrationInstance creates a migrate instance from embedded sqlite +// migrations. +func newMigrationInstance(db *sql.DB) (*gomigrate.Migrate, error) { sourceDriver, err := iofs.New(migrationFS, "migrations") if err != nil { - return fmt.Errorf("create source driver: %w", err) + return nil, fmt.Errorf("create source driver: %w", err) } - driver, err := newDriver(db) + driver, err := migrate.WithInstance(db, &migrate.Config{}) if err != nil { - return fmt.Errorf("create sqlite driver: %w", err) + return nil, fmt.Errorf("create sqlite driver: %w", err) } m, err := gomigrate.NewWithInstance("iofs", sourceDriver, "sqlite", driver) if err != nil { - return fmt.Errorf("create migrate instance: %w", err) + return nil, fmt.Errorf("create migrate instance: %w", err) + } + + return m, nil +} + +// ApplyMigrations applies all SQLite migrations to the database. +func ApplyMigrations(db *sql.DB) error { + m, err := newMigrationInstance(db) + if err != nil { + return err } err = m.Up() @@ -41,10 +49,3 @@ func applyMigrations(db *sql.DB, newDriver driverFactory) error { return nil } - -// ApplyMigrations applies all SQLite migrations to the database. -func ApplyMigrations(db *sql.DB) error { - return applyMigrations(db, func(db *sql.DB) (database.Driver, error) { - return migrate.WithInstance(db, &migrate.Config{}) - }) -} From 2a7aea6e979833d75386edd0fd01dbc2d026525c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Apr 2026 00:57:27 -0300 Subject: [PATCH 541/691] wallet: add rollback migrations functionality --- wallet/internal/sql/pg/migrations.go | 15 +++++++++++++++ wallet/internal/sql/sqlite/migrations.go | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/wallet/internal/sql/pg/migrations.go b/wallet/internal/sql/pg/migrations.go index d046244680..756e18b125 100644 --- a/wallet/internal/sql/pg/migrations.go +++ b/wallet/internal/sql/pg/migrations.go @@ -51,3 +51,18 @@ func ApplyMigrations(db *sql.DB) error { return nil } + +// RollbackMigrations rolls back all PostgreSQL migrations from the database. +func RollbackMigrations(db *sql.DB) error { + m, err := newMigrationInstance(db) + if err != nil { + return err + } + + err = m.Down() + if err != nil && !errors.Is(err, gomigrate.ErrNoChange) { + return fmt.Errorf("rollback migrations: %w", err) + } + + return nil +} diff --git a/wallet/internal/sql/sqlite/migrations.go b/wallet/internal/sql/sqlite/migrations.go index 975ca08623..05e70e63e0 100644 --- a/wallet/internal/sql/sqlite/migrations.go +++ b/wallet/internal/sql/sqlite/migrations.go @@ -49,3 +49,18 @@ func ApplyMigrations(db *sql.DB) error { return nil } + +// RollbackMigrations rolls back all SQLite migrations from the database. +func RollbackMigrations(db *sql.DB) error { + m, err := newMigrationInstance(db) + if err != nil { + return err + } + + err = m.Down() + if err != nil && !errors.Is(err, gomigrate.ErrNoChange) { + return fmt.Errorf("rollback migrations: %w", err) + } + + return nil +} From a2758c101b4a5546a1558b7fe91f0818c7ab8328 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Sun, 12 Apr 2026 14:00:40 -0300 Subject: [PATCH 542/691] wallet: fix addresses rollback objects --- wallet/internal/sql/pg/migrations/000006_addresses.down.sql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wallet/internal/sql/pg/migrations/000006_addresses.down.sql b/wallet/internal/sql/pg/migrations/000006_addresses.down.sql index ea5ce7ef37..389c49d649 100644 --- a/wallet/internal/sql/pg/migrations/000006_addresses.down.sql +++ b/wallet/internal/sql/pg/migrations/000006_addresses.down.sql @@ -1,5 +1,9 @@ -- Rollback note: Idempotent by design (using "IF EXISTS"). -- Must succeed even if tables are already dropped or database is in unexpected state. +DROP TRIGGER IF EXISTS trg_addresses_imported_key_count_insert ON addresses; +DROP TRIGGER IF EXISTS trg_addresses_imported_key_count_delete ON addresses; +DROP FUNCTION IF EXISTS sync_account_imported_key_count_insert(); +DROP FUNCTION IF EXISTS sync_account_imported_key_count_delete(); DROP INDEX IF EXISTS idx_addresses_account_id; DROP TABLE IF EXISTS address_secrets; DROP TABLE IF EXISTS addresses; From 51769a5f72ddec06739ca200d15fef3170943c71 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Apr 2026 00:56:02 -0300 Subject: [PATCH 543/691] wallet: test migration rollback reapply --- .../db/itest/migration_rollback_test.go | 25 +++++++++++++++++++ wallet/internal/db/pg/itest.go | 11 ++++++++ wallet/internal/db/sqlite/itest.go | 11 ++++++++ 3 files changed, 47 insertions(+) create mode 100644 wallet/internal/db/itest/migration_rollback_test.go diff --git a/wallet/internal/db/itest/migration_rollback_test.go b/wallet/internal/db/itest/migration_rollback_test.go new file mode 100644 index 0000000000..69c24a9030 --- /dev/null +++ b/wallet/internal/db/itest/migration_rollback_test.go @@ -0,0 +1,25 @@ +//go:build itest + +package itest + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestMigrationsRollbackReapply ensures that the full migration chain +// can be cleanly rolled back and then reapplied without errors. +func TestMigrationsRollbackReapply(t *testing.T) { + t.Parallel() + + s := NewTestStore(t) + + err := s.RollbackAllMigrations() + require.NoError(t, err, "failed to rollback all migrations") + + // Reapply all migrations to verify that the database can return to a + // valid, fully migrated state after a complete rollback. + err = s.ApplyAllMigrations() + require.NoError(t, err, "failed to reapply all migrations") +} diff --git a/wallet/internal/db/pg/itest.go b/wallet/internal/db/pg/itest.go index 2e66940b9b..0a4f62b514 100644 --- a/wallet/internal/db/pg/itest.go +++ b/wallet/internal/db/pg/itest.go @@ -5,6 +5,7 @@ package pg import ( "database/sql" + sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) @@ -17,3 +18,13 @@ func (s *Store) DB() *sql.DB { func (s *Store) Queries() *sqlc.Queries { return s.queries } + +// RollbackAllMigrations rolls back all PostgreSQL migrations. +func (s *Store) RollbackAllMigrations() error { + return sqlassetpg.RollbackMigrations(s.db) +} + +// ApplyAllMigrations reapplies all PostgreSQL migrations. +func (s *Store) ApplyAllMigrations() error { + return sqlassetpg.ApplyMigrations(s.db) +} diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go index 9069ae7af7..623078d13f 100644 --- a/wallet/internal/db/sqlite/itest.go +++ b/wallet/internal/db/sqlite/itest.go @@ -5,6 +5,7 @@ package sqlite import ( "database/sql" + sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) @@ -17,3 +18,13 @@ func (s *Store) DB() *sql.DB { func (s *Store) Queries() *sqlc.Queries { return s.queries } + +// RollbackAllMigrations rolls back all SQLite migrations. +func (s *Store) RollbackAllMigrations() error { + return sqlassetsqlite.RollbackMigrations(s.db) +} + +// ApplyAllMigrations reapplies all SQLite migrations. +func (s *Store) ApplyAllMigrations() error { + return sqlassetsqlite.ApplyMigrations(s.db) +} From 0123b141b3b48b023dbd897132ccc447bd4bab09 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 9 Apr 2026 03:32:02 +0800 Subject: [PATCH 544/691] wallet: simplify pagination api Require callers to pass explicit, non-zero page limits and remove shared default and max-limit policy from the page package. This keeps page focused on request/result semantics while stores own query sizing and validation. Use pointer-style After and Next tokens so manual pagination can forward the next page token directly without HasAfter or HasNext plumbing. Keep result assembly in page, update wallet and address stores to fetch limit+1 rows explicitly, and refresh tests and docs to match the simpler API. --- wallet/internal/db/addresses_common.go | 2 +- wallet/internal/db/interface.go | 4 + .../internal/db/itest/address_store_test.go | 72 ++++--- wallet/internal/db/itest/wallet_store_test.go | 67 ++++-- wallet/internal/db/page/doc.go | 20 +- wallet/internal/db/page/iter_test.go | 97 +++------ wallet/internal/db/page/request.go | 106 ++-------- wallet/internal/db/page/request_test.go | 195 +----------------- wallet/internal/db/page/result.go | 4 +- wallet/internal/db/pg/addresses.go | 11 +- wallet/internal/db/pg/wallet.go | 12 +- wallet/internal/db/sqlite/addresses.go | 11 +- wallet/internal/db/sqlite/wallet.go | 12 +- wallet/internal/db/wallets_common.go | 2 +- 14 files changed, 195 insertions(+), 420 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index 74238117b6..cec7c61ec3 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -364,7 +364,7 @@ func GetAddress[T any, Args any](ctx context.Context, func NextListAddressesQuery(q ListAddressesQuery, cursor uint32) ListAddressesQuery { - q.Page = q.Page.WithAfter(cursor) + q.Page.After = &cursor return q } diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 4be4dfd46d..4f61052883 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -50,6 +50,10 @@ var ( // field combinations. ErrInvalidAddressQuery = errors.New("ScriptPubKey must be provided") + // ErrInvalidPageLimit is returned when a paginated query is called with a + // zero page limit. + ErrInvalidPageLimit = errors.New("page limit must be greater than zero") + // ErrMissingScriptPubKey is returned when creating an imported // address without the required script public key. ErrMissingScriptPubKey = errors.New("script pubkey required") diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 7bb2747025..f136caa2ec 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -85,9 +85,8 @@ func getAccountByName(t *testing.T, store db.AccountStore, walletID uint32, return account } -// collectAddressPages collects paginated address results by iterating -// through all pages from ListAddresses, using cursor pagination until -// Next is nil. +// collectAddressPages collects paginated address results by iterating through +// all pages from ListAddresses until Next is nil. func collectAddressPages(t *testing.T, store db.AddressStore, query db.ListAddressesQuery) []page.Result[db.AddressInfo, uint32] { t.Helper() @@ -102,7 +101,7 @@ func collectAddressPages(t *testing.T, store db.AddressStore, return pages } - query.Page = query.Page.WithAfter(*pageResult.Next) + query.Page.After = pageResult.Next } } @@ -739,6 +738,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "test-account", + Page: page.Request[uint32]{Limit: 10}, } }, wantCount: 5, @@ -766,6 +766,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "empty-account", + Page: page.Request[uint32]{Limit: 10}, } }, wantCount: 0, @@ -803,6 +804,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "bip44-multi", + Page: page.Request[uint32]{Limit: 10}, } }, wantCount: 3, @@ -841,6 +843,21 @@ func TestListAddresses(t *testing.T) { } } +// TestListAddressesZeroLimit verifies ListAddresses rejects a zero page limit. +func TestListAddressesZeroLimit(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-list-addresses-zero-limit") + + _, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AccountName: "test-account", + }) + require.ErrorIs(t, err, db.ErrInvalidPageLimit) +} + // TestNewDerivedAddress verifies that NewDerivedAddress correctly creates // derived addresses with proper AddressInfo fields for both external and // change addresses. @@ -996,6 +1013,7 @@ func TestListAddressesOrdering(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "ordering-account", + Page: page.Request[uint32]{Limit: 10}, }, ) @@ -1053,7 +1071,7 @@ func TestListAddressesPagination(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "account-a", - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListAddresses(t.Context(), query) @@ -1062,21 +1080,21 @@ func TestListAddressesPagination(t *testing.T) { require.Equal(t, accountA[:2], page1.Items) require.NotNil(t, page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) require.Equal(t, accountA[2:4], page2.Items) require.NotNil(t, page2.Next) - query.Page = query.Page.WithAfter(*page2.Next) + query.Page.After = page2.Next page3, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Len(t, page3.Items, 1) require.Equal(t, accountA[4:], page3.Items) require.Nil(t, page3.Next) - query.Page = query.Page.WithAfter(page3.Items[len(page3.Items)-1].ID) + query.Page.After = uint32Ptr(page3.Items[len(page3.Items)-1].ID) page4, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Empty(t, page4.Items) @@ -1111,7 +1129,7 @@ func TestListAddressesExactBoundary(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListAddresses(t.Context(), query) @@ -1120,14 +1138,14 @@ func TestListAddressesExactBoundary(t *testing.T) { require.NotNil(t, page1.Next) require.Equal(t, page1.Items[1].ID, *page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Equal(t, expected[2:], page2.Items) require.Nil(t, page2.Next) require.Greater(t, page2.Items[0].ID, *page1.Next) - query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + query.Page.After = uint32Ptr(page2.Items[len(page2.Items)-1].ID) page3, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Empty(t, page3.Items) @@ -1150,7 +1168,7 @@ func TestListAddressesPagedEmptyResult(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "empty-account", - Page: page.Request[uint32]{}.WithLimit(uint32(pageSize)), + Page: page.Request[uint32]{Limit: uint32(pageSize)}, }) require.NoError(t, err) require.Empty(t, pageResult.Items) @@ -1175,7 +1193,7 @@ func TestListAddressesDeterministicPagination(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, }) require.Len(t, pages, 3) require.Len(t, pages[0].Items, 2) @@ -1247,7 +1265,7 @@ func TestListAddressesAccountIsolation(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "account-a", - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, }) addresses := flattenAddressPages(pages) @@ -1277,8 +1295,7 @@ func TestListAddressesInsertAfterCursor(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}. - WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) @@ -1291,7 +1308,7 @@ func TestListAddressesInsertAfterCursor(t *testing.T) { t, store, walletID, scope, accountName, false, ) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) @@ -1317,9 +1334,10 @@ func TestListAddressesCursorEdges(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}. - WithLimit(2). - WithAfter(math.MaxUint32), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(math.MaxUint32), + }, }) require.NoError(t, err) require.Empty(t, stalePage.Items) @@ -1329,9 +1347,10 @@ func TestListAddressesCursorEdges(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{}. - WithLimit(2). - WithAfter(0), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(0), + }, }) require.NoError(t, err) require.Len(t, zeroPage.Items, 2) @@ -1359,7 +1378,7 @@ func TestIterAddresses(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "iter-account", - Page: page.Request[uint32]{}.WithLimit(uint32(pageSize)), + Page: page.Request[uint32]{Limit: uint32(pageSize)}, } iterAddrs := make([]db.AddressInfo, 0, len(expected)) @@ -1392,8 +1411,7 @@ func TestIterAddressesPaginated(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "iter-account", - Page: page.Request[uint32]{}. - WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } pages := collectAddressPages(t, store, query) @@ -1427,7 +1445,7 @@ func TestIterAddressesEmpty(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "empty-account", - Page: page.Request[uint32]{}.WithLimit(10), + Page: page.Request[uint32]{Limit: 10}, } for addr, err := range store.IterAddresses(t.Context(), query) { diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index ab7d0eee95..a4c3d416f8 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -12,6 +12,11 @@ import ( "github.com/stretchr/testify/require" ) +// uint32Ptr returns a pointer to the given uint32 value. +func uint32Ptr(v uint32) *uint32 { + return &v +} + // TestCreateWallet verifies that CreateWallet correctly creates a wallet // and returns its information. func TestCreateWallet(t *testing.T) { @@ -156,7 +161,7 @@ func TestListWallets(t *testing.T) { // Initially empty. query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(10), + Page: page.Request[uint32]{Limit: 10}, } pageResult, err := store.ListWallets(t.Context(), query) @@ -184,6 +189,16 @@ func TestListWallets(t *testing.T) { require.ElementsMatch(t, names, walletsName) } +// TestListWalletsZeroLimit verifies ListWallets rejects a zero page limit. +func TestListWalletsZeroLimit(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + + _, err := store.ListWallets(t.Context(), db.ListWalletsQuery{}) + require.ErrorIs(t, err, db.ErrInvalidPageLimit) +} + // TestListWalletsPagination verifies that ListWallets paginates correctly and // sets Next without requiring an extra round-trip. func TestListWalletsPagination(t *testing.T) { @@ -199,7 +214,7 @@ func TestListWalletsPagination(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListWallets(t.Context(), query) @@ -208,13 +223,13 @@ func TestListWalletsPagination(t *testing.T) { require.NotNil(t, page1.Next) require.Equal(t, page1.Items[1].ID, *page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) require.Nil(t, page2.Next) - query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + query.Page.After = uint32Ptr(page2.Items[len(page2.Items)-1].ID) page3, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Empty(t, page3.Items) @@ -246,7 +261,7 @@ func TestListWalletsExactBoundary(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListWallets(t.Context(), query) @@ -257,7 +272,7 @@ func TestListWalletsExactBoundary(t *testing.T) { require.NotNil(t, page1.Next) require.Equal(t, page1.Items[1].ID, *page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) @@ -266,7 +281,7 @@ func TestListWalletsExactBoundary(t *testing.T) { require.Nil(t, page2.Next) require.Greater(t, page2.Items[0].ID, *page1.Next) - query.Page = query.Page.WithAfter(page2.Items[len(page2.Items)-1].ID) + query.Page.After = uint32Ptr(page2.Items[len(page2.Items)-1].ID) page3, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Empty(t, page3.Items) @@ -289,7 +304,7 @@ func TestIterWallets(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } expected := flattenWalletPages(collectWalletPages(t, store, query)) @@ -317,7 +332,7 @@ func TestIterWalletsPaginated(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } pages := collectWalletPages(t, store, query) @@ -354,7 +369,10 @@ func TestListWalletsPagedFromCursor(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2).WithAfter(created[1].ID), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(created[1].ID), + }, } pageResult, err := store.ListWallets(t.Context(), query) @@ -364,7 +382,7 @@ func TestListWalletsPagedFromCursor(t *testing.T) { require.Equal(t, names[3], pageResult.Items[1].Name) require.Nil(t, pageResult.Next) - query.Page = query.Page.WithAfter(created[3].ID) + query.Page.After = uint32Ptr(created[3].ID) pageResult, err = store.ListWallets(t.Context(), query) require.NoError(t, err) require.Empty(t, pageResult.Items) @@ -410,7 +428,7 @@ func TestListWalletsPagedWithSyncMetadata(t *testing.T) { require.NoError(t, err) query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(1), + Page: page.Request[uint32]{Limit: 1}, } page1, err := store.ListWallets(t.Context(), query) @@ -421,7 +439,7 @@ func TestListWalletsPagedWithSyncMetadata(t *testing.T) { require.False(t, page1.Items[0].Birthday.IsZero()) require.NotNil(t, page1.Next) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 1) @@ -453,7 +471,7 @@ func TestListWalletsDeterministicPagination(t *testing.T) { } pages := collectWalletPages(t, store, db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, }) require.Len(t, pages, 3) require.Len(t, pages[0].Items, 2) @@ -521,7 +539,7 @@ func TestListWalletsInsertAfterCursor(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2), + Page: page.Request[uint32]{Limit: 2}, } page1, err := store.ListWallets(t.Context(), query) require.NoError(t, err) @@ -535,7 +553,7 @@ func TestListWalletsInsertAfterCursor(t *testing.T) { ) require.NoError(t, err) - query.Page = query.Page.WithAfter(*page1.Next) + query.Page.After = page1.Next page2, err := store.ListWallets(t.Context(), query) require.NoError(t, err) require.Len(t, page2.Items, 2) @@ -560,14 +578,20 @@ func TestListWalletsCursorEdges(t *testing.T) { } stalePage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2).WithAfter(math.MaxUint32), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(math.MaxUint32), + }, }) require.NoError(t, err) require.Empty(t, stalePage.Items) require.Nil(t, stalePage.Next) zeroPage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ - Page: page.Request[uint32]{}.WithLimit(2).WithAfter(0), + Page: page.Request[uint32]{ + Limit: 2, + After: uint32Ptr(0), + }, }) require.NoError(t, err) require.Len(t, zeroPage.Items, 2) @@ -576,9 +600,8 @@ func TestListWalletsCursorEdges(t *testing.T) { require.NotNil(t, zeroPage.Next) } -// collectWalletPages collects paginated wallet results by iterating -// through all pages from ListWallets, using cursor pagination until -// Next is nil. +// collectWalletPages collects paginated wallet results by iterating through all +// pages from ListWallets until Next is nil. func collectWalletPages(t *testing.T, store db.WalletStore, query db.ListWalletsQuery) []page.Result[db.WalletInfo, uint32] { t.Helper() @@ -593,7 +616,7 @@ func collectWalletPages(t *testing.T, store db.WalletStore, return pages } - query.Page = query.Page.WithAfter(*pageResult.Next) + query.Page.After = pageResult.Next } } diff --git a/wallet/internal/db/page/doc.go b/wallet/internal/db/page/doc.go index b0795cd0f0..b864b87a9b 100644 --- a/wallet/internal/db/page/doc.go +++ b/wallet/internal/db/page/doc.go @@ -5,15 +5,15 @@ // // A [Request] carries the parameters for a single page fetch: page limit, // and an optional after that identifies where the previous page ended. -// The zero value requests the first page at [DefaultLimit]. // // A [Result] carries the items returned by one fetch together with -// [Result.Next]. Pass *Next back to [Request.WithAfter] to advance to the -// next page. +// [Result.Next]. Assign [Result.Next] to [Request.After] to advance to the next +// page. // -// Queries fetch normalizedLimit+1 rows internally and return at most -// normalizedLimit items. If the extra row exists, [Result.Next] is non-nil. -// If it does not, [Result.Next] is nil and the current page is the last page. +// Stores require [Request.Limit] to be positive, fetch one extra row +// internally, and return at most the requested number of items. If the extra +// row exists, [Result.Next] is non-nil. If it does not, [Result.Next] is nil +// and the current page is the last page. // // # Iterating // @@ -24,8 +24,8 @@ // // # Store integration // -// Stores typically translate [Request.After] into an optional backend -// query parameter, fetch [Request.QueryLimit] rows with a single ordered -// SQL query, map the raw rows to domain items, and then call -// [BuildResult] to derive [Result.Next]. +// Stores typically translate [Request.After] into an optional backend query +// parameter, validate [Request.Limit], fetch `limit+1` rows with a single +// ordered SQL query, and call [BuildResult] to derive [Result.Next] from the +// last returned item when another page exists. package page diff --git a/wallet/internal/db/page/iter_test.go b/wallet/internal/db/page/iter_test.go index 3b574bfbd9..11c1ba8eff 100644 --- a/wallet/internal/db/page/iter_test.go +++ b/wallet/internal/db/page/iter_test.go @@ -8,17 +8,16 @@ import ( "github.com/stretchr/testify/require" ) -// errTest is a sentinel error used across page package tests to -// verify error propagation through pagination helpers. +// errTest is a sentinel error used across page package tests. var errTest = errors.New("test error") // intPtr returns a pointer to the given int value. It is used in tests to -// construct Result literals that require a *int after. +// construct Result literals that require a *int cursor. func intPtr(v int) *int { return &v } -// TestIterTraversal tests the traversal of items using the page iterator. +// TestIterTraversal verifies that Iter walks all pages in order. func TestIterTraversal(t *testing.T) { t.Parallel() @@ -83,8 +82,8 @@ func TestIterTraversal(t *testing.T) { gotItems []int ) - fetchPage := func(_ context.Context, query int) (Result[int, int], - error) { + fetchPage := func(_ context.Context, + query int) (Result[int, int], error) { require.Less(t, fetchCalls, len(tc.pages)) require.Equal(t, fetchCalls, query) @@ -111,9 +110,7 @@ func TestIterTraversal(t *testing.T) { return query + 1 } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { require.NoError(t, err) gotItems = append(gotItems, item) @@ -128,13 +125,14 @@ func TestIterTraversal(t *testing.T) { } // TestIterFetchErrorOnFirstCall verifies that Iter yields the error immediately -// when fetchPage fails on the very first call, before any items are produced. +// when fetchPage fails on the first call, before any items are produced. func TestIterFetchErrorOnFirstCall(t *testing.T) { t.Parallel() - gotItems := make([]int, 0) - - var iterErr error + var ( + gotItems = make([]int, 0) + iterErr error + ) fetchPage := func(_ context.Context, _ int) (Result[int, int], error) { return Result[int, int]{}, errTest @@ -144,9 +142,7 @@ func TestIterFetchErrorOnFirstCall(t *testing.T) { return 0 } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { if err != nil { iterErr = err break @@ -168,14 +164,10 @@ func TestIterFetchErrorAfterTwoPages(t *testing.T) { fetchCalls int nextCursors []int iterErr error + gotItems = make([]int, 0, 4) ) - gotItems := make([]int, 0) - - fetchPage := func( - _ context.Context, _ int, - ) (Result[int, int], error) { - + fetchPage := func(_ context.Context, _ int) (Result[int, int], error) { fetchCalls++ switch fetchCalls { case 1: @@ -198,9 +190,7 @@ func TestIterFetchErrorAfterTwoPages(t *testing.T) { return query + 1 } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { if err != nil { iterErr = err break @@ -242,11 +232,8 @@ func TestIterContextCancellation(t *testing.T) { wantFetchCalls: 1, }, { - name: "cancel mid-page stops before next yield", - pages: [][]int{ - {1, 2, 3}, - {4, 5}, - }, + name: "cancel mid-page stops before next yield", + pages: [][]int{{1, 2, 3}, {4, 5}}, cancelAfterItems: 1, wantItems: []int{1}, wantFetchCalls: 1, @@ -270,25 +257,21 @@ func TestIterContextCancellation(t *testing.T) { cancel() } - fetchPage := func(ctx context.Context, _ int) (Result[int, int], - error) { + fetchPage := func(ctx context.Context, + _ int) (Result[int, int], error) { err := ctx.Err() if err != nil { fetchCalls++ - return Result[int, int]{}, err } require.Less(t, fetchCalls, len(tc.pages)) - items := tc.pages[fetchCalls] fetchCalls++ - result := Result[int, int]{ - Items: items, - } - if len(items) > 0 { + result := Result[int, int]{Items: items} + if fetchCalls < len(tc.pages) && len(items) > 0 { last := items[len(items)-1] result.Next = &last } @@ -300,9 +283,7 @@ func TestIterContextCancellation(t *testing.T) { return query + 1 } - for item, err := range Iter( - ctx, 0, fetchPage, setCursor, - ) { + for item, err := range Iter(ctx, 0, fetchPage, setCursor) { if err != nil { iterErr = err break @@ -323,8 +304,8 @@ func TestIterContextCancellation(t *testing.T) { } } -// TestIterConsumerBreaks tests the page iterator when the consumer breaks out -// of the loop. +// TestIterConsumerBreaks verifies that Iter stops without fetching another page +// when the consumer breaks early. func TestIterConsumerBreaks(t *testing.T) { t.Parallel() @@ -345,9 +326,6 @@ func TestIterConsumerBreaks(t *testing.T) { wantNextCalls: 0, }, { - // The consumer stops after consuming all items in the first page. - // setCursor is never called because the break happens before the - // iterator advances to the next page. name: "at page boundary", pages: [][]int{{1, 2}, {3, 4}}, stopAfter: 2, @@ -367,8 +345,8 @@ func TestIterConsumerBreaks(t *testing.T) { gotItems []int ) - fetchPage := func(_ context.Context, _ int) (Result[int, int], - error) { + fetchPage := func(_ context.Context, + _ int) (Result[int, int], error) { require.Less(t, fetchCalls, len(tc.pages)) @@ -376,9 +354,7 @@ func TestIterConsumerBreaks(t *testing.T) { hasMore := fetchCalls < len(tc.pages)-1 fetchCalls++ - result := Result[int, int]{ - Items: items, - } + result := Result[int, int]{Items: items} if hasMore && len(items) > 0 { last := items[len(items)-1] result.Next = &last @@ -389,13 +365,10 @@ func TestIterConsumerBreaks(t *testing.T) { setCursor := func(query int, cursor int) int { nextCalls++ - return query + cursor } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { require.NoError(t, err) gotItems = append(gotItems, item) @@ -411,8 +384,8 @@ func TestIterConsumerBreaks(t *testing.T) { } } -// TestIterNextNilTermination verifies Iter stops when Next becomes nil, -// without requiring an extra empty-page fetch. +// TestIterNextNilTermination verifies Iter stops when Next becomes nil without +// requiring an extra empty-page fetch. func TestIterNextNilTermination(t *testing.T) { t.Parallel() @@ -433,7 +406,7 @@ func TestIterNextNilTermination(t *testing.T) { wantNextCalls: 0, }, { - name: "multi-page, stops mid", + name: "multi-page stops at nil next", pages: []Result[int, int]{ {Items: []int{1, 2}, Next: intPtr(2)}, {Items: []int{3}}, @@ -454,8 +427,8 @@ func TestIterNextNilTermination(t *testing.T) { gotItems []int ) - fetchPage := func(_ context.Context, _ int) (Result[int, int], - error) { + fetchPage := func(_ context.Context, + _ int) (Result[int, int], error) { require.Less(t, fetchCalls, len(tc.pages)) @@ -471,9 +444,7 @@ func TestIterNextNilTermination(t *testing.T) { return query + 1 } - for item, err := range Iter( - t.Context(), 0, fetchPage, setCursor, - ) { + for item, err := range Iter(t.Context(), 0, fetchPage, setCursor) { require.NoError(t, err) gotItems = append(gotItems, item) diff --git a/wallet/internal/db/page/request.go b/wallet/internal/db/page/request.go index 8653199fab..6adcd4759b 100644 --- a/wallet/internal/db/page/request.go +++ b/wallet/internal/db/page/request.go @@ -1,95 +1,31 @@ package page -const ( - // DefaultLimit is the default number of items that can be returned to a - // page. - DefaultLimit = 100 - - // MaxLimit is the maximum number of items that can be returned to a page. - // Store implementations may fetch MaxLimit+1 rows internally to detect - // whether another page exists. - MaxLimit = 1000 -) - -// Request holds the parameters for a paginated list query. The zero value is -// valid and requests the first page at DefaultLimit. All With* methods -// return a modified shallow copy of the request. +// Request holds the parameters for a paginated list query. // -// Fields are unexported so that callers must use the With* methods and -// accessor functions. This design preserves the normalization of limit (zero -// maps to DefaultLimit via normalizedLimit()) and ensures consistency across -// all page operations. +// Limit must be greater than zero. type Request[Cursor any] struct { - // limit is the maximum number of items to return per page. - limit uint32 - - // after is the pagination cursor that marks where the next page - // starts after. Nil means the first page. - after *Cursor -} - -// QueryLimit returns the number of rows the SQL query should fetch. Queries -// fetch normalizedLimit+1 rows, so BuildResult can use the extra row as a -// lookahead signal to determine whether another page exists. -func (r Request[Cursor]) QueryLimit() uint32 { - return r.normalizedLimit() + 1 -} - -// WithLimit returns a copy of the request with the limit replaced. The limit is -// not validated here; normalization (zero -> DefaultLimit, over MaxLimit -> -// MaxLimit) happens in normalizedLimit() and QueryLimit(). A caller passing -// 0 or a large value will not see an error, the value will just be normalized -// later. -func (r Request[Cursor]) WithLimit(limit uint32) Request[Cursor] { - r.limit = limit - - return r -} - -// After returns the pagination after from the previous page. -// A false ok return value means the first page is being requested. -func (r Request[Cursor]) After() (Cursor, bool) { - if r.after == nil { - var zero Cursor - - return zero, false - } - - return *r.after, true -} + // Limit is the maximum number of items to return in one page. + Limit uint32 -// WithAfter returns a copy of the request with the after replaced. -// Calling this on a zero-value Request produces a request for the second page -// (the page after this after). It takes the after by value to avoid -// the caller retaining a pointer into the Request. -func (r Request[Cursor]) WithAfter(after Cursor) Request[Cursor] { - r.after = &after - - return r + // After is the cursor identifying where the next page starts after. Nil + // means the request targets the first page. + After *Cursor } -// BuildResult assembles a page.Result from a slice of items already fetched by -// the caller. It uses r.normalizedLimit to determine whether the query fetched -// an extra lookahead row. The toCursor function is called on the last item of -// the possibly trimmed slice. -// -// An empty slice always returns an empty result. +// BuildResult assembles a page result from a slice of items already fetched by +// the caller. // -// If len(items) is greater than normalizedLimit, it trims to normalizedLimit -// and sets Next to the after of the last item in the trimmed slice. -// Otherwise, Next is nil. -func BuildResult[Cursor, Item any](r Request[Cursor], items []Item, +// The caller must pass a positive limit. BuildResult trims an extra lookahead +// row when present and derives Next from the last retained item. +func BuildResult[Cursor, Item any](items []Item, limit uint32, nextOf func(Item) Cursor) Result[Item, Cursor] { if len(items) == 0 { return Result[Item, Cursor]{Items: items} } - limit := r.normalizedLimit() if len(items) <= int(limit) { - return Result[Item, Cursor]{ - Items: items, - } + return Result[Item, Cursor]{Items: items} } items = items[:int(limit)] @@ -101,19 +37,3 @@ func BuildResult[Cursor, Item any](r Request[Cursor], items []Item, Next: &cursor, } } - -// normalizedLimit returns the normalized requested page limit for this request. -// A limit of zero returns DefaultLimit. A limit greater than MaxLimit is -// clamped to MaxLimit. -func (r Request[Cursor]) normalizedLimit() uint32 { - switch { - case r.limit == 0: - return DefaultLimit - - case r.limit > MaxLimit: - return MaxLimit - - default: - return r.limit - } -} diff --git a/wallet/internal/db/page/request_test.go b/wallet/internal/db/page/request_test.go index e1de74ca5a..c64ab4e95f 100644 --- a/wallet/internal/db/page/request_test.go +++ b/wallet/internal/db/page/request_test.go @@ -6,183 +6,9 @@ import ( "github.com/stretchr/testify/require" ) -// TestRequestSize verifies that normalizedLimit normalizes the raw limit field. -func TestRequestSize(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - size uint32 - wantSize uint32 - }{ - { - name: "zero defaults to DefaultLimit", - size: 0, - wantSize: DefaultLimit, - }, - { - name: "minimum in range", - size: 1, - wantSize: 1, - }, - { - name: "exactly MaxLimit passes through", - size: MaxLimit, - wantSize: MaxLimit, - }, - { - name: "over MaxLimit clamped to MaxLimit", - size: uint32(MaxLimit) + 1, - wantSize: MaxLimit, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - request := Request[uint32]{}.WithLimit(tc.size) - require.Equal(t, tc.wantSize, request.normalizedLimit()) - }) - } -} - -// TestQueryLimit verifies that QueryLimit returns normalizedLimit+1, -// including at MaxLimit. -func TestQueryLimit(t *testing.T) { - t.Parallel() - - t.Run("uses limit plus one", func(t *testing.T) { - t.Parallel() - - r := Request[uint32]{}.WithLimit(25) - require.Equal(t, r.normalizedLimit()+1, r.QueryLimit()) - }) - - t.Run("at max page limit", func(t *testing.T) { - t.Parallel() - - r := Request[uint32]{}.WithLimit(MaxLimit) - require.Equal(t, uint32(MaxLimit)+1, r.QueryLimit()) - }) -} - -// TestRequestChaining verifies that chaining With* calls produces the -// expected limit and after on the resulting Request. -func TestRequestChaining(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - request Request[uint32] - wantSize uint32 - wantCursor uint32 - wantHasCursor bool - }{ - { - name: "after nil by default", - request: Request[uint32]{}, - wantSize: DefaultLimit, - wantHasCursor: false, - }, - { - name: "after set without limit", - request: Request[uint32]{}.WithAfter(42), - wantSize: DefaultLimit, - wantCursor: 42, - wantHasCursor: true, - }, - { - name: "limit and after set together", - request: Request[uint32]{}.WithLimit(50).WithAfter(99), - wantSize: 50, - wantCursor: 99, - wantHasCursor: true, - }, - { - name: "after overwrites previous after", - request: Request[uint32]{}.WithAfter(1).WithAfter(2), - wantSize: DefaultLimit, - wantCursor: 2, - wantHasCursor: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - require.Equal(t, tc.wantSize, tc.request.normalizedLimit()) - - if !tc.wantHasCursor { - _, ok := tc.request.After() - require.False(t, ok) - - return - } - - after, ok := tc.request.After() - require.True(t, ok) - require.Equal(t, tc.wantCursor, after) - }) - } -} - -// TestRequestWithSizeImmutability verifies that WithLimit returns a new -// Request and does not modify the original. -func TestRequestWithSizeImmutability(t *testing.T) { - t.Parallel() - - original := Request[uint32]{}.WithLimit(10).WithAfter(7) - updated := original.WithLimit(20) - - require.Equal(t, uint32(10), original.normalizedLimit()) - originalAfter, ok := original.After() - require.True(t, ok) - require.Equal(t, uint32(7), originalAfter) - - require.Equal(t, uint32(20), updated.normalizedLimit()) - updatedAfter, ok := updated.After() - require.True(t, ok) - require.Equal(t, uint32(7), updatedAfter) -} - -// TestRequestWithCursorImmutability verifies that WithAfter returns a new -// Request and does not modify the original. -func TestRequestWithCursorImmutability(t *testing.T) { - t.Parallel() - - original := Request[uint32]{}.WithLimit(10).WithAfter(7) - updated := original.WithAfter(9) - - require.Equal(t, uint32(10), original.normalizedLimit()) - originalAfter, ok := original.After() - require.True(t, ok) - require.Equal(t, uint32(7), originalAfter) - - require.Equal(t, uint32(10), updated.normalizedLimit()) - updatedAfter, ok := updated.After() - require.True(t, ok) - require.Equal(t, uint32(9), updatedAfter) -} - -// TestRequestAfterReturnsCursorCopy verifies that mutating the local cursor -// variable returned by After does not mutate the request's internal cursor. -func TestRequestAfterReturnsCursorCopy(t *testing.T) { - t.Parallel() - - request := Request[uint32]{}.WithAfter(7) - after, ok := request.After() - - require.True(t, ok) - require.Equal(t, uint32(7), after) - - after = 9 - require.Equal(t, uint32(9), after) - - originalAfter, ok := request.After() - require.True(t, ok) - require.Equal(t, uint32(7), originalAfter) +// intPtrRequest returns a pointer to the given int value. +func intPtrRequest(v int) *int { + return &v } // TestBuildResult verifies BuildResult assembles the correct Result using the @@ -195,37 +21,37 @@ func TestBuildResult(t *testing.T) { testCases := []struct { name string items []int - size uint32 + limit uint32 wantItems []int wantNext *int }{ { name: "empty slice returns empty result", items: []int{}, - size: 100, + limit: 100, wantItems: []int{}, wantNext: nil, }, { name: "len less than limit leaves Next nil", items: []int{1, 2}, - size: 5, + limit: 5, wantItems: []int{1, 2}, wantNext: nil, }, { name: "len equal to limit leaves Next nil", items: []int{1, 2}, - size: 2, + limit: 2, wantItems: []int{1, 2}, wantNext: nil, }, { name: "len greater than limit trims and sets Next", items: []int{1, 2, 3}, - size: 2, + limit: 2, wantItems: []int{1, 2}, - wantNext: func() *int { v := 2; return &v }(), + wantNext: intPtrRequest(2), }, } @@ -233,8 +59,7 @@ func TestBuildResult(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - req := Request[int]{}.WithLimit(tc.size) - result := BuildResult(req, tc.items, toCursor) + result := BuildResult(tc.items, tc.limit, toCursor) require.Equal(t, tc.wantItems, result.Items) require.Equal(t, tc.wantNext, result.Next) diff --git a/wallet/internal/db/page/result.go b/wallet/internal/db/page/result.go index 4387aa882b..b7aadf9ccd 100644 --- a/wallet/internal/db/page/result.go +++ b/wallet/internal/db/page/result.go @@ -6,7 +6,7 @@ type Result[T any, C any] struct { // page. Items []T - // Next is the after to use for fetching the next page. It is nil when - // no more pages exist. + // Next is the after to use for fetching the next page. It is nil when no + // more pages exist. Next *C } diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index b21b9fe96c..742029f934 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -37,6 +37,9 @@ func (s *Store) GetAddress(ctx context.Context, // ListAddresses returns a page of addresses matching the given query. func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { + if query.Page.Limit == 0 { + return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit + } items, err := listAddressesByAccount(ctx, s.queries, query) if err != nil { @@ -44,7 +47,7 @@ func (s *Store) ListAddresses(ctx context.Context, } result := page.BuildResult( - query.Page, items, + items, query.Page.Limit, func(item db.AddressInfo) uint32 { return item.ID }, @@ -355,12 +358,12 @@ func buildAddressPageParams( Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), AccountName: q.AccountName, - PageLimit: int64(q.Page.QueryLimit()), + PageLimit: int64(q.Page.Limit) + 1, } - if cursor, ok := q.Page.After(); ok { + if q.Page.After != nil { params.CursorID = sql.NullInt64{ - Int64: int64(cursor), + Int64: int64(*q.Page.After), Valid: true, } } diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index 8d7fc58135..1e29566761 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -147,6 +147,10 @@ func (s *Store) GetWallet(ctx context.Context, func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { + if query.Page.Limit == 0 { + return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit + } + rows, err := s.queries.ListWallets(ctx, listWalletsParams(query.Page)) if err != nil { return page.Result[db.WalletInfo, uint32]{}, @@ -165,7 +169,7 @@ func (s *Store) ListWallets(ctx context.Context, } result := page.BuildResult( - query.Page, items, + items, query.Page.Limit, func(item db.WalletInfo) uint32 { return item.ID }, @@ -322,12 +326,12 @@ func listWalletsParams( req page.Request[uint32]) sqlc.ListWalletsParams { params := sqlc.ListWalletsParams{ - PageLimit: int64(req.QueryLimit()), + PageLimit: int64(req.Limit) + 1, } - if cursor, ok := req.After(); ok { + if req.After != nil { params.CursorID = sql.NullInt64{ - Int64: int64(cursor), + Int64: int64(*req.After), Valid: true, } } diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 1dea5eeaec..0bd647be7c 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -37,6 +37,9 @@ func (s *Store) GetAddress(ctx context.Context, // ListAddresses returns a page of addresses matching the given query. func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { + if query.Page.Limit == 0 { + return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit + } items, err := listAddressesByAccount(ctx, s.queries, query) if err != nil { @@ -44,7 +47,7 @@ func (s *Store) ListAddresses(ctx context.Context, } result := page.BuildResult( - query.Page, items, + items, query.Page.Limit, func(item db.AddressInfo) uint32 { return item.ID }, @@ -351,11 +354,11 @@ func buildAddressPageParams( Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), AccountName: q.AccountName, - PageLimit: int64(q.Page.QueryLimit()), + PageLimit: int64(q.Page.Limit) + 1, } - if cursor, ok := q.Page.After(); ok { - params.CursorID = int64(cursor) + if q.Page.After != nil { + params.CursorID = int64(*q.Page.After) } return params diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index eaf0f9fd85..16a2bbc0a4 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -147,6 +147,10 @@ func (s *Store) GetWallet(ctx context.Context, func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { + if query.Page.Limit == 0 { + return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit + } + rows, err := s.queries.ListWallets( ctx, listWalletsParams(query.Page), ) @@ -167,7 +171,7 @@ func (s *Store) ListWallets(ctx context.Context, } result := page.BuildResult( - query.Page, items, + items, query.Page.Limit, func(item db.WalletInfo) uint32 { return item.ID }, @@ -325,11 +329,11 @@ func listWalletsParams( req page.Request[uint32]) sqlc.ListWalletsParams { params := sqlc.ListWalletsParams{ - PageLimit: int64(req.QueryLimit()), + PageLimit: int64(req.Limit) + 1, } - if cursor, ok := req.After(); ok { - params.CursorID = int64(cursor) + if req.After != nil { + params.CursorID = int64(*req.After) } return params diff --git a/wallet/internal/db/wallets_common.go b/wallet/internal/db/wallets_common.go index cfeb7b5f3d..64f610f769 100644 --- a/wallet/internal/db/wallets_common.go +++ b/wallet/internal/db/wallets_common.go @@ -3,7 +3,7 @@ package db // NextListWalletsQuery returns a query with its pagination cursor advanced to // the provided value. func NextListWalletsQuery(q ListWalletsQuery, cursor uint32) ListWalletsQuery { - q.Page = q.Page.WithAfter(cursor) + q.Page.After = &cursor return q } From 9b9d1032370339b694a5e722a86d72f4335cd89b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 13 Apr 2026 17:48:03 +0800 Subject: [PATCH 545/691] wallet: validate page limits on construction Hide the page limit behind Request construction so callers cannot set it to zero with struct literals while still allowing After to remain a plain continuation token. This keeps page-by-page usage direct while pushing limit validation closer to request creation instead of relying solely on store entry points. Use page.NewRequest in pagination tests and route store code through Request.Limit so SQL builders and result assembly continue to use the same positive limit contract. Keep the store-side zero-limit checks as defensive validation for zero-valued requests. --- wallet/internal/db/interface.go | 2 +- .../internal/db/itest/address_store_test.go | 42 ++++++++-------- wallet/internal/db/itest/wallet_store_test.go | 50 +++++++++++-------- wallet/internal/db/page/doc.go | 3 +- wallet/internal/db/page/request.go | 35 ++++++++++--- wallet/internal/db/page/request_test.go | 43 +++++++++++++++- wallet/internal/db/pg/addresses.go | 7 +-- wallet/internal/db/pg/wallet.go | 6 +-- wallet/internal/db/sqlite/addresses.go | 7 +-- wallet/internal/db/sqlite/wallet.go | 6 +-- 10 files changed, 137 insertions(+), 64 deletions(-) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 4f61052883..49f531beec 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -52,7 +52,7 @@ var ( // ErrInvalidPageLimit is returned when a paginated query is called with a // zero page limit. - ErrInvalidPageLimit = errors.New("page limit must be greater than zero") + ErrInvalidPageLimit = page.ErrInvalidLimit // ErrMissingScriptPubKey is returned when creating an imported // address without the required script public key. diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index f136caa2ec..d05a32ad15 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -738,7 +738,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "test-account", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } }, wantCount: 5, @@ -766,7 +766,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "empty-account", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } }, wantCount: 0, @@ -804,7 +804,7 @@ func TestListAddresses(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0044, AccountName: "bip44-multi", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } }, wantCount: 3, @@ -1013,7 +1013,7 @@ func TestListAddressesOrdering(t *testing.T) { WalletID: walletID, Scope: db.KeyScopeBIP0084, AccountName: "ordering-account", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), }, ) @@ -1071,7 +1071,7 @@ func TestListAddressesPagination(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "account-a", - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListAddresses(t.Context(), query) @@ -1129,7 +1129,7 @@ func TestListAddressesExactBoundary(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListAddresses(t.Context(), query) @@ -1168,7 +1168,7 @@ func TestListAddressesPagedEmptyResult(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "empty-account", - Page: page.Request[uint32]{Limit: uint32(pageSize)}, + Page: newTestReq[uint32](t, uint32(pageSize)), }) require.NoError(t, err) require.Empty(t, pageResult.Items) @@ -1193,7 +1193,7 @@ func TestListAddressesDeterministicPagination(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), }) require.Len(t, pages, 3) require.Len(t, pages[0].Items, 2) @@ -1265,7 +1265,7 @@ func TestListAddressesAccountIsolation(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "account-a", - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), }) addresses := flattenAddressPages(pages) @@ -1295,7 +1295,7 @@ func TestListAddressesInsertAfterCursor(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListAddresses(t.Context(), query) require.NoError(t, err) @@ -1330,27 +1330,27 @@ func TestListAddressesCursorEdges(t *testing.T) { createDerivedAccount(t, store, walletID, scope, accountName) createDerivedAddresses(t, store, walletID, scope, accountName, false, 3) + staleReq := newTestReq[uint32](t, 2) + staleReq.After = uint32Ptr(math.MaxUint32) + stalePage, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(math.MaxUint32), - }, + Page: staleReq, }) require.NoError(t, err) require.Empty(t, stalePage.Items) require.Nil(t, stalePage.Next) + zeroReq := newTestReq[uint32](t, 2) + zeroReq.After = uint32Ptr(0) + zeroPage, err := store.ListAddresses(t.Context(), db.ListAddressesQuery{ WalletID: walletID, Scope: scope, AccountName: accountName, - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(0), - }, + Page: zeroReq, }) require.NoError(t, err) require.Len(t, zeroPage.Items, 2) @@ -1378,7 +1378,7 @@ func TestIterAddresses(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "iter-account", - Page: page.Request[uint32]{Limit: uint32(pageSize)}, + Page: newTestReq[uint32](t, uint32(pageSize)), } iterAddrs := make([]db.AddressInfo, 0, len(expected)) @@ -1411,7 +1411,7 @@ func TestIterAddressesPaginated(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "iter-account", - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } pages := collectAddressPages(t, store, query) @@ -1445,7 +1445,7 @@ func TestIterAddressesEmpty(t *testing.T) { WalletID: walletID, Scope: scope, AccountName: "empty-account", - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } for addr, err := range store.IterAddresses(t.Context(), query) { diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index a4c3d416f8..b9150e23c4 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -17,6 +17,16 @@ func uint32Ptr(v uint32) *uint32 { return &v } +// newTestReq constructs a valid pagination request for tests. +func newTestReq[C any](t *testing.T, limit uint32) page.Request[C] { + t.Helper() + + req, err := page.NewRequest[C](limit) + require.NoError(t, err) + + return req +} + // TestCreateWallet verifies that CreateWallet correctly creates a wallet // and returns its information. func TestCreateWallet(t *testing.T) { @@ -161,7 +171,7 @@ func TestListWallets(t *testing.T) { // Initially empty. query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 10}, + Page: newTestReq[uint32](t, 10), } pageResult, err := store.ListWallets(t.Context(), query) @@ -214,7 +224,7 @@ func TestListWalletsPagination(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListWallets(t.Context(), query) @@ -261,7 +271,7 @@ func TestListWalletsExactBoundary(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListWallets(t.Context(), query) @@ -304,7 +314,7 @@ func TestIterWallets(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } expected := flattenWalletPages(collectWalletPages(t, store, query)) @@ -332,7 +342,7 @@ func TestIterWalletsPaginated(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } pages := collectWalletPages(t, store, query) @@ -368,11 +378,11 @@ func TestListWalletsPagedFromCursor(t *testing.T) { created = append(created, wallet) } + pageReq := newTestReq[uint32](t, 2) + pageReq.After = uint32Ptr(created[1].ID) + query := db.ListWalletsQuery{ - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(created[1].ID), - }, + Page: pageReq, } pageResult, err := store.ListWallets(t.Context(), query) @@ -428,7 +438,7 @@ func TestListWalletsPagedWithSyncMetadata(t *testing.T) { require.NoError(t, err) query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 1}, + Page: newTestReq[uint32](t, 1), } page1, err := store.ListWallets(t.Context(), query) @@ -471,7 +481,7 @@ func TestListWalletsDeterministicPagination(t *testing.T) { } pages := collectWalletPages(t, store, db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), }) require.Len(t, pages, 3) require.Len(t, pages[0].Items, 2) @@ -539,7 +549,7 @@ func TestListWalletsInsertAfterCursor(t *testing.T) { } query := db.ListWalletsQuery{ - Page: page.Request[uint32]{Limit: 2}, + Page: newTestReq[uint32](t, 2), } page1, err := store.ListWallets(t.Context(), query) require.NoError(t, err) @@ -577,21 +587,21 @@ func TestListWalletsCursorEdges(t *testing.T) { require.NoError(t, err) } + staleReq := newTestReq[uint32](t, 2) + staleReq.After = uint32Ptr(math.MaxUint32) + stalePage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(math.MaxUint32), - }, + Page: staleReq, }) require.NoError(t, err) require.Empty(t, stalePage.Items) require.Nil(t, stalePage.Next) + zeroReq := newTestReq[uint32](t, 2) + zeroReq.After = uint32Ptr(0) + zeroPage, err := store.ListWallets(t.Context(), db.ListWalletsQuery{ - Page: page.Request[uint32]{ - Limit: 2, - After: uint32Ptr(0), - }, + Page: zeroReq, }) require.NoError(t, err) require.Len(t, zeroPage.Items, 2) diff --git a/wallet/internal/db/page/doc.go b/wallet/internal/db/page/doc.go index b864b87a9b..59465b91ba 100644 --- a/wallet/internal/db/page/doc.go +++ b/wallet/internal/db/page/doc.go @@ -4,7 +4,8 @@ // # Core types // // A [Request] carries the parameters for a single page fetch: page limit, -// and an optional after that identifies where the previous page ended. +// and an optional after that identifies where the previous page ended. Use +// [NewRequest] to construct a request with a positive page limit. // // A [Result] carries the items returned by one fetch together with // [Result.Next]. Assign [Result.Next] to [Request.After] to advance to the next diff --git a/wallet/internal/db/page/request.go b/wallet/internal/db/page/request.go index 6adcd4759b..aaa4ca9d60 100644 --- a/wallet/internal/db/page/request.go +++ b/wallet/internal/db/page/request.go @@ -1,29 +1,48 @@ package page +import "errors" + +// ErrInvalidLimit is returned when a request is created with a zero page limit. +var ErrInvalidLimit = errors.New("page limit must be greater than zero") + // Request holds the parameters for a paginated list query. // -// Limit must be greater than zero. +// Use NewRequest to construct a request with a positive page limit. type Request[Cursor any] struct { - // Limit is the maximum number of items to return in one page. - Limit uint32 + limit uint32 - // After is the cursor identifying where the next page starts after. Nil - // means the request targets the first page. + // After is the cursor identifying where the next page starts after. + // Nil means the request targets the first page. After *Cursor } +// NewRequest returns a Request with the given positive page limit. +func NewRequest[Cursor any](limit uint32) (Request[Cursor], error) { + if limit == 0 { + return Request[Cursor]{}, ErrInvalidLimit + } + + return Request[Cursor]{limit: limit}, nil +} + +// Limit returns the configured page size. +func (r Request[Cursor]) Limit() uint32 { + return r.limit +} + // BuildResult assembles a page result from a slice of items already fetched by // the caller. // -// The caller must pass a positive limit. BuildResult trims an extra lookahead -// row when present and derives Next from the last retained item. -func BuildResult[Cursor, Item any](items []Item, limit uint32, +// BuildResult uses the request limit to trim an extra lookahead row when +// present and derives Next from the last retained item. +func BuildResult[Cursor, Item any](r Request[Cursor], items []Item, nextOf func(Item) Cursor) Result[Item, Cursor] { if len(items) == 0 { return Result[Item, Cursor]{Items: items} } + limit := r.Limit() if len(items) <= int(limit) { return Result[Item, Cursor]{Items: items} } diff --git a/wallet/internal/db/page/request_test.go b/wallet/internal/db/page/request_test.go index c64ab4e95f..267a7023fc 100644 --- a/wallet/internal/db/page/request_test.go +++ b/wallet/internal/db/page/request_test.go @@ -11,6 +11,44 @@ func intPtrRequest(v int) *int { return &v } +// TestNewRequest verifies NewRequest validates and stores the page limit. +func TestNewRequest(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + limit uint32 + wantLimit uint32 + wantErr error + }{ + { + name: "zero limit rejected", + limit: 0, + wantErr: ErrInvalidLimit, + }, + { + name: "positive limit accepted", + limit: 5, + wantLimit: 5, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req, err := NewRequest[int](tc.limit) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tc.wantLimit, req.Limit()) + }) + } +} + // TestBuildResult verifies BuildResult assembles the correct Result using the // lookahead row trimming and Next logic. func TestBuildResult(t *testing.T) { @@ -59,7 +97,10 @@ func TestBuildResult(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - result := BuildResult(tc.items, tc.limit, toCursor) + req, err := NewRequest[int](tc.limit) + require.NoError(t, err) + + result := BuildResult(req, tc.items, toCursor) require.Equal(t, tc.wantItems, result.Items) require.Equal(t, tc.wantNext, result.Next) diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 742029f934..fc16cd427c 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -37,7 +37,8 @@ func (s *Store) GetAddress(ctx context.Context, // ListAddresses returns a page of addresses matching the given query. func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { - if query.Page.Limit == 0 { + + if query.Page.Limit() == 0 { return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit } @@ -47,7 +48,7 @@ func (s *Store) ListAddresses(ctx context.Context, } result := page.BuildResult( - items, query.Page.Limit, + query.Page, items, func(item db.AddressInfo) uint32 { return item.ID }, @@ -358,7 +359,7 @@ func buildAddressPageParams( Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), AccountName: q.AccountName, - PageLimit: int64(q.Page.Limit) + 1, + PageLimit: int64(q.Page.Limit()) + 1, } if q.Page.After != nil { diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index 1e29566761..6b121eef15 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -147,7 +147,7 @@ func (s *Store) GetWallet(ctx context.Context, func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { - if query.Page.Limit == 0 { + if query.Page.Limit() == 0 { return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit } @@ -169,7 +169,7 @@ func (s *Store) ListWallets(ctx context.Context, } result := page.BuildResult( - items, query.Page.Limit, + query.Page, items, func(item db.WalletInfo) uint32 { return item.ID }, @@ -326,7 +326,7 @@ func listWalletsParams( req page.Request[uint32]) sqlc.ListWalletsParams { params := sqlc.ListWalletsParams{ - PageLimit: int64(req.Limit) + 1, + PageLimit: int64(req.Limit()) + 1, } if req.After != nil { diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 0bd647be7c..838efde2b4 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -37,7 +37,8 @@ func (s *Store) GetAddress(ctx context.Context, // ListAddresses returns a page of addresses matching the given query. func (s *Store) ListAddresses(ctx context.Context, query db.ListAddressesQuery) (page.Result[db.AddressInfo, uint32], error) { - if query.Page.Limit == 0 { + + if query.Page.Limit() == 0 { return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit } @@ -47,7 +48,7 @@ func (s *Store) ListAddresses(ctx context.Context, } result := page.BuildResult( - items, query.Page.Limit, + query.Page, items, func(item db.AddressInfo) uint32 { return item.ID }, @@ -354,7 +355,7 @@ func buildAddressPageParams( Purpose: int64(q.Scope.Purpose), CoinType: int64(q.Scope.Coin), AccountName: q.AccountName, - PageLimit: int64(q.Page.Limit) + 1, + PageLimit: int64(q.Page.Limit()) + 1, } if q.Page.After != nil { diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 16a2bbc0a4..03d1fd2707 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -147,7 +147,7 @@ func (s *Store) GetWallet(ctx context.Context, func (s *Store) ListWallets(ctx context.Context, query db.ListWalletsQuery) (page.Result[db.WalletInfo, uint32], error) { - if query.Page.Limit == 0 { + if query.Page.Limit() == 0 { return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit } @@ -171,7 +171,7 @@ func (s *Store) ListWallets(ctx context.Context, } result := page.BuildResult( - items, query.Page.Limit, + query.Page, items, func(item db.WalletInfo) uint32 { return item.ID }, @@ -329,7 +329,7 @@ func listWalletsParams( req page.Request[uint32]) sqlc.ListWalletsParams { params := sqlc.ListWalletsParams{ - PageLimit: int64(req.Limit) + 1, + PageLimit: int64(req.Limit()) + 1, } if req.After != nil { From bb0ee776d2002e99c0a31a0c17e404db94e6ceff Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Apr 2026 11:15:59 -0300 Subject: [PATCH 546/691] wallet: cleanup import aliases --- sqlc.yaml | 4 +-- wallet/internal/db/db_connectors_test.go | 30 +++++++++---------- wallet/internal/db/itest/fixtures_pg_test.go | 8 ++--- .../internal/db/itest/fixtures_sqlite_test.go | 8 ++--- wallet/internal/db/itest/pg_test.go | 20 ++++++------- wallet/internal/db/itest/sqlite_test.go | 20 ++++++------- .../db/itest/tx_corruption_pg_test.go | 12 ++++---- .../db/itest/tx_corruption_sqlite_test.go | 12 ++++---- .../db/itest/tx_store_corruption_test.go | 6 ++-- .../db/itest/utxo_store_edge_cases_test.go | 4 +-- wallet/internal/db/pg/accounts.go | 4 +-- wallet/internal/db/pg/address_types.go | 4 +-- wallet/internal/db/pg/addresses.go | 4 +-- wallet/internal/db/pg/backend_error_test.go | 4 +-- wallet/internal/db/pg/backend_rows_test.go | 4 +-- wallet/internal/db/pg/block.go | 4 +-- wallet/internal/db/pg/config.go | 2 +- wallet/internal/db/pg/config_test.go | 2 +- wallet/internal/db/pg/itest.go | 8 ++--- wallet/internal/db/pg/store.go | 8 ++--- wallet/internal/db/pg/testhelpers_test.go | 2 +- wallet/internal/db/pg/txstore_createtx.go | 4 +-- wallet/internal/db/pg/txstore_deletetx.go | 4 +-- wallet/internal/db/pg/txstore_gettx.go | 4 +-- .../db/pg/txstore_invalidateunmined.go | 4 +-- wallet/internal/db/pg/txstore_listtxns.go | 4 +-- wallet/internal/db/pg/txstore_rollback.go | 4 +-- wallet/internal/db/pg/txstore_updatetx.go | 4 +-- wallet/internal/db/pg/utxostore_balance.go | 4 +-- wallet/internal/db/pg/utxostore_getutxo.go | 4 +-- .../internal/db/pg/utxostore_leaseoutput.go | 4 +-- .../db/pg/utxostore_listleasedoutputs.go | 4 +-- wallet/internal/db/pg/utxostore_listutxos.go | 4 +-- .../internal/db/pg/utxostore_releaseoutput.go | 4 +-- wallet/internal/db/pg/wallet.go | 4 +-- wallet/internal/db/sqlite/accounts.go | 4 +-- wallet/internal/db/sqlite/address_types.go | 4 +-- wallet/internal/db/sqlite/addresses.go | 4 +-- .../internal/db/sqlite/backend_error_test.go | 4 +-- .../internal/db/sqlite/backend_rows_test.go | 4 +-- wallet/internal/db/sqlite/block.go | 4 +-- wallet/internal/db/sqlite/config.go | 2 +- wallet/internal/db/sqlite/config_test.go | 2 +- wallet/internal/db/sqlite/itest.go | 8 ++--- wallet/internal/db/sqlite/store.go | 8 ++--- wallet/internal/db/sqlite/testhelpers_test.go | 2 +- wallet/internal/db/sqlite/txstore_createtx.go | 4 +-- wallet/internal/db/sqlite/txstore_deletetx.go | 4 +-- wallet/internal/db/sqlite/txstore_gettx.go | 4 +-- .../db/sqlite/txstore_invalidateunmined.go | 4 +-- wallet/internal/db/sqlite/txstore_listtxns.go | 4 +-- wallet/internal/db/sqlite/txstore_rollback.go | 4 +-- wallet/internal/db/sqlite/txstore_updatetx.go | 4 +-- .../internal/db/sqlite/utxostore_balance.go | 4 +-- .../internal/db/sqlite/utxostore_getutxo.go | 4 +-- .../db/sqlite/utxostore_leaseoutput.go | 4 +-- .../db/sqlite/utxostore_listleasedoutputs.go | 4 +-- .../internal/db/sqlite/utxostore_listutxos.go | 4 +-- .../db/sqlite/utxostore_releaseoutput.go | 4 +-- wallet/internal/db/sqlite/wallet.go | 4 +-- wallet/internal/sql/pg/sqlc/accounts.sql.go | 2 +- .../internal/sql/pg/sqlc/address_types.sql.go | 2 +- wallet/internal/sql/pg/sqlc/addresses.sql.go | 2 +- wallet/internal/sql/pg/sqlc/blocks.sql.go | 2 +- wallet/internal/sql/pg/sqlc/db.go | 2 +- wallet/internal/sql/pg/sqlc/key_scopes.sql.go | 2 +- wallet/internal/sql/pg/sqlc/models.go | 2 +- wallet/internal/sql/pg/sqlc/querier.go | 2 +- .../internal/sql/pg/sqlc/transactions.sql.go | 2 +- .../sql/pg/sqlc/tx_replacements.sql.go | 2 +- .../internal/sql/pg/sqlc/utxo_leases.sql.go | 2 +- wallet/internal/sql/pg/sqlc/utxos.sql.go | 2 +- wallet/internal/sql/pg/sqlc/wallets.sql.go | 2 +- .../internal/sql/sqlite/sqlc/accounts.sql.go | 2 +- .../sql/sqlite/sqlc/address_types.sql.go | 2 +- .../internal/sql/sqlite/sqlc/addresses.sql.go | 2 +- wallet/internal/sql/sqlite/sqlc/blocks.sql.go | 2 +- wallet/internal/sql/sqlite/sqlc/db.go | 2 +- .../sql/sqlite/sqlc/key_scopes.sql.go | 2 +- wallet/internal/sql/sqlite/sqlc/models.go | 2 +- wallet/internal/sql/sqlite/sqlc/querier.go | 2 +- .../sql/sqlite/sqlc/transactions.sql.go | 2 +- .../sql/sqlite/sqlc/tx_replacements.sql.go | 2 +- .../sql/sqlite/sqlc/utxo_leases.sql.go | 2 +- wallet/internal/sql/sqlite/sqlc/utxos.sql.go | 2 +- .../internal/sql/sqlite/sqlc/wallets.sql.go | 2 +- wallet/mock_test.go | 2 +- wallet/utxo_manager.go | 2 +- wallet/utxo_manager_test.go | 2 +- wallet/wallet.go | 2 +- 90 files changed, 194 insertions(+), 194 deletions(-) diff --git a/sqlc.yaml b/sqlc.yaml index 1ee7f931f1..21de01f0d0 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -8,7 +8,7 @@ sql: gen: go: out: "wallet/internal/sql/pg/sqlc" - package: "sqlcpg" + package: "sqlc" # This is the driver package that sqlc will use in the generated code. sql_package: database/sql @@ -29,7 +29,7 @@ sql: gen: go: out: "wallet/internal/sql/sqlite/sqlc" - package: "sqlcsqlite" + package: "sqlc" # This is the driver package that sqlc will use in the generated code. sql_package: database/sql diff --git a/wallet/internal/db/db_connectors_test.go b/wallet/internal/db/db_connectors_test.go index 9185bf8d74..f80473b1a8 100644 --- a/wallet/internal/db/db_connectors_test.go +++ b/wallet/internal/db/db_connectors_test.go @@ -4,9 +4,9 @@ import ( "path/filepath" "testing" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -15,19 +15,19 @@ func TestPostgresNewStoreValidateConfig(t *testing.T) { tests := []struct { name string - cfg dbpg.Config + cfg pg.Config wantErr error }{ { name: "empty DSN", - cfg: dbpg.Config{ + cfg: pg.Config{ Dsn: "", }, wantErr: db.ErrEmptyDSN, }, { name: "negative max connections", - cfg: dbpg.Config{ + cfg: pg.Config{ Dsn: "postgres://test", MaxConnections: -1, }, @@ -39,7 +39,7 @@ func TestPostgresNewStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := dbpg.NewStore(t.Context(), tc.cfg) + store, err := pg.NewStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -50,11 +50,11 @@ func TestPostgresNewStoreConnectionFailure(t *testing.T) { t.Parallel() // Valid config, but hits a connection failure. - cfg := dbpg.Config{ + cfg := pg.Config{ Dsn: "postgres://localhost:1/testdb", } - store, err := dbpg.NewStore(t.Context(), cfg) + store, err := pg.NewStore(t.Context(), cfg) require.Error(t, err) require.ErrorContains(t, err, "ping database") require.NotErrorIs(t, err, db.ErrEmptyDSN) @@ -70,19 +70,19 @@ func TestSQLiteNewStoreValidateConfig(t *testing.T) { tests := []struct { name string - cfg dbsqlite.Config + cfg sqlite.Config wantErr error }{ { name: "empty DB path", - cfg: dbsqlite.Config{ + cfg: sqlite.Config{ DBPath: "", }, wantErr: db.ErrEmptyDBPath, }, { name: "negative max connections", - cfg: dbsqlite.Config{ + cfg: sqlite.Config{ DBPath: "/tmp/test.db", MaxConnections: -1, }, @@ -94,7 +94,7 @@ func TestSQLiteNewStoreValidateConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - store, err := dbsqlite.NewStore(t.Context(), tc.cfg) + store, err := sqlite.NewStore(t.Context(), tc.cfg) require.ErrorIs(t, err, tc.wantErr) require.Nil(t, store) }) @@ -104,11 +104,11 @@ func TestSQLiteNewStoreValidateConfig(t *testing.T) { func TestSQLiteNewStoreSuccess(t *testing.T) { t.Parallel() - cfg := dbsqlite.Config{ + cfg := sqlite.Config{ DBPath: filepath.Join(t.TempDir(), "wallet.db"), } - store, err := dbsqlite.NewStore(t.Context(), cfg) + store, err := sqlite.NewStore(t.Context(), cfg) require.NoError(t, err) require.NotNil(t, store) diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 924e1ae3c5..c029000305 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) @@ -190,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &dbpg.Store{}, store) + require.IsType(t, &pg.Store{}, store) - pgStore := store.(*dbpg.Store) + pgStore := store.(*pg.Store) queries := pgStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 634dcb0a4b..d2f2475e72 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) @@ -190,9 +190,9 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, t.Helper() - require.IsType(t, &dbsqlite.Store{}, store) + require.IsType(t, &sqlite.Store{}, store) - sqliteStore := store.(*dbsqlite.Store) + sqliteStore := store.(*sqlite.Store) queries := sqliteStore.Queries() scopeID := GetKeyScopeID(t, queries, walletID, db.KeyScopeBIP0084) CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go index ac528b8e98..e48929284d 100644 --- a/wallet/internal/db/itest/pg_test.go +++ b/wallet/internal/db/itest/pg_test.go @@ -20,8 +20,8 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" @@ -193,7 +193,7 @@ func sanitizedPgDBName(t *testing.T) string { // limit allows, exhausting the PostgreSQL connection pool. Avoid this by // creating NewTestStore inside each parallel subtest so its lifecycle is tied // to the subtest's parallel slot. -func NewTestStore(t *testing.T) *dbpg.Store { +func NewTestStore(t *testing.T) *pg.Store { t.Helper() ctx := t.Context() @@ -223,12 +223,12 @@ func NewTestStore(t *testing.T) *dbpg.Store { // Build the connection string for the test database. testConnStr := strings.Replace(connStr, "/postgres?", "/"+dbName+"?", 1) - cfg := dbpg.Config{ + cfg := pg.Config{ Dsn: testConnStr, MaxConnections: 0, } - store, err := dbpg.NewStore(t.Context(), cfg) + store, err := pg.NewStore(t.Context(), cfg) require.NoError(t, err, "failed to create postgres store") t.Cleanup(func() { @@ -240,7 +240,7 @@ func NewTestStore(t *testing.T) *dbpg.Store { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *dbpg.Store, +func childSpendingTxIDs(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash) []int64 { @@ -273,7 +273,7 @@ func childSpendingTxIDs(t *testing.T, store *dbpg.Store, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *dbpg.Store, walletID uint32, +func txIDByHash(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() @@ -297,7 +297,7 @@ func txIDByHash(t *testing.T, store *dbpg.Store, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *dbpg.Store, walletID uint32, +func rawTxByHash(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() @@ -315,7 +315,7 @@ func rawTxByHash(t *testing.T, store *dbpg.Store, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *dbpg.Store, walletID uint32, +func setTxStatus(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -336,7 +336,7 @@ func setTxStatus(t *testing.T, store *dbpg.Store, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *dbpg.Store, +func walletUtxoExists(t *testing.T, store *pg.Store, walletID uint32, outPoint wire.OutPoint) bool { diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go index a2c93da5b2..223e3c4ff4 100644 --- a/wallet/internal/db/itest/sqlite_test.go +++ b/wallet/internal/db/itest/sqlite_test.go @@ -11,25 +11,25 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) // NewTestStore creates a new SQLite database for testing with migrations // applied. Each test gets its own temporary database file. -func NewTestStore(t *testing.T) *dbsqlite.Store { +func NewTestStore(t *testing.T) *sqlite.Store { t.Helper() tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") - cfg := dbsqlite.Config{ + cfg := sqlite.Config{ DBPath: dbPath, MaxConnections: 0, } - store, err := dbsqlite.NewStore(t.Context(), cfg) + store, err := sqlite.NewStore(t.Context(), cfg) require.NoError(t, err, "failed to create sqlite store") t.Cleanup(func() { @@ -41,7 +41,7 @@ func NewTestStore(t *testing.T) *dbsqlite.Store { // childSpendingTxIDs returns the direct child transaction IDs recorded for the // provided parent transaction hash. -func childSpendingTxIDs(t *testing.T, store *dbsqlite.Store, +func childSpendingTxIDs(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash) []int64 { @@ -74,7 +74,7 @@ func childSpendingTxIDs(t *testing.T, store *dbsqlite.Store, // txIDByHash returns the database row ID for the given wallet-scoped // transaction hash and reports whether the row exists. -func txIDByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, +func txIDByHash(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash) (int64, bool) { t.Helper() @@ -98,7 +98,7 @@ func txIDByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, // rawTxByHash returns the serialized transaction bytes for the given // wallet-scoped transaction hash. -func rawTxByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, +func rawTxByHash(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash) []byte { t.Helper() @@ -116,7 +116,7 @@ func rawTxByHash(t *testing.T, store *dbsqlite.Store, walletID uint32, // setTxStatus rewrites one wallet-scoped transaction row to the provided // status using the internal status-update query. -func setTxStatus(t *testing.T, store *dbsqlite.Store, walletID uint32, +func setTxStatus(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, status db.TxStatus) { t.Helper() @@ -137,7 +137,7 @@ func setTxStatus(t *testing.T, store *dbsqlite.Store, walletID uint32, // walletUtxoExists reports whether one wallet-scoped outpoint is currently // present in the UTXO set. -func walletUtxoExists(t *testing.T, store *dbsqlite.Store, +func walletUtxoExists(t *testing.T, store *sqlite.Store, walletID uint32, outPoint wire.OutPoint) bool { diff --git a/wallet/internal/db/itest/tx_corruption_pg_test.go b/wallet/internal/db/itest/tx_corruption_pg_test.go index 43884520b3..5fc1b0d5e0 100644 --- a/wallet/internal/db/itest/tx_corruption_pg_test.go +++ b/wallet/internal/db/itest/tx_corruption_pg_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg/chainhash" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -19,7 +19,7 @@ import ( // corruptTransactionStatus writes an invalid tx status after dropping the // validating constraints that normally reject it. The corruption itests use // this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *dbpg.Store, +func corruptTransactionStatus(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -49,7 +49,7 @@ func corruptTransactionStatus(t *testing.T, store *dbpg.Store, // corruptTransactionHash writes malformed tx-hash bytes after dropping the // fixed-length hash check. The corruption itests then verify that hash // decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *dbpg.Store, +func corruptTransactionHash(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -75,7 +75,7 @@ func corruptTransactionHash(t *testing.T, store *dbpg.Store, // the non-negative height check and creating a matching block row. The // corruption itests use this to verify that reads reject impossible // confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *dbpg.Store, +func corruptTransactionBlockHeight(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -110,7 +110,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *dbpg.Store, // corruptUtxoOutputIndex writes an invalid output index after dropping the // non-negative output-index check. The corruption itests then verify that UTXO // decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *dbpg.Store, +func corruptUtxoOutputIndex(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -136,7 +136,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *dbpg.Store, // corruptActiveLeaseLockID writes an invalid lease lock ID after dropping the // fixed-length lock-id check. The corruption itests use this to verify that // lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *dbpg.Store, +func corruptActiveLeaseLockID(t *testing.T, store *pg.Store, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/itest/tx_corruption_sqlite_test.go b/wallet/internal/db/itest/tx_corruption_sqlite_test.go index 2827a8f713..a952c750bf 100644 --- a/wallet/internal/db/itest/tx_corruption_sqlite_test.go +++ b/wallet/internal/db/itest/tx_corruption_sqlite_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg/chainhash" - dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/stretchr/testify/require" ) @@ -19,7 +19,7 @@ import ( // corruptTransactionStatus writes an invalid tx status into one stored row while // sqlite check constraints are disabled inside the surrounding transaction. The // corruption itests use this to verify that reads reject impossible tx states. -func corruptTransactionStatus(t *testing.T, store *dbsqlite.Store, +func corruptTransactionStatus(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, status int64) { t.Helper() @@ -54,7 +54,7 @@ func corruptTransactionStatus(t *testing.T, store *dbsqlite.Store, // corruptTransactionHash writes malformed tx-hash bytes into one stored row // while sqlite check constraints are disabled. The corruption itests then // verify that hash decoding fails with the expected error path. -func corruptTransactionHash(t *testing.T, store *dbsqlite.Store, +func corruptTransactionHash(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, hash []byte) { t.Helper() @@ -89,7 +89,7 @@ func corruptTransactionHash(t *testing.T, store *dbsqlite.Store, // corruptTransactionBlockHeight writes an invalid block height after first // creating a matching block row in sqlite. The corruption itests use this to // verify that reads reject impossible confirmation metadata. -func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.Store, +func corruptTransactionBlockHeight(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, height int64) { t.Helper() @@ -134,7 +134,7 @@ func corruptTransactionBlockHeight(t *testing.T, store *dbsqlite.Store, // corruptUtxoOutputIndex writes an invalid output index into one stored UTXO // while sqlite check constraints are disabled. The corruption itests then // verify that UTXO decoding rejects the malformed persisted value. -func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.Store, +func corruptUtxoOutputIndex(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, oldIndex uint32, newIndex int64) { t.Helper() @@ -170,7 +170,7 @@ func corruptUtxoOutputIndex(t *testing.T, store *dbsqlite.Store, // corruptActiveLeaseLockID writes an invalid lease lock ID into one active // lease row while sqlite check constraints are disabled. The corruption itests // use this to verify that lease reads reject malformed lock identifiers. -func corruptActiveLeaseLockID(t *testing.T, store *dbsqlite.Store, +func corruptActiveLeaseLockID(t *testing.T, store *sqlite.Store, walletID uint32, txHash chainhash.Hash, outputIndex uint32, lockID []byte) { t.Helper() diff --git a/wallet/internal/db/itest/tx_store_corruption_test.go b/wallet/internal/db/itest/tx_store_corruption_test.go index d77a3eb7ee..32e74a8ba1 100644 --- a/wallet/internal/db/itest/tx_store_corruption_test.go +++ b/wallet/internal/db/itest/tx_store_corruption_test.go @@ -11,7 +11,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -22,7 +22,7 @@ func dropTableForCorruption(t *testing.T, store interface{ DB() *sql.DB }, t.Helper() stmt := fmt.Sprintf("DROP TABLE %s", table) - if _, ok := any(store).(*dbpg.Store); ok { + if _, ok := any(store).(*pg.Store); ok { stmt += " CASCADE" } @@ -492,7 +492,7 @@ func TestRollbackToBlockReturnsQueryErrorWhenBlocksTableMissing(t *testing.T) { // wallet_sync_states keeps direct block references with ON DELETE RESTRICT. // PostgreSQL drops those dependent rows with CASCADE when the blocks table is // removed, so rollback gets far enough to fail on the block delete instead. - _, ok := any(store).(*dbpg.Store) + _, ok := any(store).(*pg.Store) if ok { require.ErrorContains(t, err, "delete blocks at or above height") return diff --git a/wallet/internal/db/itest/utxo_store_edge_cases_test.go b/wallet/internal/db/itest/utxo_store_edge_cases_test.go index 563a7c3910..b36a8c6f7b 100644 --- a/wallet/internal/db/itest/utxo_store_edge_cases_test.go +++ b/wallet/internal/db/itest/utxo_store_edge_cases_test.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet/internal/db" - dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/stretchr/testify/require" ) @@ -258,7 +258,7 @@ func TestGetUtxoAndLeaseRejectLargeOutputIndex(t *testing.T) { }, ) - if _, ok := any(store).(*dbpg.Store); ok { + if _, ok := any(store).(*pg.Store); ok { require.ErrorContains(t, err, "convert output index") require.ErrorContains(t, leaseErr, "convert output index") require.ErrorContains(t, releaseErr, "could not cast") diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index 7e1503b789..180a5efeec 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -5,8 +5,8 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Ensure Store satisfies the AccountStore interface. diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index fbbc06f9bf..919bfb6c98 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -3,8 +3,8 @@ package pg import ( "context" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // addressTypeRowToInfo converts a PostgreSQL address type row to an diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index fc16cd427c..d859b095dd 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -7,9 +7,9 @@ import ( "iter" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) var _ db.AddressStore = (*Store)(nil) diff --git a/wallet/internal/db/pg/backend_error_test.go b/wallet/internal/db/pg/backend_error_test.go index d2e9066d5e..4d7497ce9e 100644 --- a/wallet/internal/db/pg/backend_error_test.go +++ b/wallet/internal/db/pg/backend_error_test.go @@ -9,8 +9,8 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/pg/backend_rows_test.go b/wallet/internal/db/pg/backend_rows_test.go index f36e764a1c..6f52369bc3 100644 --- a/wallet/internal/db/pg/backend_rows_test.go +++ b/wallet/internal/db/pg/backend_rows_test.go @@ -7,8 +7,8 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) diff --git a/wallet/internal/db/pg/block.go b/wallet/internal/db/pg/block.go index 8f906eb2f7..e8ea756994 100644 --- a/wallet/internal/db/pg/block.go +++ b/wallet/internal/db/pg/block.go @@ -7,8 +7,8 @@ import ( "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // buildBlock constructs a Block from the given PostgreSQL block diff --git a/wallet/internal/db/pg/config.go b/wallet/internal/db/pg/config.go index 3046c040c6..8a9aed180d 100644 --- a/wallet/internal/db/pg/config.go +++ b/wallet/internal/db/pg/config.go @@ -3,7 +3,7 @@ package pg import ( "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/jackc/pgx/v5" ) diff --git a/wallet/internal/db/pg/config_test.go b/wallet/internal/db/pg/config_test.go index 1cf5fdcf93..9787217a9f 100644 --- a/wallet/internal/db/pg/config_test.go +++ b/wallet/internal/db/pg/config_test.go @@ -3,7 +3,7 @@ package pg import ( "testing" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/pg/itest.go b/wallet/internal/db/pg/itest.go index 0a4f62b514..2255ace7f3 100644 --- a/wallet/internal/db/pg/itest.go +++ b/wallet/internal/db/pg/itest.go @@ -5,8 +5,8 @@ package pg import ( "database/sql" - sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. @@ -21,10 +21,10 @@ func (s *Store) Queries() *sqlc.Queries { // RollbackAllMigrations rolls back all PostgreSQL migrations. func (s *Store) RollbackAllMigrations() error { - return sqlassetpg.RollbackMigrations(s.db) + return pg.RollbackMigrations(s.db) } // ApplyAllMigrations reapplies all PostgreSQL migrations. func (s *Store) ApplyAllMigrations() error { - return sqlassetpg.ApplyMigrations(s.db) + return pg.ApplyMigrations(s.db) } diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index 2b7e4c3a0a..4b734d84c0 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -5,9 +5,9 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlassetpg "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. ) @@ -54,7 +54,7 @@ func NewStore(ctx context.Context, cfg Config) (*Store, queries := sqlc.New(dbConn) - err = sqlassetpg.ApplyMigrations(dbConn) + err = pg.ApplyMigrations(dbConn) if err != nil { _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) diff --git a/wallet/internal/db/pg/testhelpers_test.go b/wallet/internal/db/pg/testhelpers_test.go index 743698f5d1..fa0bf3bfd2 100644 --- a/wallet/internal/db/pg/testhelpers_test.go +++ b/wallet/internal/db/pg/testhelpers_test.go @@ -7,7 +7,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index 687060f16c..0d70ba973f 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -8,8 +8,8 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index d37ed033aa..9e980a4edf 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index 50a117a789..c117d8fcef 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index 82ca2308d7..ca9c0765a7 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index c74bbb934f..222a69b2ea 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -5,8 +5,8 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index 8741fb93ab..ef4978cd52 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -6,8 +6,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index 3e7e94608b..131902632d 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. diff --git a/wallet/internal/db/pg/utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go index 223c7ff397..55125a21a2 100644 --- a/wallet/internal/db/pg/utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -6,8 +6,8 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index 03ecb4a8da..4a2b4f8114 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index 211afd2123..d0f685e270 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. diff --git a/wallet/internal/db/pg/utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go index 0d17605633..7df070a56b 100644 --- a/wallet/internal/db/pg/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -5,8 +5,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index 9673ca15bf..39de82d47a 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index 0876102448..4555f3b932 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index 6b121eef15..e72d5c3662 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -7,9 +7,9 @@ import ( "fmt" "iter" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" ) // Ensure Store satisfies the WalletStore interface. diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index e6315a4f21..b4507f8bcf 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -5,8 +5,8 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Ensure Store satisfies the AccountStore interface. diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index 1f39b389a7..ab12a86503 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -3,8 +3,8 @@ package sqlite import ( "context" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // addressTypeRowToInfo converts a SQLite address type row to an diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 838efde2b4..33cfb386ae 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -7,9 +7,9 @@ import ( "iter" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) var _ db.AddressStore = (*Store)(nil) diff --git a/wallet/internal/db/sqlite/backend_error_test.go b/wallet/internal/db/sqlite/backend_error_test.go index 55909fcded..6b0723217c 100644 --- a/wallet/internal/db/sqlite/backend_error_test.go +++ b/wallet/internal/db/sqlite/backend_error_test.go @@ -6,8 +6,8 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/backend_rows_test.go b/wallet/internal/db/sqlite/backend_rows_test.go index f1df054789..fd8c564e03 100644 --- a/wallet/internal/db/sqlite/backend_rows_test.go +++ b/wallet/internal/db/sqlite/backend_rows_test.go @@ -8,8 +8,8 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/block.go b/wallet/internal/db/sqlite/block.go index 1c4ddc78e4..a3f09dcb04 100644 --- a/wallet/internal/db/sqlite/block.go +++ b/wallet/internal/db/sqlite/block.go @@ -7,8 +7,8 @@ import ( "errors" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // buildBlock constructs a Block from the given SQLite block diff --git a/wallet/internal/db/sqlite/config.go b/wallet/internal/db/sqlite/config.go index f9aba8086e..a069a01a9e 100644 --- a/wallet/internal/db/sqlite/config.go +++ b/wallet/internal/db/sqlite/config.go @@ -1,6 +1,6 @@ package sqlite -import db "github.com/btcsuite/btcwallet/wallet/internal/db" +import "github.com/btcsuite/btcwallet/wallet/internal/db" // Config holds the configuration for the SQLite database. type Config struct { diff --git a/wallet/internal/db/sqlite/config_test.go b/wallet/internal/db/sqlite/config_test.go index c3dc9c7e4c..cc378647e3 100644 --- a/wallet/internal/db/sqlite/config_test.go +++ b/wallet/internal/db/sqlite/config_test.go @@ -3,7 +3,7 @@ package sqlite import ( "testing" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/itest.go b/wallet/internal/db/sqlite/itest.go index 623078d13f..7529e793f2 100644 --- a/wallet/internal/db/sqlite/itest.go +++ b/wallet/internal/db/sqlite/itest.go @@ -5,8 +5,8 @@ package sqlite import ( "database/sql" - sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DB returns the underlying *sql.DB connection for integration testing. @@ -21,10 +21,10 @@ func (s *Store) Queries() *sqlc.Queries { // RollbackAllMigrations rolls back all SQLite migrations. func (s *Store) RollbackAllMigrations() error { - return sqlassetsqlite.RollbackMigrations(s.db) + return sqlite.RollbackMigrations(s.db) } // ApplyAllMigrations reapplies all SQLite migrations. func (s *Store) ApplyAllMigrations() error { - return sqlassetsqlite.ApplyMigrations(s.db) + return sqlite.ApplyMigrations(s.db) } diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index 6f08363d82..a93ed3fd72 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -5,9 +5,9 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlassetsqlite "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. ) @@ -60,7 +60,7 @@ func NewStore(ctx context.Context, cfg Config) (*Store, queries := sqlc.New(dbConn) - err = sqlassetsqlite.ApplyMigrations(dbConn) + err = sqlite.ApplyMigrations(dbConn) if err != nil { _ = dbConn.Close() return nil, fmt.Errorf("apply migrations: %w", err) diff --git a/wallet/internal/db/sqlite/testhelpers_test.go b/wallet/internal/db/sqlite/testhelpers_test.go index 8c367d3eb2..eac4ab3dca 100644 --- a/wallet/internal/db/sqlite/testhelpers_test.go +++ b/wallet/internal/db/sqlite/testhelpers_test.go @@ -10,7 +10,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/stretchr/testify/require" ) diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 2c367536f5..71466f0312 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -8,8 +8,8 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // CreateTx atomically records a wallet-scoped transaction row, its wallet-owned diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index c930d83263..d5f59eb3ee 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // DeleteTx atomically removes one unmined transaction and restores any wallet diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index 955d0a71a5..9870a85772 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetTx retrieves one wallet-scoped transaction snapshot by hash. diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index 2c3ede8c7b..3e914f1217 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // InvalidateUnminedTx atomically invalidates one wallet-owned unmined diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index bcfbe12bc3..9581a9dfc5 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -5,8 +5,8 @@ import ( "database/sql" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListTxns lists wallet-scoped transactions using either the confirmed-range diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index 2e2b55686c..21a05a1c3f 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -6,8 +6,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // RollbackToBlock atomically removes every block at or above the provided diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index 6d2ab78d6b..e23a92e412 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/btcsuite/btcd/chaincfg/chainhash" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // UpdateTx patches the mutable metadata for one wallet-scoped transaction. diff --git a/wallet/internal/db/sqlite/utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go index 7e9cca30ad..4a8cda5005 100644 --- a/wallet/internal/db/sqlite/utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -6,8 +6,8 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Balance returns the sum of wallet-owned current UTXOs after optional filters. diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index d5bc3b60cf..c470041807 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // GetUtxo retrieves one current wallet-owned UTXO by outpoint. diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index cbe2c2b3b2..1be9ccb8a7 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // LeaseOutput atomically acquires or renews a lease for one current UTXO. diff --git a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index 68705d245d..b7a6317dcb 100644 --- a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -5,8 +5,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListLeasedOutputs lists all active leases for current wallet-owned UTXOs. diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index 03c1d082d2..2fa7e94e88 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ListUTXOs lists all current wallet-owned UTXOs matching the caller filters. diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index 5cfad1d1e6..e2a453c3d0 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -7,8 +7,8 @@ import ( "fmt" "time" - db "github.com/btcsuite/btcwallet/wallet/internal/db" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // ReleaseOutput atomically releases a lease when the caller provides the diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 03d1fd2707..0e3f29b6b2 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -7,9 +7,9 @@ import ( "fmt" "iter" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wallet/internal/db/page" - sqlc "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" ) // Ensure Store satisfies the WalletStore interface. diff --git a/wallet/internal/sql/pg/sqlc/accounts.sql.go b/wallet/internal/sql/pg/sqlc/accounts.sql.go index 894cd597d1..bfd09a6879 100644 --- a/wallet/internal/sql/pg/sqlc/accounts.sql.go +++ b/wallet/internal/sql/pg/sqlc/accounts.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: accounts.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/address_types.sql.go b/wallet/internal/sql/pg/sqlc/address_types.sql.go index 86e7a4a531..eeb9d1f895 100644 --- a/wallet/internal/sql/pg/sqlc/address_types.sql.go +++ b/wallet/internal/sql/pg/sqlc/address_types.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: address_types.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/addresses.sql.go b/wallet/internal/sql/pg/sqlc/addresses.sql.go index 3ffd8b851a..d6ce305551 100644 --- a/wallet/internal/sql/pg/sqlc/addresses.sql.go +++ b/wallet/internal/sql/pg/sqlc/addresses.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: addresses.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/blocks.sql.go b/wallet/internal/sql/pg/sqlc/blocks.sql.go index b5f83978cb..41071c7263 100644 --- a/wallet/internal/sql/pg/sqlc/blocks.sql.go +++ b/wallet/internal/sql/pg/sqlc/blocks.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: blocks.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/db.go b/wallet/internal/sql/pg/sqlc/db.go index 19e8837f3d..b40f806aa3 100644 --- a/wallet/internal/sql/pg/sqlc/db.go +++ b/wallet/internal/sql/pg/sqlc/db.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/key_scopes.sql.go b/wallet/internal/sql/pg/sqlc/key_scopes.sql.go index 2afd0e7aaa..39608fd021 100644 --- a/wallet/internal/sql/pg/sqlc/key_scopes.sql.go +++ b/wallet/internal/sql/pg/sqlc/key_scopes.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: key_scopes.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/models.go b/wallet/internal/sql/pg/sqlc/models.go index 363466e546..9173419113 100644 --- a/wallet/internal/sql/pg/sqlc/models.go +++ b/wallet/internal/sql/pg/sqlc/models.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcpg +package sqlc import ( "database/sql" diff --git a/wallet/internal/sql/pg/sqlc/querier.go b/wallet/internal/sql/pg/sqlc/querier.go index 2fc726dbc0..963865d03c 100644 --- a/wallet/internal/sql/pg/sqlc/querier.go +++ b/wallet/internal/sql/pg/sqlc/querier.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/transactions.sql.go b/wallet/internal/sql/pg/sqlc/transactions.sql.go index c4406f509d..0c30cb96ea 100644 --- a/wallet/internal/sql/pg/sqlc/transactions.sql.go +++ b/wallet/internal/sql/pg/sqlc/transactions.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: transactions.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go b/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go index c536d3710b..b067298c53 100644 --- a/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go +++ b/wallet/internal/sql/pg/sqlc/tx_replacements.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: tx_replacements.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go b/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go index 607e95362e..660a915a13 100644 --- a/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go +++ b/wallet/internal/sql/pg/sqlc/utxo_leases.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: utxo_leases.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/utxos.sql.go b/wallet/internal/sql/pg/sqlc/utxos.sql.go index 7eb9ab54c9..54aa0f53d6 100644 --- a/wallet/internal/sql/pg/sqlc/utxos.sql.go +++ b/wallet/internal/sql/pg/sqlc/utxos.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: utxos.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/pg/sqlc/wallets.sql.go b/wallet/internal/sql/pg/sqlc/wallets.sql.go index 91055c9f90..368b97957b 100644 --- a/wallet/internal/sql/pg/sqlc/wallets.sql.go +++ b/wallet/internal/sql/pg/sqlc/wallets.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: wallets.sql -package sqlcpg +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go index f50130e742..481ede7af2 100644 --- a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: accounts.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/address_types.sql.go b/wallet/internal/sql/sqlite/sqlc/address_types.sql.go index 5aa5cfdd5b..fb9b4de436 100644 --- a/wallet/internal/sql/sqlite/sqlc/address_types.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/address_types.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: address_types.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go index fbf4baf2e2..cd60257e8e 100644 --- a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: addresses.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/blocks.sql.go b/wallet/internal/sql/sqlite/sqlc/blocks.sql.go index decb094d32..a154122ed8 100644 --- a/wallet/internal/sql/sqlite/sqlc/blocks.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/blocks.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: blocks.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/db.go b/wallet/internal/sql/sqlite/sqlc/db.go index d5fa946fc3..ce1ff24b00 100644 --- a/wallet/internal/sql/sqlite/sqlc/db.go +++ b/wallet/internal/sql/sqlite/sqlc/db.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go b/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go index 7305a3c014..7b102fa56d 100644 --- a/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/key_scopes.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: key_scopes.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/models.go b/wallet/internal/sql/sqlite/sqlc/models.go index a08fb8d7a7..053824aa10 100644 --- a/wallet/internal/sql/sqlite/sqlc/models.go +++ b/wallet/internal/sql/sqlite/sqlc/models.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcsqlite +package sqlc import ( "database/sql" diff --git a/wallet/internal/sql/sqlite/sqlc/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go index bebc1585c0..8586c40cc1 100644 --- a/wallet/internal/sql/sqlite/sqlc/querier.go +++ b/wallet/internal/sql/sqlite/sqlc/querier.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/transactions.sql.go b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go index 07ef498aa1..fe9cde14c3 100644 --- a/wallet/internal/sql/sqlite/sqlc/transactions.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: transactions.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go b/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go index a4a2cd60ce..9397074b51 100644 --- a/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/tx_replacements.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: tx_replacements.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go b/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go index 3fc475ca72..2a85cb3a58 100644 --- a/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/utxo_leases.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: utxo_leases.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/utxos.sql.go b/wallet/internal/sql/sqlite/sqlc/utxos.sql.go index f8b5e110d5..137407be77 100644 --- a/wallet/internal/sql/sqlite/sqlc/utxos.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/utxos.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: utxos.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/internal/sql/sqlite/sqlc/wallets.sql.go b/wallet/internal/sql/sqlite/sqlc/wallets.sql.go index 24077a7cae..13f890a6ca 100644 --- a/wallet/internal/sql/sqlite/sqlc/wallets.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/wallets.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: wallets.sql -package sqlcsqlite +package sqlc import ( "context" diff --git a/wallet/mock_test.go b/wallet/mock_test.go index c5b95f7c85..84127a5620 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -21,7 +21,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/neutrino" diff --git a/wallet/utxo_manager.go b/wallet/utxo_manager.go index 91b14f9a8e..f0ee565875 100644 --- a/wallet/utxo_manager.go +++ b/wallet/utxo_manager.go @@ -19,7 +19,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) diff --git a/wallet/utxo_manager_test.go b/wallet/utxo_manager_test.go index fc96b7788c..b1c36d46b4 100644 --- a/wallet/utxo_manager_test.go +++ b/wallet/utxo_manager_test.go @@ -13,7 +13,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/wallet/wallet.go b/wallet/wallet.go index adac110654..e6e05c8813 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -24,7 +24,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" - db "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) From fdd247cb34b2e1fc9d901109a89a09058eb61e75 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:43:07 +0800 Subject: [PATCH 547/691] db/err: add shared SQL error types --- wallet/internal/db/err/errors.go | 441 ++++++++++++++++++++++++++ wallet/internal/db/err/errors_test.go | 270 ++++++++++++++++ 2 files changed, 711 insertions(+) create mode 100644 wallet/internal/db/err/errors.go create mode 100644 wallet/internal/db/err/errors_test.go diff --git a/wallet/internal/db/err/errors.go b/wallet/internal/db/err/errors.go new file mode 100644 index 0000000000..ee6654f342 --- /dev/null +++ b/wallet/internal/db/err/errors.go @@ -0,0 +1,441 @@ +// Package dberr contains the shared SQL error taxonomy and normalization logic +// used by backend-specific db packages. +package dberr + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "net" +) + +// unknownString is the fallback text for invalid enum values. +const unknownString = "unknown" + +// Backend identifies which SQL backend produced a classified SQL error. +// +// The backend is preserved on SQLError so callers can log raw backend codes, +// maintain per-backend stats, and keep backend-specific handling separate from +// the shared error model. +type Backend string + +// Backend constants identify supported SQL backends. +const ( + // BackendPostgres identifies the PostgreSQL SQL backend. + BackendPostgres Backend = "postgres" + + // BackendSQLite identifies the SQLite SQL backend. + BackendSQLite Backend = "sqlite" +) + +// String returns the canonical string form of the backend. +func (b Backend) String() string { + switch b { + case BackendPostgres: + return string(b) + + case BackendSQLite: + return string(b) + + default: + return unknownString + } +} + +// Valid reports whether the backend is one of the known SQL backends. +func (b Backend) Valid() bool { + switch b { + case BackendPostgres: + return true + + case BackendSQLite: + return true + + default: + return false + } +} + +// Class describes the caller-facing policy bucket for a classified SQL backend +// error. +// +// The class answers what runtime code should do next: +// +// - Transient: retry the operation when it is safe to retry. +// - Permanent: fail the operation immediately without poisoning the store. +// - Fatal: fail the operation and mark the store unhealthy so later calls +// fail fast until the backend is reopened or repaired. +type Class uint8 + +// Class constants identify caller-facing SQL error policy buckets. +const ( + // ClassTransient marks failures that may succeed on a later retry. + ClassTransient Class = iota + + // ClassPermanent marks failures that should fail the current + // operation without retrying or poisoning the store. + ClassPermanent + + // ClassFatal marks failures that should fail the current operation + // and take the store out of service. + ClassFatal +) + +// String returns the canonical string form of the class. +func (c Class) String() string { + switch c { + case ClassTransient: + return "transient" + + case ClassPermanent: + return "permanent" + + case ClassFatal: + return "fatal" + + default: + return unknownString + } +} + +// Valid reports whether the class is recognized. +func (c Class) Valid() bool { + switch c { + case ClassTransient: + return true + + case ClassPermanent: + return true + + case ClassFatal: + return true + + default: + return false + } +} + +// Reason describes the specific backend failure bucket for a classified SQL +// error. +// +// Callers use Reason for diagnosis and metrics, then derive runtime policy +// through Class(). New or unmapped backend codes should fall back to +// ReasonUnknown while preserving the raw backend code on SQLError. +type Reason uint8 + +// Reason constants identify shared SQL backend failure buckets. +const ( + // ReasonUnknown is the fallback reason for uncategorized backend codes. + ReasonUnknown Reason = iota + + // ReasonSerialization marks transaction serialization failures + // caused by concurrent updates. + ReasonSerialization + + // ReasonDeadlock marks deadlock failures where the backend aborts + // one participant to break a lock cycle. + ReasonDeadlock + + // ReasonBusy marks SQLite busy failures where a lock cannot be + // acquired in time. + ReasonBusy + + // ReasonLocked marks lock-not-available failures. + ReasonLocked + + // ReasonUnavailable marks backend availability and transport + // failures. + ReasonUnavailable + + // ReasonPoolExhausted marks connection-pool or backend + // connection-limit failures. + ReasonPoolExhausted + + // ReasonSchemaMismatch marks schema drift or schema version + // mismatches. + ReasonSchemaMismatch + + // ReasonConstraint marks backend constraint failures. + ReasonConstraint + + // ReasonResourceExhausted marks disk, memory, or similar hard + // resource failures. + ReasonResourceExhausted + + // ReasonReadOnly marks read-only backend failures. + ReasonReadOnly + + // ReasonPermission marks backend permission failures. + ReasonPermission + + // ReasonCorrupt marks corruption failures. + ReasonCorrupt +) + +// Class returns the caller-facing policy bucket derived from the reason. +func (r Reason) Class() Class { + switch r { + case ReasonSerialization, ReasonDeadlock, ReasonBusy, ReasonLocked, + ReasonUnavailable, ReasonPoolExhausted: + + return ClassTransient + + // Unknown or caller-fixable backend states fail the current operation + // without retrying or taking the store out of service. + case ReasonSchemaMismatch, ReasonConstraint, ReasonUnknown: + return ClassPermanent + + case ReasonResourceExhausted, ReasonReadOnly, ReasonPermission, + ReasonCorrupt: + + return ClassFatal + + default: + return ClassPermanent + } +} + +// String returns the canonical string form of the reason. +// +//nolint:cyclop // One explicit enum-to-string switch is the clearest form here. +func (r Reason) String() string { + switch r { + case ReasonSerialization: + return "serialization" + + case ReasonDeadlock: + return "deadlock" + + case ReasonBusy: + return "busy" + + case ReasonLocked: + return "locked" + + case ReasonUnavailable: + return "unavailable" + + case ReasonPoolExhausted: + return "pool_exhausted" + + case ReasonSchemaMismatch: + return "schema_mismatch" + + case ReasonConstraint: + return "constraint" + + case ReasonUnknown: + return unknownString + + case ReasonResourceExhausted: + return "resource_exhausted" + + case ReasonReadOnly: + return "read_only" + + case ReasonPermission: + return "permission" + + case ReasonCorrupt: + return "corrupt" + + default: + return unknownString + } +} + +// Valid reports whether the reason is recognized. +func (r Reason) Valid() bool { + return r <= ReasonCorrupt +} + +// SQLError wraps a backend or transport error with stable SQL classification +// metadata. +// +// The wrapper keeps classification data inside ordinary error chains so callers +// can recover backend, reason, class, and raw codes with errors.As after higher +// layers add their own context. +type SQLError struct { + // Backend identifies which SQL backend produced the error. + Backend Backend + + // Reason identifies the specific backend failure bucket. + Reason Reason + + // Code stores the raw backend code used for classification. + Code string + + // Err stores the wrapped backend or transport error. + Err error +} + +// NewSQLError builds a classified SQL backend error wrapper. +func NewSQLError(backend Backend, reason Reason, code string, + err error) *SQLError { + + return &SQLError{ + Backend: backend, + Reason: reason, + Code: code, + Err: err, + } +} + +// Class returns the caller-facing policy bucket derived from the reason. +func (e *SQLError) Class() Class { + if e == nil { + // A missing wrapper is treated like an unknown classified error so + // defensive callers do not retry or poison store state based on + // absent classification data. + return ReasonUnknown.Class() + } + + return e.Reason.Class() +} + +// Error returns the printable form of the wrapped backend error. +func (e *SQLError) Error() string { + if e == nil { + return "" + } + + // Normal wrappers carry the backend or transport error, so preserve that + // message and leave the remaining branches as defensive fallbacks. + if e.Err != nil { + return e.Err.Error() + } + + // Fall back to synthesized text only for malformed or test-built + // wrappers that do not carry an underlying cause. + return fmt.Sprintf("%s %s sql error", e.Backend.String(), e.Reason) +} + +// Unwrap returns the wrapped backend error. +func (e *SQLError) Unwrap() error { + if e == nil { + return nil + } + + return e.Err +} + +// Normalize converts one backend error into the shared SQL error model. +// +// It handles errors in this order: +// +// 1. A nil error stays nil. +// 2. Context cancellation and deadline errors are returned unchanged, +// including any caller-added wrapping context. +// 3. Existing SQLError wrappers are returned unchanged so Normalize does not +// rebuild or strip the original error chain. +// 4. The mapper gets the first chance to translate backend-native driver +// errors into SQLError values. +// 5. Generic connection and transport failures are classified as +// ReasonUnavailable. +// 6. If the backend is known and no earlier rule matches, err is wrapped as +// ReasonUnknown. +// 7. If the backend is unknown, err is returned unchanged. +// +// Normalize is intentionally side-effect free. Callers own follow-up actions +// such as stats recording or unhealthy-store transitions. +// +// The mapper keeps backend-specific matching outside the shared error package +// so backend packages can return shared SQL errors without introducing import +// cycles. Callers should pass a no-op mapper when they have no backend- +// specific codes to inspect. +func Normalize(backend Backend, mapper func(error) *SQLError, err error) error { + if err == nil { + return nil + } + + // Preserve caller-driven cancellation semantics, including any wrapping + // context, instead of reclassifying them as backend errors. + contextErr := unwrapContextErr(err) + if contextErr != nil { + return contextErr + } + + // Preserve existing SQL wrappers so caller-added context is not dropped + // from the original error chain. + if extractSQLError(err) != nil { + return err + } + + // Ask the backend-specific mapper first so driver-native codes stay as + // specific as possible. + sqlErr := mapper(err) + if sqlErr != nil { + return sqlErr + } + + // Fall back to transport-level classification for connection-oriented + // failures that are not backend-code specific. + transportErr := classifyConnErr(backend, err) + if transportErr != nil { + return transportErr + } + + // Unknown backends are left untouched so callers do not accidentally invent + // a backend identity the error never had. + if !backend.Valid() { + return err + } + + // Keep backend identity even when the exact backend code is not recognized + // so logs and stats retain useful diagnosis data. + return NewSQLError(backend, ReasonUnknown, "", err) +} + +// extractSQLError extracts the shared SQL error wrapper from err, if present. +func extractSQLError(err error) *SQLError { + var sqlErr *SQLError + if errors.As(err, &sqlErr) { + return sqlErr + } + + return nil +} + +// classifyConnErr classifies generic connection and transport failures that do +// not come with backend-specific codes. +func classifyConnErr(backend Backend, err error) *SQLError { + if !backend.Valid() { + return nil + } + + badConn := errors.Is(err, driver.ErrBadConn) + connDone := errors.Is(err, sql.ErrConnDone) + + // Broken-connection sentinels and EOF all mean the caller lost the SQL + // transport path before receiving a normal backend result. + if badConn || connDone || errors.Is(err, io.EOF) { + return NewSQLError(backend, ReasonUnavailable, "", err) + } + + var netErr net.Error + if errors.As(err, &netErr) { + // Timeout-only net errors are still transport failures even when the + // backend never returned a backend-specific code. + if netErr.Timeout() { + return NewSQLError(backend, ReasonUnavailable, "", err) + } + } + + return nil +} + +// unwrapContextErr preserves caller-driven context cancellation and deadlines. +func unwrapContextErr(err error) error { + if errors.Is(err, context.Canceled) { + return err + } + + if errors.Is(err, context.DeadlineExceeded) { + return err + } + + return nil +} diff --git a/wallet/internal/db/err/errors_test.go b/wallet/internal/db/err/errors_test.go new file mode 100644 index 0000000000..9a4a9b6024 --- /dev/null +++ b/wallet/internal/db/err/errors_test.go @@ -0,0 +1,270 @@ +package dberr + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +var errConstraint = errors.New("constraint") + +// noOpMapper is a test helper that leaves backend-specific classification to +// later Normalize fallbacks. +func noOpMapper(error) *SQLError { + return nil +} + +// TestBackendString verifies the string forms of the SQL backend identifiers. +func TestBackendString(t *testing.T) { + t.Parallel() + + require.Equal(t, "postgres", BackendPostgres.String()) + require.Equal(t, "sqlite", BackendSQLite.String()) + require.Equal(t, unknownString, Backend("mysql").String()) + require.True(t, BackendPostgres.Valid()) + require.False(t, Backend("mysql").Valid()) +} + +// TestClassString verifies the string forms of the exported error classes. +func TestClassString(t *testing.T) { + t.Parallel() + + require.Equal(t, "transient", ClassTransient.String()) + require.Equal(t, "permanent", ClassPermanent.String()) + require.Equal(t, "fatal", ClassFatal.String()) + require.Equal(t, unknownString, Class(99).String()) + require.True(t, ClassFatal.Valid()) + require.False(t, Class(99).Valid()) +} + +// TestReasonMetadata verifies the string and class metadata of the exported +// reasons. +func TestReasonMetadata(t *testing.T) { + t.Parallel() + + var zero Reason + require.Equal(t, ReasonUnknown, zero) + + require.Equal(t, "serialization", ReasonSerialization.String()) + require.Equal(t, ClassTransient, ReasonBusy.Class()) + require.Equal(t, ClassPermanent, ReasonUnknown.Class()) + require.Equal(t, ClassFatal, ReasonCorrupt.Class()) + require.Equal(t, unknownString, Reason(99).String()) + require.False(t, Reason(99).Valid()) +} + +// TestReasonClassCoverage verifies that every valid reason maps to the +// expected caller-facing policy bucket. +func TestReasonClassCoverage(t *testing.T) { + t.Parallel() + + tests := map[Reason]Class{ + ReasonUnknown: ClassPermanent, + ReasonSerialization: ClassTransient, + ReasonDeadlock: ClassTransient, + ReasonBusy: ClassTransient, + ReasonLocked: ClassTransient, + ReasonUnavailable: ClassTransient, + ReasonPoolExhausted: ClassTransient, + ReasonSchemaMismatch: ClassPermanent, + ReasonConstraint: ClassPermanent, + ReasonResourceExhausted: ClassFatal, + ReasonReadOnly: ClassFatal, + ReasonPermission: ClassFatal, + ReasonCorrupt: ClassFatal, + } + + for reason := ReasonUnknown; reason <= ReasonCorrupt; reason++ { + wantClass, ok := tests[reason] + require.Truef(t, ok, "missing class expectation for reason %v", reason) + require.Equal(t, wantClass, reason.Class()) + } +} + +// TestSQLErrorFormatting verifies the formatting helpers on nil and populated +// SQL errors. +func TestSQLErrorFormatting(t *testing.T) { + t.Parallel() + + var nilErr *SQLError + require.Equal(t, "", nilErr.Error()) + require.NoError(t, nilErr.Unwrap()) + require.Equal(t, ClassPermanent, nilErr.Class()) + + err := &SQLError{ + Backend: BackendSQLite, + Reason: ReasonUnknown, + } + require.Equal(t, "sqlite unknown sql error", err.Error()) + require.NoError(t, err.Unwrap()) +} + +// TestExtractSQLErrorClass verifies that callers can recover a wrapped SQL +// error and inspect its derived class with the standard errors.As pattern. +func TestExtractSQLErrorClass(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("wrap: %w", NewSQLError( + BackendPostgres, + ReasonSerialization, + "40001", + driver.ErrBadConn, + )) + + sqlErr := extractSQLError(err) + require.NotNil(t, sqlErr) + require.Equal(t, ClassTransient, sqlErr.Class()) + + require.Nil(t, extractSQLError(errConstraint)) +} + +// TestExtractSQLError verifies that callers can recover structured SQL +// metadata with the standard errors.As pattern. +func TestExtractSQLError(t *testing.T) { + t.Parallel() + + wrapped := fmt.Errorf("outer: %w", NewSQLError( + BackendSQLite, + ReasonConstraint, + "2067", + errConstraint, + )) + + sqlErr := extractSQLError(wrapped) + require.NotNil(t, sqlErr) + require.Equal(t, BackendSQLite, sqlErr.Backend) + require.Equal(t, ReasonConstraint, sqlErr.Reason) + require.Equal(t, "2067", sqlErr.Code) + require.Equal(t, ClassPermanent, sqlErr.Class()) +} + +// TestClassifyConnErr verifies that generic connection failures are classified +// as transient availability problems. +func TestClassifyConnErr(t *testing.T) { + t.Parallel() + + err := classifyConnErr(BackendPostgres, driver.ErrBadConn) + require.NotNil(t, err) + require.Equal(t, BackendPostgres, err.Backend) + require.Equal(t, ReasonUnavailable, err.Reason) + require.Equal(t, ClassTransient, err.Class()) + + err = classifyConnErr(BackendSQLite, sql.ErrConnDone) + require.NotNil(t, err) + require.Equal(t, ReasonUnavailable, err.Reason) + + err = classifyConnErr(BackendSQLite, netTimeoutError{}) + require.NotNil(t, err) + require.Equal(t, ReasonUnavailable, err.Reason) + + err = classifyConnErr(BackendSQLite, temporaryNetError{err: io.EOF}) + require.Nil(t, err) + + require.Nil(t, classifyConnErr(Backend(""), driver.ErrBadConn)) + require.Nil(t, classifyConnErr(BackendSQLite, sql.ErrNoRows)) +} + +// TestCoreHelpers verifies the remaining backend-agnostic helper branches. +func TestCoreHelpers(t *testing.T) { + t.Parallel() + + require.Equal(t, context.Canceled, unwrapContextErr(context.Canceled)) + require.Equal(t, context.DeadlineExceeded, + unwrapContextErr(context.DeadlineExceeded)) + + wrappedCanceled := fmt.Errorf("query accounts: %w", context.Canceled) + require.Same(t, wrappedCanceled, unwrapContextErr(wrappedCanceled)) + require.NoError(t, unwrapContextErr(sql.ErrNoRows)) +} + +// TestNormalize verifies the backend-aware normalization flow that keeps +// context errors untouched and preserves backend-specific mapping. +func TestNormalize(t *testing.T) { + t.Parallel() + + err := Normalize(BackendPostgres, noOpMapper, context.Canceled) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, extractSQLError(err)) + + wrappedCanceled := fmt.Errorf("query accounts: %w", context.Canceled) + err = Normalize(BackendPostgres, noOpMapper, wrappedCanceled) + require.Same(t, wrappedCanceled, err) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, extractSQLError(err)) + + err = Normalize(BackendPostgres, noOpMapper, driver.ErrBadConn) + + sqlErr := extractSQLError(err) + require.NotNil(t, sqlErr) + require.Equal(t, BackendPostgres, sqlErr.Backend) + require.Equal(t, ClassTransient, sqlErr.Class()) + require.Equal(t, ReasonUnavailable, sqlErr.Reason) + + err = Normalize(BackendSQLite, func(in error) *SQLError { + return NewSQLError(BackendSQLite, ReasonUnknown, "", in) + }, errConstraint) + sqlErr = extractSQLError(err) + require.NotNil(t, sqlErr) + require.Equal(t, ClassPermanent, sqlErr.Class()) + require.Equal(t, ReasonUnknown, sqlErr.Reason) + + existing := NewSQLError(Backend(""), ReasonCorrupt, "", io.EOF) + err = Normalize(BackendPostgres, noOpMapper, existing) + require.Same(t, existing, err) + require.Equal(t, Backend(""), extractSQLError(err).Backend) + + wrapped := fmt.Errorf("outer: %w", existing) + err = Normalize(BackendSQLite, noOpMapper, wrapped) + require.Same(t, wrapped, err) + require.Equal(t, Backend(""), extractSQLError(err).Backend) + + require.Same(t, errConstraint, + Normalize(Backend(""), noOpMapper, errConstraint)) +} + +// temporaryNetError is a test helper that exposes only the legacy Temporary +// signal without reporting a timeout. +type temporaryNetError struct { + // err is the wrapped error string returned by Error. + err error +} + +// Error returns the wrapped error string. +func (e temporaryNetError) Error() string { + return e.err.Error() +} + +// Timeout reports that the test error is not a timeout. +func (e temporaryNetError) Timeout() bool { + return false +} + +// Temporary reports that the test error should be treated as temporary. +func (e temporaryNetError) Temporary() bool { + return true +} + +// netTimeoutError is a test helper that reports a timeout without a legacy +// Temporary method. +type netTimeoutError struct{} + +// Error returns the static timeout error string. +func (e netTimeoutError) Error() string { + return "timeout" +} + +// Timeout reports that the test error is a timeout. +func (e netTimeoutError) Timeout() bool { + return true +} + +// Temporary reports that the test error is not a legacy temporary error. +func (e netTimeoutError) Temporary() bool { + return false +} From b11564572f85ad8c4f1dfcbdfc4f03f17ea3e680 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:43:32 +0800 Subject: [PATCH 548/691] db/pg: add SQL error mapper --- wallet/internal/db/pg/errors.go | 139 +++++++++++++++++++++++++++ wallet/internal/db/pg/errors_test.go | 118 +++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 wallet/internal/db/pg/errors.go create mode 100644 wallet/internal/db/pg/errors_test.go diff --git a/wallet/internal/db/pg/errors.go b/wallet/internal/db/pg/errors.go new file mode 100644 index 0000000000..1af2b8b158 --- /dev/null +++ b/wallet/internal/db/pg/errors.go @@ -0,0 +1,139 @@ +// Package pg contains PostgreSQL-specific SQL helpers. +package pg + +import ( + "errors" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/jackc/pgx/v5/pgconn" +) + +// SQLSTATE helper constants support PostgreSQL error classification. +const ( + // connectionExceptionClass identifies PostgreSQL SQLSTATE class 08, + // which covers connection exceptions. + connectionExceptionClass = "08" + + // sqlStateClassLen is the length of a SQLSTATE class prefix. + sqlStateClassLen = 2 +) + +// SQLSTATE code constants capture the PostgreSQL errors mapped here. +const ( + // The SQLSTATE codes below follow PostgreSQL's documented error code + // appendix. The wallet intentionally collapses many backend-specific + // codes into a smaller caller-facing reason set. + // + // Reference: + // https://www.postgresql.org/docs/current/errcodes-appendix.html + codeSerializationFailure = "40001" + codeDeadlockDetected = "40P01" + codeLockNotAvailable = "55P03" + codeQueryCanceled = "57014" + codeAdminShutdown = "57P01" + codeCrashShutdown = "57P02" + codeCannotConnectNow = "57P03" + codeTooManyConnections = "53300" + codeDiskFull = "53100" + codeOutOfMemory = "53200" + codeConfigLimitExceeded = "53400" + codeReadOnlyTxn = "25006" + codeInsufficientPriv = "42501" + codeCorruptData = "XX001" + codeCorruptIndex = "XX002" + codeUndefinedTable = "42P01" + codeUndefinedColumn = "42703" + codeUniqueViolation = "23505" + codeForeignKeyViolation = "23503" + codeCheckViolation = "23514" + codeNotNullViolation = "23502" + codeExclusionViolation = "23P01" +) + +// reasonByCode maps PostgreSQL SQLSTATE codes into the shared SQL error model. +var reasonByCode = map[string]dberr.Reason{ + codeSerializationFailure: dberr.ReasonSerialization, + codeDeadlockDetected: dberr.ReasonDeadlock, + codeLockNotAvailable: dberr.ReasonLocked, + codeQueryCanceled: dberr.ReasonUnknown, + codeAdminShutdown: dberr.ReasonUnavailable, + codeCrashShutdown: dberr.ReasonUnavailable, + codeCannotConnectNow: dberr.ReasonUnavailable, + codeTooManyConnections: dberr.ReasonPoolExhausted, + codeDiskFull: dberr.ReasonResourceExhausted, + codeOutOfMemory: dberr.ReasonResourceExhausted, + codeConfigLimitExceeded: dberr.ReasonResourceExhausted, + codeReadOnlyTxn: dberr.ReasonReadOnly, + codeInsufficientPriv: dberr.ReasonPermission, + codeCorruptData: dberr.ReasonCorrupt, + codeCorruptIndex: dberr.ReasonCorrupt, + codeUndefinedTable: dberr.ReasonSchemaMismatch, + codeUndefinedColumn: dberr.ReasonSchemaMismatch, + codeUniqueViolation: dberr.ReasonConstraint, + codeForeignKeyViolation: dberr.ReasonConstraint, + codeCheckViolation: dberr.ReasonConstraint, + codeNotNullViolation: dberr.ReasonConstraint, + codeExclusionViolation: dberr.ReasonConstraint, +} + +// mapErr maps PostgreSQL driver and transport errors into SQLError. +func mapErr(err error) *dberr.SQLError { + // Prefer SQLSTATE-based mapping first so a completed PostgreSQL statement + // keeps its specific backend code instead of being collapsed into a generic + // transport fallback. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return mapCode(pgErr.Code, err) + } + + var connectErr *pgconn.ConnectError + + // ConnectError means the driver failed while establishing or maintaining + // the connection, so callers see the same transient unavailable bucket used + // for other connection-path failures. + if errors.As(err, &connectErr) { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, "", err, + ) + } + + // Safe-to-retry and timeout failures happen on the connection or transport + // path rather than as a completed SQL statement outcome, so they share the + // transient backend-unavailable bucket with SQLSTATE connection exceptions. + if pgconn.SafeToRetry(err) || pgconn.Timeout(err) { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, "", err, + ) + } + + return nil +} + +// mapCode maps one PostgreSQL SQLSTATE into SQLError. +func mapCode(code string, err error) *dberr.SQLError { + reason, ok := reasonByCode[code] + if ok { + return dberr.NewSQLError(dberr.BackendPostgres, reason, code, err) + } + + if sqlStateClass(code) == connectionExceptionClass { + // SQLSTATE class 08 reports connection exceptions rather than completed + // statement outcomes, so it maps to the shared unavailable reason. + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, code, err, + ) + } + + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnknown, code, err, + ) +} + +// sqlStateClass returns the SQLSTATE class prefix. +func sqlStateClass(code string) string { + if len(code) < sqlStateClassLen { + return "" + } + + return code[:sqlStateClassLen] +} diff --git a/wallet/internal/db/pg/errors_test.go b/wallet/internal/db/pg/errors_test.go new file mode 100644 index 0000000000..81cc1c733a --- /dev/null +++ b/wallet/internal/db/pg/errors_test.go @@ -0,0 +1,118 @@ +package pg + +import ( + "io" + "testing" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +// codeClassificationTestCase defines one SQLSTATE mapping expectation. +type codeClassificationTestCase struct { + // name describes the subtest. + name string + + // code is the PostgreSQL SQLSTATE under test. + code string + + // wantReason is the expected shared SQL error reason. + wantReason dberr.Reason +} + +// TestMapCode verifies that representative SQLSTATEs map to the expected +// reason and class buckets. +func TestMapCode(t *testing.T) { + t.Parallel() + + tests := []codeClassificationTestCase{ + { + name: "serialization", + code: codeSerializationFailure, + wantReason: dberr.ReasonSerialization, + }, + { + name: "deadlock", + code: codeDeadlockDetected, + wantReason: dberr.ReasonDeadlock, + }, + { + name: "too many connections", + code: codeTooManyConnections, + wantReason: dberr.ReasonPoolExhausted, + }, + { + name: "disk full", + code: codeDiskFull, + wantReason: dberr.ReasonResourceExhausted, + }, + { + name: "schema mismatch", + code: codeUndefinedTable, + wantReason: dberr.ReasonSchemaMismatch, + }, + { + name: "constraint", + code: codeUniqueViolation, + wantReason: dberr.ReasonConstraint, + }, + { + name: "not null constraint", + code: codeNotNullViolation, + wantReason: dberr.ReasonConstraint, + }, + { + name: "exclusion constraint", + code: codeExclusionViolation, + wantReason: dberr.ReasonConstraint, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + err := mapCode(testCase.code, io.EOF) + require.NotNil(t, err) + require.Equal(t, dberr.BackendPostgres, err.Backend) + require.Equal(t, testCase.wantReason, err.Reason) + require.Equal(t, testCase.wantReason.Class(), err.Class()) + require.Equal(t, testCase.code, err.Code) + }) + } +} + +// TestMapCodeFallback verifies the fallback mapping paths for connection +// exception classes and unknown SQLSTATEs. +func TestMapCodeFallback(t *testing.T) { + t.Parallel() + + connectionErr := mapCode("08006", io.EOF) + require.NotNil(t, connectionErr) + require.Equal(t, dberr.ReasonUnavailable, connectionErr.Reason) + require.Equal(t, dberr.ClassTransient, connectionErr.Class()) + + unknownErr := mapCode("99999", io.EOF) + require.NotNil(t, unknownErr) + require.Equal(t, dberr.ReasonUnknown, unknownErr.Reason) + require.Equal(t, dberr.ClassPermanent, unknownErr.Class()) + + require.Equal(t, "08", sqlStateClass("08006")) + require.Empty(t, sqlStateClass("0")) +} + +// TestMapErr verifies that driver-specific PostgreSQL errors are recognized and +// wrapped as SQL errors. +func TestMapErr(t *testing.T) { + t.Parallel() + + err := mapErr(&pgconn.PgError{ + Code: codeReadOnlyTxn, + Message: "read only", + }) + require.NotNil(t, err) + require.Equal(t, dberr.BackendPostgres, err.Backend) + require.Equal(t, dberr.ReasonReadOnly, err.Reason) + require.Equal(t, dberr.ClassFatal, err.Class()) +} From a76bfb5d321447fea3bac604c7771dc83b110b9d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:56:03 +0800 Subject: [PATCH 549/691] db/sqlite: add SQL error mapper --- wallet/internal/db/sqlite/errors.go | 71 +++++++++++++++++++ wallet/internal/db/sqlite/errors_test.go | 89 ++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 wallet/internal/db/sqlite/errors.go create mode 100644 wallet/internal/db/sqlite/errors_test.go diff --git a/wallet/internal/db/sqlite/errors.go b/wallet/internal/db/sqlite/errors.go new file mode 100644 index 0000000000..90eda4e985 --- /dev/null +++ b/wallet/internal/db/sqlite/errors.go @@ -0,0 +1,71 @@ +package sqlite + +import ( + "errors" + "strconv" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "modernc.org/sqlite" + sqlite3 "modernc.org/sqlite/lib" +) + +// SQLite result-code helper constants support error classification. +const ( + // primaryCodeMask strips a SQLite extended result code to its primary + // result code. + primaryCodeMask = 0xff +) + +// reasonByCode maps SQLite primary result codes into the shared SQL error +// model. +var reasonByCode = map[int]dberr.Reason{ + sqlite3.SQLITE_BUSY: dberr.ReasonBusy, + sqlite3.SQLITE_LOCKED: dberr.ReasonLocked, + sqlite3.SQLITE_INTERRUPT: dberr.ReasonUnknown, + sqlite3.SQLITE_CONSTRAINT: dberr.ReasonConstraint, + sqlite3.SQLITE_FULL: dberr.ReasonResourceExhausted, + sqlite3.SQLITE_NOMEM: dberr.ReasonResourceExhausted, + sqlite3.SQLITE_IOERR: dberr.ReasonResourceExhausted, + sqlite3.SQLITE_PROTOCOL: dberr.ReasonUnavailable, + sqlite3.SQLITE_READONLY: dberr.ReasonReadOnly, + sqlite3.SQLITE_PERM: dberr.ReasonPermission, + sqlite3.SQLITE_CORRUPT: dberr.ReasonCorrupt, + sqlite3.SQLITE_NOTADB: dberr.ReasonCorrupt, + sqlite3.SQLITE_NOTFOUND: dberr.ReasonUnknown, + sqlite3.SQLITE_SCHEMA: dberr.ReasonSchemaMismatch, + sqlite3.SQLITE_CANTOPEN: dberr.ReasonUnknown, +} + +// mapErr maps SQLite result codes into SQLError. +func mapErr(err error) *dberr.SQLError { + // Start by extracting the SQLite driver error so the backend package can + // inspect its numeric result code. + var sqliteErr *sqlite.Error + if !errors.As(err, &sqliteErr) { + return nil + } + + code := sqliteErr.Code() + + // Reduce extended result codes to their primary code before consulting the + // shared reason map so related variants share one caller-facing bucket. + primaryCode := primaryCode(code) + codeString := codeString(code) + + reason, ok := reasonByCode[primaryCode] + if !ok { + reason = dberr.ReasonUnknown + } + + return dberr.NewSQLError(dberr.BackendSQLite, reason, codeString, err) +} + +// codeString formats a SQLite numeric result code for logs and stats. +func codeString(code int) string { + return strconv.Itoa(code) +} + +// primaryCode strips a SQLite extended result code to its primary code. +func primaryCode(code int) int { + return code & primaryCodeMask +} diff --git a/wallet/internal/db/sqlite/errors_test.go b/wallet/internal/db/sqlite/errors_test.go new file mode 100644 index 0000000000..fb6a4db9a0 --- /dev/null +++ b/wallet/internal/db/sqlite/errors_test.go @@ -0,0 +1,89 @@ +package sqlite + +import ( + "database/sql" + "path/filepath" + "testing" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/stretchr/testify/require" + sqlite3 "modernc.org/sqlite/lib" +) + +// TestMapErrConstraint verifies that SQLite constraint violations are mapped to +// permanent constraint failures. +func TestMapErrConstraint(t *testing.T) { + t.Parallel() + + dbConn, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "wallet.db")) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + ctx := t.Context() + + _, err = dbConn.ExecContext( + ctx, `CREATE TABLE demo (id INTEGER PRIMARY KEY, val TEXT UNIQUE)`, + ) + require.NoError(t, err) + + _, err = dbConn.ExecContext(ctx, `INSERT INTO demo (val) VALUES ('dup')`) + require.NoError(t, err) + + _, err = dbConn.ExecContext(ctx, `INSERT INTO demo (val) VALUES ('dup')`) + require.Error(t, err) + + sqlErr := mapErr(err) + require.NotNil(t, sqlErr) + require.Equal(t, dberr.BackendSQLite, sqlErr.Backend) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) + require.Equal(t, dberr.ClassPermanent, sqlErr.Class()) + require.NotEmpty(t, sqlErr.Code) +} + +// TestMapErrReadOnly verifies that SQLite query-only failures are mapped to +// fatal read-only backend errors. +func TestMapErrReadOnly(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "wallet.db") + dbConn, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + + ctx := t.Context() + + _, err = dbConn.ExecContext( + ctx, `CREATE TABLE demo (id INTEGER PRIMARY KEY, val TEXT)`, + ) + require.NoError(t, err) + require.NoError(t, dbConn.Close()) + + roDB, err := sql.Open("sqlite", dbPath+"?_pragma=query_only=on") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, roDB.Close()) + }) + + _, err = roDB.ExecContext(ctx, `INSERT INTO demo (val) VALUES ('x')`) + require.Error(t, err) + + sqlErr := mapErr(err) + require.NotNil(t, sqlErr) + require.Equal(t, dberr.BackendSQLite, sqlErr.Backend) + require.Equal(t, dberr.ReasonReadOnly, sqlErr.Reason) + require.Equal(t, dberr.ClassFatal, sqlErr.Class()) +} + +// TestHelpers verifies the SQLite-specific helper paths. +func TestHelpers(t *testing.T) { + t.Parallel() + + require.Equal(t, "5", codeString(5)) + require.Equal(t, dberr.ReasonUnavailable, + reasonByCode[sqlite3.SQLITE_PROTOCOL]) + require.Equal(t, dberr.ReasonUnknown, + reasonByCode[sqlite3.SQLITE_NOTFOUND]) + require.Equal(t, sqlite3.SQLITE_CONSTRAINT, + primaryCode(sqlite3.SQLITE_CONSTRAINT_UNIQUE)) +} From b98c0bca369c6499bef49f9bde4683b9770248da Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:44:28 +0800 Subject: [PATCH 550/691] db/err: add SQL error stats --- wallet/internal/db/err/stats.go | 226 +++++++++++++++++++++++++++ wallet/internal/db/err/stats_test.go | 115 ++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 wallet/internal/db/err/stats.go create mode 100644 wallet/internal/db/err/stats_test.go diff --git a/wallet/internal/db/err/stats.go b/wallet/internal/db/err/stats.go new file mode 100644 index 0000000000..60d4a2a1b3 --- /dev/null +++ b/wallet/internal/db/err/stats.go @@ -0,0 +1,226 @@ +package dberr + +import ( + "errors" + "sync/atomic" +) + +// StatsSnapshot is a read-only copy of the SQL backend error counters. +type StatsSnapshot struct { + // Backend identifies which SQL backend produced the snapshot. + Backend Backend + + // TotalErrs counts all classified SQL backend errors. + TotalErrs uint64 + + // TransientErrs counts classified transient SQL backend errors. + TransientErrs uint64 + + // PermanentErrs counts classified permanent SQL backend errors. + PermanentErrs uint64 + + // FatalErrs counts classified fatal SQL backend errors. + FatalErrs uint64 + + // Serialization counts serialization failures. + Serialization uint64 + + // Deadlocks counts deadlock failures. + Deadlocks uint64 + + // Busy counts SQLite busy failures. + Busy uint64 + + // Locked counts lock-not-available failures. + Locked uint64 + + // Unavailable counts backend availability failures. + Unavailable uint64 + + // PoolExhausted counts connection exhaustion failures. + PoolExhausted uint64 + + // ResourceExhausted counts disk, memory, or similar failures. + ResourceExhausted uint64 + + // ReadOnly counts read-only backend failures. + ReadOnly uint64 + + // Permission counts backend permission failures. + Permission uint64 + + // Corrupt counts corruption failures. + Corrupt uint64 + + // SchemaMismatch counts schema mismatch failures. + SchemaMismatch uint64 + + // Constraint counts backend constraint failures. + Constraint uint64 + + // Unknown counts classified backend failures with an unknown reason. + Unknown uint64 +} + +// Stats stores low-overhead atomic counters for classified SQL errors. +type Stats struct { + // totalErrs counts all classified SQL backend errors. + totalErrs atomic.Uint64 + + // transientErrs counts transient SQL backend errors. + transientErrs atomic.Uint64 + + // permanentErrs counts permanent SQL backend errors. + permanentErrs atomic.Uint64 + + // fatalErrs counts fatal SQL backend errors. + fatalErrs atomic.Uint64 + + // serialization counts serialization failures. + serialization atomic.Uint64 + + // deadlocks counts deadlock failures. + deadlocks atomic.Uint64 + + // busy counts SQLite busy failures. + busy atomic.Uint64 + + // locked counts lock-not-available failures. + locked atomic.Uint64 + + // unavailable counts backend availability failures. + unavailable atomic.Uint64 + + // poolExhausted counts connection exhaustion failures. + poolExhausted atomic.Uint64 + + // resourceExhausted counts resource exhaustion failures. + resourceExhausted atomic.Uint64 + + // readOnly counts read-only backend failures. + readOnly atomic.Uint64 + + // permission counts backend permission failures. + permission atomic.Uint64 + + // corrupt counts corruption failures. + corrupt atomic.Uint64 + + // schemaMismatch counts schema mismatch failures. + schemaMismatch atomic.Uint64 + + // constraint counts backend constraint failures. + constraint atomic.Uint64 + + // unknown counts classified backend failures with an unknown reason. + unknown atomic.Uint64 +} + +// reasonRecorder updates one per-reason counter on Stats. +type reasonRecorder func(*Stats) + +// reasonRecorders maps each valid reason to its per-reason counter update. +var reasonRecorders = [...]reasonRecorder{ + ReasonSerialization: func(s *Stats) { + s.serialization.Add(1) + }, + ReasonDeadlock: func(s *Stats) { + s.deadlocks.Add(1) + }, + ReasonBusy: func(s *Stats) { + s.busy.Add(1) + }, + ReasonLocked: func(s *Stats) { + s.locked.Add(1) + }, + ReasonUnavailable: func(s *Stats) { + s.unavailable.Add(1) + }, + ReasonPoolExhausted: func(s *Stats) { + s.poolExhausted.Add(1) + }, + ReasonSchemaMismatch: func(s *Stats) { + s.schemaMismatch.Add(1) + }, + ReasonConstraint: func(s *Stats) { + s.constraint.Add(1) + }, + ReasonUnknown: func(s *Stats) { + s.unknown.Add(1) + }, + ReasonResourceExhausted: func(s *Stats) { + s.resourceExhausted.Add(1) + }, + ReasonReadOnly: func(s *Stats) { + s.readOnly.Add(1) + }, + ReasonPermission: func(s *Stats) { + s.permission.Add(1) + }, + ReasonCorrupt: func(s *Stats) { + s.corrupt.Add(1) + }, +} + +// Record updates counters from a classified SQL backend error. +func (s *Stats) Record(err error) { + var sqlErr *SQLError + if !errors.As(err, &sqlErr) { + return + } + + s.totalErrs.Add(1) + + switch sqlErr.Class() { + case ClassTransient: + s.transientErrs.Add(1) + + case ClassPermanent: + s.permanentErrs.Add(1) + + case ClassFatal: + s.fatalErrs.Add(1) + } + + s.recordReason(sqlErr.Reason) +} + +// Snapshot returns a read-only copy of the current SQL error counters. +func (s *Stats) Snapshot(backend Backend) StatsSnapshot { + return StatsSnapshot{ + Backend: backend, + TotalErrs: s.totalErrs.Load(), + TransientErrs: s.transientErrs.Load(), + PermanentErrs: s.permanentErrs.Load(), + FatalErrs: s.fatalErrs.Load(), + Serialization: s.serialization.Load(), + Deadlocks: s.deadlocks.Load(), + Busy: s.busy.Load(), + Locked: s.locked.Load(), + Unavailable: s.unavailable.Load(), + PoolExhausted: s.poolExhausted.Load(), + ResourceExhausted: s.resourceExhausted.Load(), + ReadOnly: s.readOnly.Load(), + Permission: s.permission.Load(), + Corrupt: s.corrupt.Load(), + SchemaMismatch: s.schemaMismatch.Load(), + Constraint: s.constraint.Load(), + Unknown: s.unknown.Load(), + } +} + +// recordReason updates counters for one classified SQL error reason. +func (s *Stats) recordReason(reason Reason) { + if !reason.Valid() { + s.unknown.Add(1) + return + } + + record := reasonRecorders[reason] + if record == nil { + s.unknown.Add(1) + return + } + + record(s) +} diff --git a/wallet/internal/db/err/stats_test.go b/wallet/internal/db/err/stats_test.go new file mode 100644 index 0000000000..a7727514d7 --- /dev/null +++ b/wallet/internal/db/err/stats_test.go @@ -0,0 +1,115 @@ +package dberr + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +var ( + errTestSerialization = errors.New("serialization") + errTestConstraint = errors.New("constraint") + errTestPlain = errors.New("plain") +) + +// TestStatsRecord verifies that the stats collector updates class and reason +// counters from classified SQL backend errors. +func TestStatsRecord(t *testing.T) { + t.Parallel() + + var stats Stats + + stats.Record(NewSQLError( + BackendPostgres, + ReasonSerialization, + "40001", + errTestSerialization, + )) + stats.Record(NewSQLError( + BackendSQLite, + ReasonConstraint, + "2067", + errTestConstraint, + )) + + snapshot := stats.Snapshot(BackendPostgres) + require.EqualValues(t, 2, snapshot.TotalErrs) + require.EqualValues(t, 1, snapshot.TransientErrs) + require.EqualValues(t, 1, snapshot.PermanentErrs) + require.EqualValues(t, 1, snapshot.Serialization) + require.EqualValues(t, 1, snapshot.Constraint) +} + +// TestStatsRecordReasonAll verifies that each reason increments its expected +// counter. +func TestStatsRecordReasonAll(t *testing.T) { + t.Parallel() + + reasons := []Reason{ + ReasonSerialization, + ReasonDeadlock, + ReasonBusy, + ReasonLocked, + ReasonUnavailable, + ReasonPoolExhausted, + ReasonResourceExhausted, + ReasonReadOnly, + ReasonPermission, + ReasonCorrupt, + ReasonSchemaMismatch, + ReasonConstraint, + ReasonUnknown, + } + + var stats Stats + for _, reason := range reasons { + stats.recordReason(reason) + } + + snapshot := stats.Snapshot(BackendPostgres) + require.EqualValues(t, 1, snapshot.Serialization) + require.EqualValues(t, 1, snapshot.Deadlocks) + require.EqualValues(t, 1, snapshot.Busy) + require.EqualValues(t, 1, snapshot.Locked) + require.EqualValues(t, 1, snapshot.Unavailable) + require.EqualValues(t, 1, snapshot.PoolExhausted) + require.EqualValues(t, 1, snapshot.ResourceExhausted) + require.EqualValues(t, 1, snapshot.ReadOnly) + require.EqualValues(t, 1, snapshot.Permission) + require.EqualValues(t, 1, snapshot.Corrupt) + require.EqualValues(t, 1, snapshot.SchemaMismatch) + require.EqualValues(t, 1, snapshot.Constraint) + require.EqualValues(t, 1, snapshot.Unknown) +} + +// TestStatsRecordUnknown verifies that fallback reasons are counted as +// permanent unknown failures. +func TestStatsRecordUnknown(t *testing.T) { + t.Parallel() + + var stats Stats + stats.Record(NewSQLError(BackendSQLite, ReasonUnknown, "", errTestPlain)) + stats.recordReason(Reason(99)) + + snapshot := stats.Snapshot(BackendSQLite) + require.EqualValues(t, 1, snapshot.TotalErrs) + require.Zero(t, snapshot.TransientErrs) + require.EqualValues(t, 1, snapshot.PermanentErrs) + require.Zero(t, snapshot.FatalErrs) + require.EqualValues(t, 2, snapshot.Unknown) +} + +// TestStatsRecordIgnoresPlainErr verifies that only classified SQL backend +// errors affect the stats collector. +func TestStatsRecordIgnoresPlainErr(t *testing.T) { + t.Parallel() + + var stats Stats + + stats.Record(errTestPlain) + + snapshot := stats.Snapshot(BackendSQLite) + require.Zero(t, snapshot.TotalErrs) + require.Zero(t, snapshot.Unknown) +} From f3750dab18e8367c0e6b055f26c346af2958042d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:44:53 +0800 Subject: [PATCH 551/691] db/err: add package README --- wallet/internal/db/err/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 wallet/internal/db/err/README.md diff --git a/wallet/internal/db/err/README.md b/wallet/internal/db/err/README.md new file mode 100644 index 0000000000..9d05b769ab --- /dev/null +++ b/wallet/internal/db/err/README.md @@ -0,0 +1,16 @@ +# `db/err` + +This package provides the shared SQL error taxonomy and normalization layer +used by the wallet database backends. + +It defines the common backend, class, and reason enums, the `SQLError` +wrapper used to preserve classification data in error chains, normalization +helpers for mapping backend failures into the shared model, and stats types for +recording classified SQL errors. + +Backend-specific mapping remains in the backend packages, including [`pg`](../pg/errors.go) +and [`sqlite`](../sqlite/errors.go). + +Higher-level runtime behavior such as retry handling, unhealthy-store +transitions, and transaction execution policy is implemented by callers outside +this package. From 3c6170b157bf45b26ea5c20da9b98875e5c36956 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:59:11 +0800 Subject: [PATCH 552/691] db/runtime: add ambiguous tx commit error --- wallet/internal/db/runtime/runtime.go | 90 ++++++++++++++++++++++ wallet/internal/db/runtime/runtime_test.go | 60 +++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 wallet/internal/db/runtime/runtime.go create mode 100644 wallet/internal/db/runtime/runtime_test.go diff --git a/wallet/internal/db/runtime/runtime.go b/wallet/internal/db/runtime/runtime.go new file mode 100644 index 0000000000..fc9397e2f1 --- /dev/null +++ b/wallet/internal/db/runtime/runtime.go @@ -0,0 +1,90 @@ +// Package runtime provides shared SQL execution helpers for split backend +// packages. +package runtime + +import ( + "database/sql" + "database/sql/driver" + "errors" + "io" +) + +// Runtime sentinel errors returned by the shared SQL helpers. +var ( + // ErrAmbiguousTxCommit matches commit failures whose final database outcome + // is unknown because the commit may already have reached the backend before + // the client observed the failure. + ErrAmbiguousTxCommit = errors.New("sql ambiguous tx commit") +) + +// AmbiguousTxCommitError wraps a classified commit error returned when Commit +// fails after the final database outcome may already be decided by the +// backend. +// +// This lets callers ask two independent questions about the same failure: +// +// - errors.Is(err, ErrAmbiguousTxCommit) reports that the transaction outcome +// is unknown and should not be retried blindly. +// - errors.As / errors.Unwrap expose the classified backend error for +// logging, metrics, or backend-specific handling. +type AmbiguousTxCommitError struct { + // Err is the classified backend error observed during commit. + Err error +} + +// Error returns the wrapped error string. +func (e *AmbiguousTxCommitError) Error() string { + if e == nil || e.Err == nil { + return ErrAmbiguousTxCommit.Error() + } + + return e.Err.Error() +} + +// Unwrap returns the wrapped classified commit error. +func (e *AmbiguousTxCommitError) Unwrap() error { + if e == nil { + return nil + } + + return e.Err +} + +// Is reports whether target matches the ambiguous commit sentinel. +func (e *AmbiguousTxCommitError) Is(target error) bool { + switch target { + case ErrAmbiguousTxCommit: + return true + + default: + return false + } +} + +// isCommitTransportError reports whether commit failed after the request may +// already have reached the backend. +func isCommitTransportError(err error) bool { + if errors.Is(err, driver.ErrBadConn) || errors.Is(err, sql.ErrConnDone) || + errors.Is(err, io.EOF) { + + return true + } + + return extractNetError(err) != nil +} + +// extractNetError extracts a net.Error used by commit transport checks. +func extractNetError(err error) netError { + var transportErr netError + if errors.As(err, &transportErr) { + return transportErr + } + + return nil +} + +// netError captures the net.Error behavior needed for commit transport checks. +type netError interface { + error + Timeout() bool +} diff --git a/wallet/internal/db/runtime/runtime_test.go b/wallet/internal/db/runtime/runtime_test.go new file mode 100644 index 0000000000..f9d8334960 --- /dev/null +++ b/wallet/internal/db/runtime/runtime_test.go @@ -0,0 +1,60 @@ +package runtime + +import ( + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +// Test errors used by the runtime helper tests. +var errRuntimeOther = errors.New("other") + +// TestAmbiguousTxCommitError verifies the sentinel-matching and unwrap +// behavior of AmbiguousTxCommitError. +func TestAmbiguousTxCommitError(t *testing.T) { + t.Parallel() + + err := &AmbiguousTxCommitError{Err: io.EOF} + wrappedSentinel := fmt.Errorf("wrap: %w", ErrAmbiguousTxCommit) + + require.ErrorIs(t, err, ErrAmbiguousTxCommit) + require.False(t, err.Is(wrappedSentinel)) + require.Equal(t, io.EOF.Error(), err.Error()) + require.ErrorIs(t, err, io.EOF) + require.Equal( + t, ErrAmbiguousTxCommit.Error(), (&AmbiguousTxCommitError{}).Error(), + ) + require.NoError(t, (*AmbiguousTxCommitError)(nil).Unwrap()) +} + +// TestCommitTransportError verifies that transport-shaped commit failures are +// detected as ambiguous-outcome candidates. +func TestCommitTransportError(t *testing.T) { + t.Parallel() + + require.True(t, isCommitTransportError(io.EOF)) + require.True(t, isCommitTransportError(driver.ErrBadConn)) + require.True(t, isCommitTransportError(sql.ErrConnDone)) + require.True(t, isCommitTransportError(netTimeoutError{})) + require.False(t, isCommitTransportError(errRuntimeOther)) + require.NotNil(t, extractNetError(netTimeoutError{})) + require.Nil(t, extractNetError(errRuntimeOther)) +} + +// netTimeoutError is a test helper that reports a transport timeout. +type netTimeoutError struct{} + +// Error returns the static timeout error string. +func (e netTimeoutError) Error() string { + return "timeout" +} + +// Timeout reports that the test error is a timeout. +func (e netTimeoutError) Timeout() bool { + return true +} From e6ded29bb094fb1d811c4635c2168e662338a222 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:56:41 +0800 Subject: [PATCH 553/691] db/runtime: add Read helper --- wallet/internal/db/runtime/runtime.go | 285 ++++++++++++++++++ .../internal/db/runtime/runtime_bench_test.go | 64 ++++ wallet/internal/db/runtime/runtime_test.go | 260 +++++++++++++++- 3 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 wallet/internal/db/runtime/runtime_bench_test.go diff --git a/wallet/internal/db/runtime/runtime.go b/wallet/internal/db/runtime/runtime.go index fc9397e2f1..248671c993 100644 --- a/wallet/internal/db/runtime/runtime.go +++ b/wallet/internal/db/runtime/runtime.go @@ -3,20 +3,37 @@ package runtime import ( + "context" "database/sql" "database/sql/driver" "errors" + "fmt" "io" + "time" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" ) // Runtime sentinel errors returned by the shared SQL helpers. var ( + // ErrStoreUnhealthy is returned when a runtime helper operates on an + // already unhealthy store. + ErrStoreUnhealthy = errors.New("sql store unhealthy") + // ErrAmbiguousTxCommit matches commit failures whose final database outcome // is unknown because the commit may already have reached the backend before // the client observed the failure. ErrAmbiguousTxCommit = errors.New("sql ambiguous tx commit") ) +// Read config validation errors returned by Read. +var ( + errReadMaxAttempts = errors.New("read max attempts must be positive") + errReadBaseDelay = errors.New("read base delay must be positive") + errReadMaxDelay = errors.New("read max delay must be positive") + errReadDelayOrder = errors.New("read base delay exceeds max delay") +) + // AmbiguousTxCommitError wraps a classified commit error returned when Commit // fails after the final database outcome may already be decided by the // backend. @@ -61,6 +78,274 @@ func (e *AmbiguousTxCommitError) Is(target error) bool { } } +// ReadHooks defines the runtime hooks needed by the shared read helper. +type ReadHooks interface { + // CheckHealthy fails fast when the backend has already been marked + // unhealthy. + CheckHealthy() error + + // ClassifyError maps backend failures into the shared SQL error model. + ClassifyError(err error) error + + // RecordError records a classified SQL error. + RecordError(err error) + + // RecordRetryAttempt records one retry backoff attempt. + RecordRetryAttempt() + + // RecordRetrySuccess records a successful retry outcome. + RecordRetrySuccess() + + // RecordRetryExhausted records that retry budget was exhausted. + RecordRetryExhausted() +} + +// ReadConfig holds caller-provided retry settings for Read. +// +// Callers should derive this from backend or wallet configuration so retry +// policy stays outside the shared runtime helper package. +type ReadConfig struct { + // MaxAttempts is the maximum number of callback attempts, including the + // first attempt. Set this to 1 to disable retries. + MaxAttempts int + + // BaseDelay is the starting backoff delay before jitter is applied. + // Ignored when MaxAttempts is 1. + BaseDelay time.Duration + + // MaxDelay is the upper bound for the exponential backoff delay. + // Ignored when MaxAttempts is 1. + MaxDelay time.Duration +} + +// readConfig holds the retry settings used by readWithConfig. +type readConfig struct { + // attempts is the maximum number of callback attempts. + attempts int + + // base is the starting backoff delay before jitter is applied. + base time.Duration + + // max is the upper bound for the exponential backoff delay. + max time.Duration + + // jitter rewrites one calculated delay before waiting. + jitter func(time.Duration) time.Duration + + // timer waits for one retry delay. + timer func(time.Duration) *time.Timer +} + +// Read executes a read-only SQL callback with transient retry handling. +// +// Read returns the callback result from the first successful attempt. On any +// failure, it returns the zero value of T together with the final error. +func Read[Q any, T any](ctx context.Context, hooks ReadHooks, queries Q, + config ReadConfig, + fn func(context.Context, Q) (T, error)) (T, error) { + + execConfig, err := buildReadConfig(config) + if err != nil { + var zero T + + return zero, fmt.Errorf("build read config: %w", err) + } + + return readWithConfig(ctx, hooks, queries, fn, execConfig) +} + +// buildReadConfig converts the caller-facing config into runtime settings. +func buildReadConfig(config ReadConfig) (readConfig, error) { + switch { + case config.MaxAttempts <= 0: + return readConfig{}, errReadMaxAttempts + + case config.MaxAttempts > 1 && config.BaseDelay <= 0: + return readConfig{}, errReadBaseDelay + + case config.MaxAttempts > 1 && config.MaxDelay <= 0: + return readConfig{}, errReadMaxDelay + + case config.MaxAttempts > 1 && config.BaseDelay > config.MaxDelay: + return readConfig{}, errReadDelayOrder + } + + return readConfig{ + attempts: config.MaxAttempts, + base: config.BaseDelay, + max: config.MaxDelay, + jitter: func(delay time.Duration) time.Duration { + return delay + }, + timer: time.NewTimer, + }, nil +} + +// readWithConfig executes Read with injected retry settings. +func readWithConfig[Q any, T any](ctx context.Context, hooks ReadHooks, + queries Q, fn func(context.Context, Q) (T, error), + config readConfig) (T, error) { + + var zero T + + // Fail fast if a prior fatal backend error already poisoned the store. + err := hooks.CheckHealthy() + if err != nil { + return zero, fmt.Errorf("check store health: %w", err) + } + + for attempt := range config.attempts { + result, shouldRetry, err := readAttempt(ctx, hooks, queries, fn) + if !shouldRetry && err == nil { + if attempt > 0 { + hooks.RecordRetrySuccess() + } + + return result, nil + } + + if !shouldRetry { + return zero, err + } + + if attempt == config.attempts-1 { + hooks.RecordRetryExhausted() + return zero, fmt.Errorf("read: %w", err) + } + + hooks.RecordRetryAttempt() + + // Use exponential backoff with injectable jitter and timer hooks so the + // helper stays deterministic under test. + delay := retryDelay(attempt, config.base, config.max) + delay = config.jitter(delay) + + err = waitForRetry(ctx, config.timer, delay) + if err != nil { + return zero, err + } + } + + return zero, ErrStoreUnhealthy +} + +// readAttempt executes one read callback attempt. +// +// It returns the callback result on success. On failure, it returns the zero +// value of T together with either an immediate return error or a classified +// transient error for the outer retry loop. +func readAttempt[Q any, T any](ctx context.Context, hooks ReadHooks, queries Q, + fn func(context.Context, Q) (T, error)) (T, bool, error) { + + var zero T + + // Run the read callback first so successful reads never pay any + // classification or retry overhead. + result, err := fn(ctx, queries) + if err == nil { + return result, false, nil + } + + // Preserve caller-driven cancellation and no-row results unchanged. + ctxErr := unwrapContextError(err) + if ctxErr != nil { + return zero, false, ctxErr + } + + if errors.Is(err, sql.ErrNoRows) { + return zero, false, err + } + + // Classify the backend failure once before deciding whether the read is + // safe to retry. + classifiedErr := hooks.ClassifyError(err) + hooks.RecordError(classifiedErr) + + // Retry only transient classified failures. Everything else returns to the + // caller immediately with context. + var sqlErr *dberr.SQLError + if !errors.As(classifiedErr, &sqlErr) || + sqlErr.Class() != dberr.ClassTransient { + + return zero, false, fmt.Errorf("read: %w", classifiedErr) + } + + //nolint:wrapcheck + // We need to return the exact classified error to be wrapped by Read. + return zero, true, classifiedErr +} + +// retryDelay applies bounded exponential backoff for retry attempts. +func retryDelay(attempt int, baseDelay, maxDelay time.Duration) time.Duration { + // The first retry waits for the configured base delay. Each later retry + // doubles the previous delay until the backoff reaches the configured cap. + delay := baseDelay + + for range attempt { + // Saturate before doubling once the next step would reach or exceed the + // cap. The maxDelay/2 check avoids computing delay*2 when the bounded + // result must already be maxDelay. + if delay >= maxDelay || delay > maxDelay/2 { + return maxDelay + } + + delay *= 2 + } + + // Keep the helper defensive when called directly with unchecked values. + if delay > maxDelay { + return maxDelay + } + + return delay +} + +// waitForRetry waits for the next retry delay or returns ctx.Err when the +// caller cancels first. +func waitForRetry(ctx context.Context, + timer func(time.Duration) *time.Timer, delay time.Duration) error { + + retryTimer := timer(delay) + defer stopRetryTimer(retryTimer) + + select { + case <-retryTimer.C: + return nil + + case <-ctx.Done(): + return ctx.Err() + } +} + +// stopRetryTimer stops a retry timer and drains a fired value when needed. +func stopRetryTimer(timer *time.Timer) { + if timer == nil { + return + } + + if timer.Stop() { + return + } + + select { + case <-timer.C: + default: + } +} + +// unwrapContextError preserves caller-driven cancellation and deadlines. +func unwrapContextError(err error) error { + if errors.Is(err, context.Canceled) { + return err + } + + if errors.Is(err, context.DeadlineExceeded) { + return err + } + + return nil +} + // isCommitTransportError reports whether commit failed after the request may // already have reached the backend. func isCommitTransportError(err error) bool { diff --git a/wallet/internal/db/runtime/runtime_bench_test.go b/wallet/internal/db/runtime/runtime_bench_test.go new file mode 100644 index 0000000000..7da3ee474e --- /dev/null +++ b/wallet/internal/db/runtime/runtime_bench_test.go @@ -0,0 +1,64 @@ +package runtime + +import ( + "context" + "testing" + "time" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" +) + +// BenchmarkReadSuccess measures a successful read with no retries. +func BenchmarkReadSuccess(b *testing.B) { + hooks := &fakeStore{} + for range b.N { + _, _ = readWithConfig( + context.Background(), hooks, struct{}{}, + func(context.Context, struct{}) (struct{}, error) { + return struct{}{}, nil + }, + readConfig{ + attempts: 1, + base: time.Millisecond, + max: time.Millisecond, + timer: time.NewTimer, + jitter: func(delay time.Duration) time.Duration { + return delay + }, + }, + ) + } +} + +// BenchmarkReadTransientRetry measures one retry before success. +func BenchmarkReadTransientRetry(b *testing.B) { + hooks := &fakeStore{classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendSQLite, dberr.ReasonBusy, "5", err, + ) + }} + + for range b.N { + attempts := 0 + _, _ = readWithConfig( + context.Background(), hooks, struct{}{}, + func(context.Context, struct{}) (struct{}, error) { + attempts++ + if attempts == 1 { + return struct{}{}, errRuntimeBusy + } + + return struct{}{}, nil + }, + readConfig{ + attempts: 2, + base: time.Millisecond, + max: time.Millisecond, + timer: immediateTimer, + jitter: func(delay time.Duration) time.Duration { + return delay + }, + }, + ) + } +} diff --git a/wallet/internal/db/runtime/runtime_test.go b/wallet/internal/db/runtime/runtime_test.go index f9d8334960..29743fe956 100644 --- a/wallet/internal/db/runtime/runtime_test.go +++ b/wallet/internal/db/runtime/runtime_test.go @@ -1,18 +1,70 @@ package runtime import ( + "context" "database/sql" "database/sql/driver" "errors" "fmt" "io" "testing" + "time" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" "github.com/stretchr/testify/require" ) // Test errors used by the runtime helper tests. -var errRuntimeOther = errors.New("other") +var ( + errRuntimeBusy = errors.New("busy") + errRuntimeRetry = errors.New("retry") + errRuntimeOther = errors.New("other") +) + +// fakeStore implements the runtime hook interfaces for tests. +type fakeStore struct { + healthyErr error + classifyFn func(error) error + errorCount int + retryAttempts int + retrySuccesses int + retryExhausted int + ambiguousCommit int +} + +// CheckHealthy returns the configured health-check result for fakeStore. +func (s *fakeStore) CheckHealthy() error { + return s.healthyErr +} + +// ClassifyError applies the configured classifier when one is present. +func (s *fakeStore) ClassifyError(err error) error { + if s.classifyFn != nil { + return s.classifyFn(err) + } + + return err +} + +// RecordError increments the fake classified-error counter. +func (s *fakeStore) RecordError(error) { + s.errorCount++ +} + +// RecordRetryAttempt increments the fake retry-attempt counter. +func (s *fakeStore) RecordRetryAttempt() { + s.retryAttempts++ +} + +// RecordRetrySuccess increments the fake retry-success counter. +func (s *fakeStore) RecordRetrySuccess() { + s.retrySuccesses++ +} + +// RecordRetryExhausted increments the fake retry-exhausted counter. +func (s *fakeStore) RecordRetryExhausted() { + s.retryExhausted++ +} // TestAmbiguousTxCommitError verifies the sentinel-matching and unwrap // behavior of AmbiguousTxCommitError. @@ -46,6 +98,212 @@ func TestCommitTransportError(t *testing.T) { require.Nil(t, extractNetError(errRuntimeOther)) } +// TestReadHealthyCheck verifies that unhealthy stores fail fast. +func TestReadHealthyCheck(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{healthyErr: ErrStoreUnhealthy} + _, err := Read(context.Background(), hooks, struct{}{}, ReadConfig{ + MaxAttempts: 1, + BaseDelay: time.Millisecond, + MaxDelay: time.Millisecond, + }, + func(context.Context, struct{}) (struct{}, error) { + return struct{}{}, nil + }) + require.ErrorIs(t, err, ErrStoreUnhealthy) +} + +// TestReadInvalidConfig verifies that invalid retry settings fail fast. +func TestReadInvalidConfig(t *testing.T) { + t.Parallel() + + _, err := Read(context.Background(), &fakeStore{}, struct{}{}, + ReadConfig{}, func(context.Context, struct{}) (struct{}, error) { + return struct{}{}, nil + }) + require.EqualError(t, err, + "build read config: read max attempts must be positive") +} + +// TestReadReturnsValue verifies that successful reads return their value and do +// not force disabled retries to provide unused backoff delays. +func TestReadReturnsValue(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{} + result, err := Read(context.Background(), hooks, struct{}{}, ReadConfig{ + MaxAttempts: 1, + }, + func(context.Context, struct{}) (string, error) { + return "ok", nil + }) + require.NoError(t, err) + require.Equal(t, "ok", result) +} + +// TestReadNoRowsPassthrough verifies that no-row results are preserved. +func TestReadNoRowsPassthrough(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{} + result, err := Read(context.Background(), hooks, struct{}{}, ReadConfig{ + MaxAttempts: 1, + BaseDelay: time.Millisecond, + MaxDelay: time.Millisecond, + }, + func(context.Context, struct{}) (string, error) { + return "", sql.ErrNoRows + }) + require.ErrorIs(t, err, sql.ErrNoRows) + require.Empty(t, result) + require.Zero(t, hooks.errorCount) +} + +// TestReadRetriesTransientError verifies that transient read failures retry, +// record stats, and eventually succeed. +func TestReadRetriesTransientError(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendSQLite, dberr.ReasonBusy, "5", err, + ) + }} + + attempts := 0 + result, err := readWithConfig( + context.Background(), hooks, struct{}{}, + func(context.Context, struct{}) (string, error) { + attempts++ + if attempts == 1 { + return "", errRuntimeBusy + } + + return "ok", nil + }, + readConfig{ + attempts: 2, + base: time.Millisecond, + max: time.Millisecond, + jitter: func(delay time.Duration) time.Duration { return delay }, + timer: immediateTimer, + }, + ) + require.NoError(t, err) + require.Equal(t, "ok", result) + require.Equal(t, 1, hooks.retryAttempts) + require.Equal(t, 1, hooks.retrySuccesses) + require.Equal(t, 1, hooks.errorCount) +} + +// TestReadRetryExhausted verifies that transient reads return the final +// classified error and a zero value after their retry budget is exhausted. +func TestReadRetryExhausted(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonSerialization, + "40001", err, + ) + }} + + result, err := readWithConfig( + context.Background(), hooks, struct{}{}, + func(context.Context, struct{}) (string, error) { + return "", errRuntimeRetry + }, + readConfig{ + attempts: 1, + base: time.Millisecond, + max: time.Millisecond, + jitter: func(delay time.Duration) time.Duration { return delay }, + timer: immediateTimer, + }, + ) + + var sqlErr *dberr.SQLError + + require.Empty(t, result) + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ClassTransient, sqlErr.Class()) + require.Equal(t, 1, hooks.retryExhausted) +} + +// TestReadWrappedContextCancellation verifies that wrapped caller cancellation +// is not hidden behind retry logic. +func TestReadWrappedContextCancellation(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{} + wrappedCanceled := fmt.Errorf("read accounts: %w", context.Canceled) + + result, err := Read(context.Background(), hooks, struct{}{}, ReadConfig{ + MaxAttempts: 1, + BaseDelay: time.Millisecond, + MaxDelay: time.Millisecond, + }, + func(context.Context, struct{}) (string, error) { + return "", wrappedCanceled + }) + require.Empty(t, result) + require.Same(t, wrappedCanceled, err) + require.ErrorIs(t, err, context.Canceled) +} + +// TestReadWaitCancellation verifies that cancellation during backoff returns +// ctx.Err. +func TestReadWaitCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + hooks := &fakeStore{classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendSQLite, dberr.ReasonBusy, "5", err, + ) + }} + + result, err := readWithConfig( + ctx, hooks, struct{}{}, + func(context.Context, struct{}) (string, error) { + return "", errRuntimeBusy + }, + readConfig{ + attempts: 2, + base: time.Millisecond, + max: time.Millisecond, + jitter: func(delay time.Duration) time.Duration { return delay }, + timer: time.NewTimer, + }, + ) + require.Empty(t, result) + require.ErrorIs(t, err, context.Canceled) +} + +// TestReadUtilities verifies the remaining read helper branches. +func TestReadUtilities(t *testing.T) { + t.Parallel() + + require.Equal(t, 100*time.Millisecond, + retryDelay(10, 10*time.Millisecond, 100*time.Millisecond)) + require.Equal(t, 100*time.Millisecond, + retryDelay(63, 10*time.Millisecond, 100*time.Millisecond)) + require.Equal(t, context.DeadlineExceeded, + unwrapContextError(context.DeadlineExceeded)) + + wrappedCanceled := fmt.Errorf("read: %w", context.Canceled) + require.Same(t, wrappedCanceled, unwrapContextError(wrappedCanceled)) + require.NoError(t, unwrapContextError(sql.ErrNoRows)) +} + +// immediateTimer returns a timer that fires immediately. +func immediateTimer(time.Duration) *time.Timer { + return time.NewTimer(0) +} + // netTimeoutError is a test helper that reports a transport timeout. type netTimeoutError struct{} From 39318126b4d70b47bc207399c0c7111427712205 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Apr 2026 11:51:22 +0800 Subject: [PATCH 554/691] db/runtime: add Write helper --- wallet/internal/db/runtime/runtime.go | 129 ++++++++- wallet/internal/db/runtime/runtime_test.go | 295 +++++++++++++++++---- 2 files changed, 376 insertions(+), 48 deletions(-) diff --git a/wallet/internal/db/runtime/runtime.go b/wallet/internal/db/runtime/runtime.go index 248671c993..1c1b83300e 100644 --- a/wallet/internal/db/runtime/runtime.go +++ b/wallet/internal/db/runtime/runtime.go @@ -23,6 +23,12 @@ var ( // ErrAmbiguousTxCommit matches commit failures whose final database outcome // is unknown because the commit may already have reached the backend before // the client observed the failure. + // + // Callers should treat this differently from an ordinary commit failure and + // should not blindly retry the transaction. Write returns this through + // AmbiguousTxCommitError so callers can use errors.Is(err, + // ErrAmbiguousTxCommit) for policy checks while still inspecting the + // underlying classified backend error. ErrAmbiguousTxCommit = errors.New("sql ambiguous tx commit") ) @@ -34,8 +40,8 @@ var ( errReadDelayOrder = errors.New("read base delay exceeds max delay") ) -// AmbiguousTxCommitError wraps a classified commit error returned when Commit -// fails after the final database outcome may already be decided by the +// AmbiguousTxCommitError wraps a classified commit error returned by Write when +// Commit fails after the final database outcome may already be decided by the // backend. // // This lets callers ask two independent questions about the same failure: @@ -100,6 +106,25 @@ type ReadHooks interface { RecordRetryExhausted() } +// WriteHooks defines the runtime hooks needed by the shared transaction helper. +type WriteHooks interface { + // CheckHealthy fails fast when the backend has already been marked + // unhealthy. + CheckHealthy() error + + // ClassifyError maps backend failures into the shared SQL error model. + ClassifyError(err error) error + + // RecordError records a classified SQL error. + RecordError(err error) + + // RecordAmbiguousTxCommit records a commit failure with unknown outcome. + RecordAmbiguousTxCommit() + + // RawDB returns the backend database handle used for transactions. + RawDB() *sql.DB +} + // ReadConfig holds caller-provided retry settings for Read. // // Callers should derive this from backend or wallet configuration so retry @@ -275,6 +300,106 @@ func readAttempt[Q any, T any](ctx context.Context, hooks ReadHooks, queries Q, return zero, true, classifiedErr } +// Write executes a transactional SQL callback without retrying it. +// +// Write returns the callback result only after Commit succeeds. If Begin, +// callback execution, or Commit fails, it returns the zero value of T together +// with the resulting error. +// +// hooks supplies the shared health check, error classification, SQL error +// recording, ambiguous-commit accounting, and raw database handle used by the +// helper. +// +// bind converts the started *sql.Tx into the caller's transactional query +// handle, such as a sqlc Queries value bound to that transaction. +// +// fn performs the transactional work with that bound handle. SQL and backend +// callback failures are normalized through hooks.ClassifyError, while ordinary +// domain errors pass through unchanged. +// +// If Commit fails after the backend may already have applied the transaction, +// Write returns an AmbiguousTxCommitError. Callers should detect that case +// with errors.Is(err, ErrAmbiguousTxCommit) before deciding whether retrying +// or compensating work is safe. +func Write[Q any, T any](ctx context.Context, hooks WriteHooks, + bind func(*sql.Tx) Q, fn func(Q) (T, error)) (T, error) { + + var zero T + + // Fail fast if a prior fatal backend error already poisoned the store. + err := hooks.CheckHealthy() + if err != nil { + return zero, fmt.Errorf("check store health: %w", err) + } + + // Begin the transaction before invoking the callback so begin failures are + // still classified and recorded consistently. + tx, err := hooks.RawDB().BeginTx(ctx, nil) + if err != nil { + classifiedErr := hooks.ClassifyError(err) + hooks.RecordError(classifiedErr) + + return zero, fmt.Errorf("begin tx: %w", classifiedErr) + } + + defer func() { + _ = tx.Rollback() + }() + + // Callback errors are normalized through the backend classifier so SQL + // driver failures reach callers consistently while non-SQL domain errors + // pass through unchanged. + result, err := fn(bind(tx)) + if err != nil { + classifiedErr := hooks.ClassifyError(err) + recordWriteCallbackError(hooks, classifiedErr) + + return zero, normalizeWriteCallbackError(err, classifiedErr) + } + + // Commit transport failures are wrapped so callers know the final + // transaction outcome is unknown. + err = tx.Commit() + if err != nil { + classifiedErr := hooks.ClassifyError(err) + hooks.RecordError(classifiedErr) + + if isCommitTransportError(err) { + hooks.RecordAmbiguousTxCommit() + + return zero, &AmbiguousTxCommitError{ + Err: fmt.Errorf("commit tx: %w", classifiedErr), + } + } + + return zero, fmt.Errorf("commit tx: %w", classifiedErr) + } + + return result, nil +} + +// normalizeWriteCallbackError preserves unchanged callback errors while +// returning normalized SQL backend failures when classification added one. +func normalizeWriteCallbackError(err, classifiedErr error) error { + var sqlErr *dberr.SQLError + if !errors.As(classifiedErr, &sqlErr) { + return err + } + + return classifiedErr +} + +// recordWriteCallbackError records one classified SQL callback error while +// leaving non-SQL callback failures out of SQL error accounting. +func recordWriteCallbackError(hooks WriteHooks, classifiedErr error) { + var sqlErr *dberr.SQLError + if !errors.As(classifiedErr, &sqlErr) { + return + } + + hooks.RecordError(classifiedErr) +} + // retryDelay applies bounded exponential backoff for retry attempts. func retryDelay(attempt int, baseDelay, maxDelay time.Duration) time.Duration { // The first retry waits for the configured base delay. Each later retry diff --git a/wallet/internal/db/runtime/runtime_test.go b/wallet/internal/db/runtime/runtime_test.go index 29743fe956..d1ecf5e06e 100644 --- a/wallet/internal/db/runtime/runtime_test.go +++ b/wallet/internal/db/runtime/runtime_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "sync/atomic" "testing" "time" @@ -16,20 +17,23 @@ import ( // Test errors used by the runtime helper tests. var ( - errRuntimeBusy = errors.New("busy") - errRuntimeRetry = errors.New("retry") - errRuntimeOther = errors.New("other") + errRuntimeBusy = errors.New("busy") + errRuntimeRetry = errors.New("retry") + errRuntimeCallback = errors.New("callback failed") + errRuntimeOther = errors.New("other") + errRuntimeUnused = errors.New("unused") ) // fakeStore implements the runtime hook interfaces for tests. type fakeStore struct { - healthyErr error - classifyFn func(error) error - errorCount int - retryAttempts int - retrySuccesses int - retryExhausted int - ambiguousCommit int + healthyErr error + classifyFn func(error) error + errorCount int + retryAttempts int + retrySuccesses int + retryExhausted int + ambiguousCommits int + db *sql.DB } // CheckHealthy returns the configured health-check result for fakeStore. @@ -66,36 +70,14 @@ func (s *fakeStore) RecordRetryExhausted() { s.retryExhausted++ } -// TestAmbiguousTxCommitError verifies the sentinel-matching and unwrap -// behavior of AmbiguousTxCommitError. -func TestAmbiguousTxCommitError(t *testing.T) { - t.Parallel() - - err := &AmbiguousTxCommitError{Err: io.EOF} - wrappedSentinel := fmt.Errorf("wrap: %w", ErrAmbiguousTxCommit) - - require.ErrorIs(t, err, ErrAmbiguousTxCommit) - require.False(t, err.Is(wrappedSentinel)) - require.Equal(t, io.EOF.Error(), err.Error()) - require.ErrorIs(t, err, io.EOF) - require.Equal( - t, ErrAmbiguousTxCommit.Error(), (&AmbiguousTxCommitError{}).Error(), - ) - require.NoError(t, (*AmbiguousTxCommitError)(nil).Unwrap()) +// RecordAmbiguousTxCommit increments the fake ambiguous-commit counter. +func (s *fakeStore) RecordAmbiguousTxCommit() { + s.ambiguousCommits++ } -// TestCommitTransportError verifies that transport-shaped commit failures are -// detected as ambiguous-outcome candidates. -func TestCommitTransportError(t *testing.T) { - t.Parallel() - - require.True(t, isCommitTransportError(io.EOF)) - require.True(t, isCommitTransportError(driver.ErrBadConn)) - require.True(t, isCommitTransportError(sql.ErrConnDone)) - require.True(t, isCommitTransportError(netTimeoutError{})) - require.False(t, isCommitTransportError(errRuntimeOther)) - require.NotNil(t, extractNetError(netTimeoutError{})) - require.Nil(t, extractNetError(errRuntimeOther)) +// RawDB returns the fake database handle used by transaction tests. +func (s *fakeStore) RawDB() *sql.DB { + return s.db } // TestReadHealthyCheck verifies that unhealthy stores fail fast. @@ -299,20 +281,241 @@ func TestReadUtilities(t *testing.T) { require.NoError(t, unwrapContextError(sql.ErrNoRows)) } +// TestWriteHealthyCheck verifies that unhealthy stores fail fast before +// starting a transaction. +func TestWriteHealthyCheck(t *testing.T) { + t.Parallel() + + hooks := &fakeStore{healthyErr: ErrStoreUnhealthy} + result, err := Write(context.Background(), hooks, func(*sql.Tx) struct{} { + return struct{}{} + }, func(struct{}) (string, error) { return "", nil }) + require.Empty(t, result) + require.ErrorIs(t, err, ErrStoreUnhealthy) +} + +// TestWriteReturnsValue verifies that successful writes return their value only +// after commit succeeds. +func TestWriteReturnsValue(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{}) + hooks := &fakeStore{db: dbConn} + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "ok", nil + }) + require.NoError(t, err) + require.Equal(t, "ok", result) +} + +// TestWriteBeginFailure verifies that begin failures are classified and +// recorded. +func TestWriteBeginFailure(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{err: driver.ErrBadConn}) + hooks := &fakeStore{db: dbConn, classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, "", err, + ) + }} + + result, err := Write(context.Background(), hooks, func(*sql.Tx) struct{} { + return struct{}{} + }, func(struct{}) (string, error) { return "", nil }) + + var sqlErr *dberr.SQLError + + require.Empty(t, result) + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) + require.Equal(t, 1, hooks.errorCount) +} + +// TestWriteCallbackErrorPassthrough verifies that non-SQL callback errors pass +// through unchanged and do not leak a result value. +func TestWriteCallbackErrorPassthrough(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{}) + hooks := &fakeStore{db: dbConn} + callbackErr := errRuntimeCallback + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "", callbackErr + }) + require.Empty(t, result) + require.ErrorIs(t, err, callbackErr) + require.Zero(t, hooks.errorCount) +} + +// TestWriteCallbackErrorClassified verifies that SQL-like callback failures are +// normalized through the backend classifier before they reach callers. +func TestWriteCallbackErrorClassified(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{}) + hooks := &fakeStore{db: dbConn, classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonConstraint, "23505", err, + ) + }} + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "", errRuntimeCallback + }) + + var sqlErr *dberr.SQLError + + require.Empty(t, result) + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) + require.Equal(t, 1, hooks.errorCount) +} + +// TestWriteCommitAmbiguous verifies that transport failures during commit are +// wrapped as ambiguous commit errors, recorded, and return a zero value. +func TestWriteCommitAmbiguous(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{commitErr: io.EOF}) + hooks := &fakeStore{db: dbConn, classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, + "08006", err, + ) + }} + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "applied", nil + }) + require.Empty(t, result) + require.ErrorIs(t, err, ErrAmbiguousTxCommit) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) + require.Equal(t, 1, hooks.ambiguousCommits) + require.Equal(t, 1, hooks.errorCount) +} + +// TestWriteCommitUnavailable verifies that classified availability errors +// without a transport failure stay ordinary commit errors. +func TestWriteCommitUnavailable(t *testing.T) { + t.Parallel() + + dbConn := newTestDB(t, beginErrDriver{commitErr: errRuntimeOther}) + hooks := &fakeStore{db: dbConn, classifyFn: func(err error) error { + return dberr.NewSQLError( + dberr.BackendPostgres, dberr.ReasonUnavailable, + "08006", err, + ) + }} + + result, err := Write(context.Background(), hooks, func(tx *sql.Tx) *sql.Tx { + return tx + }, func(*sql.Tx) (string, error) { + return "applied", nil + }) + require.Empty(t, result) + require.NotErrorIs(t, err, ErrAmbiguousTxCommit) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) + require.Zero(t, hooks.ambiguousCommits) + require.Equal(t, 1, hooks.errorCount) +} + // immediateTimer returns a timer that fires immediately. func immediateTimer(time.Duration) *time.Timer { return time.NewTimer(0) } -// netTimeoutError is a test helper that reports a transport timeout. -type netTimeoutError struct{} +// beginErrDriver is a test SQL driver that injects begin and commit failures. +type beginErrDriver struct { + err error + commitErr error +} + +// Open returns a connection that injects the configured failures. +func (d beginErrDriver) Open(string) (driver.Conn, error) { + return beginErrConn(d), nil +} + +// beginErrConn is a test connection that injects begin and commit failures. +type beginErrConn struct { + err error + commitErr error +} + +// Prepare returns an unused statement error for the test driver. +func (c beginErrConn) Prepare(string) (driver.Stmt, error) { + return nil, errRuntimeUnused +} + +// Close reports success for the test connection close path. +func (c beginErrConn) Close() error { + return nil +} -// Error returns the static timeout error string. -func (e netTimeoutError) Error() string { - return "timeout" +// Begin returns the legacy unused error because tests use BeginTx instead. +func (c beginErrConn) Begin() (driver.Tx, error) { + return nil, errRuntimeUnused } -// Timeout reports that the test error is a timeout. -func (e netTimeoutError) Timeout() bool { - return true +// BeginTx returns the configured begin error or a transaction test double. +func (c beginErrConn) BeginTx(ctx context.Context, + _ driver.TxOptions) (driver.Tx, error) { + + _ = ctx + + if c.err != nil { + return nil, c.err + } + + return beginErrTx{commitErr: c.commitErr}, nil +} + +// beginErrTx is a test transaction that injects a commit failure. +type beginErrTx struct { + commitErr error +} + +// Commit returns the configured commit error. +func (tx beginErrTx) Commit() error { + return tx.commitErr +} + +// Rollback reports success for the test rollback path. +func (tx beginErrTx) Rollback() error { + return nil +} + +// testDriverSeq keeps each registered test driver name unique. +var testDriverSeq atomic.Uint64 + +// newTestDB registers one test driver instance and opens a matching database. +func newTestDB(t *testing.T, drv beginErrDriver) *sql.DB { + t.Helper() + + name := fmt.Sprintf("runtime-test-%d", testDriverSeq.Add(1)) + sql.Register(name, drv) + + dbConn, err := sql.Open(name, "") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + return dbConn } From b9afec3ab0430964a6971ffa147a4fb935f4d50d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 15 Apr 2026 18:33:57 +0800 Subject: [PATCH 555/691] bwtest: pin bitcoind to v1 transport bitcoind v30 enables v2 transport by default, while the shared btcd\nminer in the integration harness still runs in legacy transport mode.\nThis can leave the bitcoind backend stuck at height 0 during startup.\n\nPin bitcoind to v1 in the harness so the backend connects\ndeterministically during CI and local bitcoind itests. --- bwtest/bitcoind.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bwtest/bitcoind.go b/bwtest/bitcoind.go index b949df838a..b8c62c2c48 100644 --- a/bwtest/bitcoind.go +++ b/bwtest/bitcoind.go @@ -154,6 +154,12 @@ func (b *BitcoindBackend) Start() error { "-regtest", "-connect=" + b.minerAddr, + // bitcoind enables P2P v2 by default, but the shared btcd miner keeps + // v2 transport disabled by default. Pin bitcoind to v1 here so harness + // startup does not rely on downgrade timing during the first peer + // handshake. + "-v2transport=0", + // Enable wallet-required indexing and RPC auth. "-txindex", "-disablewallet", From 87f5d66d0dd8b3b1a321a20fbff464c83d746567 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 16 Apr 2026 19:35:34 -0300 Subject: [PATCH 556/691] wallet: add key scope wallet/id index --- wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql | 3 +++ wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql | 3 +++ 2 files changed, 6 insertions(+) diff --git a/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql b/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql index 5386d22855..7275240b4a 100644 --- a/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql +++ b/wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql @@ -41,6 +41,9 @@ CREATE TABLE key_scopes ( CREATE UNIQUE INDEX uidx_key_scopes_wallet_purpose_coin ON key_scopes (wallet_id, purpose, coin_type); +-- Unique index to support composite foreign keys scoped by wallet ownership. +CREATE UNIQUE INDEX uidx_key_scopes_wallet_id_id ON key_scopes (wallet_id, id); + -- Key Scope Secrets table to hold encrypted coin-type secrets for each scope. -- Separated from the main key_scopes table for security and access pattern isolation. -- Watch-only scopes may have no corresponding row in this table or have NULL diff --git a/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql b/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql index ade3e3ef78..1f78f64f20 100644 --- a/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql +++ b/wallet/internal/sql/sqlite/migrations/000004_key_scopes.up.sql @@ -41,6 +41,9 @@ CREATE TABLE key_scopes ( CREATE UNIQUE INDEX uidx_key_scopes_wallet_purpose_coin ON key_scopes (wallet_id, purpose, coin_type); +-- Unique index to support composite foreign keys scoped by wallet ownership. +CREATE UNIQUE INDEX uidx_key_scopes_wallet_id_id ON key_scopes (wallet_id, id); + -- Key Scope Secrets table to hold encrypted coin-type secrets for each scope. -- Separated from the main key_scopes table for security and access pattern isolation. -- Watch-only scopes may have no corresponding row in this table or have NULL From 250d9a86d3654e3e15488f31b3f20cbef62eb4cc Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 16 Apr 2026 19:39:04 -0300 Subject: [PATCH 557/691] wallet: scope accounts to wallet ownership --- .../internal/db/itest/account_store_test.go | 46 ++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 54 +++++++++++++- .../internal/db/itest/fixtures_sqlite_test.go | 54 +++++++++++++- .../sql/pg/migrations/000005_accounts.up.sql | 16 ++++- wallet/internal/sql/pg/queries/accounts.sql | 63 +++++++++++----- wallet/internal/sql/pg/sqlc/accounts.sql.go | 71 +++++++++++++------ wallet/internal/sql/pg/sqlc/models.go | 1 + .../sqlite/migrations/000005_accounts.up.sql | 16 ++++- .../internal/sql/sqlite/queries/accounts.sql | 61 +++++++++++----- .../internal/sql/sqlite/sqlc/accounts.sql.go | 69 ++++++++++++------ wallet/internal/sql/sqlite/sqlc/models.go | 1 + 11 files changed, 366 insertions(+), 86 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index 63ee0e195f..db06fb306a 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -46,6 +46,52 @@ func TestCreateAccounts(t *testing.T) { } } +// TestCreateDerivedAccountRejectsWalletScopeMismatch verifies that the +// composite wallet/scope invariant is enforced by the database on direct +// derived-account inserts. +func TestCreateDerivedAccountRejectsWalletScopeMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + firstWalletID := newWallet(t, store, "wallet-raw-derived-account-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-raw-derived-account-mismatch-b") + createDerivedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "seed-derived-scope", + ) + + firstScopeID := GetKeyScopeID(t, queries, firstWalletID, db.KeyScopeBIP0084) + + err := createDerivedAccountRaw( + t, store.DB(), secondWalletID, firstScopeID, 0, "raw-derived-mismatch", + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + +// TestCreateImportedAccountRejectsWalletScopeMismatch verifies that the +// composite wallet/scope invariant is enforced by the database on direct +// imported-account inserts. +func TestCreateImportedAccountRejectsWalletScopeMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + firstWalletID := newWallet(t, store, "wallet-raw-imported-account-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-raw-imported-account-mismatch-b") + CreateImportedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "seed-imported-scope", + ) + + firstScopeID := GetKeyScopeID(t, queries, firstWalletID, db.KeyScopeBIP0084) + + err := createImportedAccountRaw( + t, store.DB(), secondWalletID, firstScopeID, "raw-imported-mismatch", + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + // TestCreateDerivedAccountErrors verifies that CreateDerivedAccount returns // appropriate errors for invalid inputs. func TestCreateDerivedAccountErrors(t *testing.T) { diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index c029000305..8633a4f827 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -51,15 +51,67 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlc.Queries, require.NoError(t, err) } +// createDerivedAccountRaw inserts a derived account directly through the +// database so tests can validate wallet/scope ownership invariants. +func createDerivedAccountRaw(t *testing.T, dbConn *sql.DB, walletID uint32, + scopeID int64, accountNumber uint32, name string) error { + + t.Helper() + + const stmt = ` + INSERT INTO accounts ( + wallet_id, + scope_id, + account_number, + account_name, + origin_id, + is_watch_only + ) VALUES ($1, $2, $3, $4, $5, $6)` + + _, err := dbConn.ExecContext( + t.Context(), stmt, int64(walletID), scopeID, int64(accountNumber), + name, int16(db.DerivedAccount), false, + ) + + return err +} + +// createImportedAccountRaw inserts an imported account directly through the +// database so tests can validate wallet/scope ownership invariants. +func createImportedAccountRaw(t *testing.T, dbConn *sql.DB, walletID uint32, + scopeID int64, name string) error { + + t.Helper() + + const stmt = ` + INSERT INTO accounts ( + wallet_id, + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + is_watch_only + ) VALUES ($1, $2, NULL, $3, $4, $5, $6)` + + _, err := dbConn.ExecContext( + t.Context(), stmt, int64(walletID), scopeID, name, + int16(db.ImportedAccount), RandomBytes(32), true, + ) + + return err +} + // CreateAddressWithIndex creates a derived address with a specific address // index. Used to test address index overflow without creating billions of // addresses. func CreateAddressWithIndex(t *testing.T, queries *sqlc.Queries, - accountID int64, branch int16, index uint32) { + walletID uint32, accountID int64, branch int16, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( t.Context(), sqlc.CreateDerivedAddressParams{ + WalletID: int64(walletID), AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int16(db.WitnessPubKey), diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index d2f2475e72..9ba8b38302 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -51,15 +51,67 @@ func CreateAccountWithNumber(t *testing.T, queries *sqlc.Queries, require.NoError(t, err) } +// createDerivedAccountRaw inserts a derived account directly through the +// database so tests can validate wallet/scope ownership invariants. +func createDerivedAccountRaw(t *testing.T, dbConn *sql.DB, walletID uint32, + scopeID int64, accountNumber uint32, name string) error { + + t.Helper() + + const stmt = ` + INSERT INTO accounts ( + wallet_id, + scope_id, + account_number, + account_name, + origin_id, + is_watch_only + ) VALUES (?, ?, ?, ?, ?, ?)` + + _, err := dbConn.ExecContext( + t.Context(), stmt, int64(walletID), scopeID, int64(accountNumber), + name, int64(db.DerivedAccount), false, + ) + + return err +} + +// createImportedAccountRaw inserts an imported account directly through the +// database so tests can validate wallet/scope ownership invariants. +func createImportedAccountRaw(t *testing.T, dbConn *sql.DB, walletID uint32, + scopeID int64, name string) error { + + t.Helper() + + const stmt = ` + INSERT INTO accounts ( + wallet_id, + scope_id, + account_number, + account_name, + origin_id, + encrypted_public_key, + is_watch_only + ) VALUES (?, ?, NULL, ?, ?, ?, ?)` + + _, err := dbConn.ExecContext( + t.Context(), stmt, int64(walletID), scopeID, name, + int64(db.ImportedAccount), RandomBytes(32), true, + ) + + return err +} + // CreateAddressWithIndex creates a derived address with a specific address // index. Used to test address index overflow without creating billions of // addresses. func CreateAddressWithIndex(t *testing.T, queries *sqlc.Queries, - accountID int64, branch uint32, index uint32) { + walletID uint32, accountID int64, branch uint32, index uint32) { t.Helper() _, err := queries.CreateDerivedAddress( t.Context(), sqlc.CreateDerivedAddressParams{ + WalletID: int64(walletID), AccountID: accountID, ScriptPubKey: RandomBytes(20), TypeID: int64(db.WitnessPubKey), diff --git a/wallet/internal/sql/pg/migrations/000005_accounts.up.sql b/wallet/internal/sql/pg/migrations/000005_accounts.up.sql index cea4682d93..a1b1914c23 100644 --- a/wallet/internal/sql/pg/migrations/000005_accounts.up.sql +++ b/wallet/internal/sql/pg/migrations/000005_accounts.up.sql @@ -38,6 +38,9 @@ CREATE TABLE accounts ( -- DB ID of the account, primary key. id BIGSERIAL PRIMARY KEY, + -- Reference to the wallet this account belongs to. + wallet_id BIGINT NOT NULL, + -- Reference to the key scope this account belongs to. scope_id BIGINT NOT NULL, @@ -83,9 +86,13 @@ CREATE TABLE accounts ( -- Imported address counter must be non-negative. CHECK (imported_key_count >= 0), - -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure - -- that the key scope cannot be deleted if accounts still exist. - FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, + -- Composite foreign key to key scopes. This ensures scope_id belongs to + -- the same wallet_id as the account row. Wallet ownership is transitively + -- enforced through key_scopes, which has its own FK to wallets. Using ON + -- DELETE RESTRICT to ensure that the wallet/scope cannot be deleted if + -- accounts still exist. + FOREIGN KEY (wallet_id, scope_id) + REFERENCES key_scopes (wallet_id, id) ON DELETE RESTRICT, -- Foreign key constraint to account origins. Using ON DELETE RESTRICT to -- ensure that the origin cannot be deleted if accounts still exist. @@ -95,6 +102,9 @@ CREATE TABLE accounts ( -- Index on foreign scope_id for faster lookups and joins. CREATE INDEX idx_accounts_scope ON accounts (scope_id); +-- Unique index to support composite foreign keys from addresses. +CREATE UNIQUE INDEX uidx_accounts_wallet_id_id ON accounts (wallet_id, id); + -- Unique partial index to prevent duplicate account numbers within the same -- key scope. Only enforced for non-NULL account numbers (derived accounts). -- Imported accounts have NULL account_number and are excluded from this diff --git a/wallet/internal/sql/pg/queries/accounts.sql b/wallet/internal/sql/pg/queries/accounts.sql index 0a92f1f307..79c7c542a0 100644 --- a/wallet/internal/sql/pg/queries/accounts.sql +++ b/wallet/internal/sql/pg/queries/accounts.sql @@ -31,6 +31,7 @@ SELECT pg_advisory_xact_lock(hashtextextended('account_scope', $1::BIGINT)); -- to acquire the advisory lock and prevent race conditions. -- See LockAccountScope comments for why this is a separate statement. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -39,15 +40,21 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ( - $1, +SELECT + ks.wallet_id, + ks.id AS scope_id, ( - SELECT coalesce(max(account_number), -1) + 1 - FROM accounts - WHERE scope_id = $1 - ), - $2, $3, $4, $5, $6 -) + SELECT coalesce(max(a.account_number), -1) + 1 + FROM accounts AS a + WHERE a.scope_id = sqlc.arg('scope_id') + ) AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('encrypted_public_key') AS encrypted_public_key, + sqlc.arg('master_fingerprint') AS master_fingerprint, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, account_number, created_at; -- name: CreateImportedAccount :one @@ -55,6 +62,7 @@ RETURNING id, account_number, created_at; -- number. Imported accounts don't follow BIP44 derivation, so they don't need -- a sequential account number. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -63,7 +71,17 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ($1, NULL, $2, $3, $4, $5, $6) +SELECT + ks.wallet_id, + ks.id AS scope_id, + NULL AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('encrypted_public_key') AS encrypted_public_key, + sqlc.arg('master_fingerprint') AS master_fingerprint, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, created_at; -- name: CreateAccountSecret :exec @@ -265,12 +283,12 @@ UPDATE accounts SET account_name = sqlc.arg(new_name) WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = sqlc.arg(wallet_id) - AND purpose = sqlc.arg(purpose) - AND coin_type = sqlc.arg(coin_type) + key_scopes.wallet_id = sqlc.arg('wallet_id') + AND key_scopes.purpose = sqlc.arg('purpose') + AND key_scopes.coin_type = sqlc.arg('coin_type') ) AND account_number = sqlc.arg(account_number); @@ -280,12 +298,12 @@ UPDATE accounts SET account_name = sqlc.arg(new_name) WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = sqlc.arg(wallet_id) - AND purpose = sqlc.arg(purpose) - AND coin_type = sqlc.arg(coin_type) + key_scopes.wallet_id = sqlc.arg('wallet_id') + AND key_scopes.purpose = sqlc.arg('purpose') + AND key_scopes.coin_type = sqlc.arg('coin_type') ) AND account_name = sqlc.arg(old_name); @@ -293,13 +311,22 @@ WHERE -- Test-only: Creates a derived account with a specific account number. -- Used for testing account number overflow without creating billions of accounts. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, origin_id, is_watch_only ) -VALUES ($1, $2, $3, $4, $5) +SELECT + ks.wallet_id, + ks.id AS scope_id, + sqlc.arg('account_number') AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, account_number, created_at; -- name: GetAndIncrementNextExternalIndex :one diff --git a/wallet/internal/sql/pg/sqlc/accounts.sql.go b/wallet/internal/sql/pg/sqlc/accounts.sql.go index bfd09a6879..c5d4cb0e1b 100644 --- a/wallet/internal/sql/pg/sqlc/accounts.sql.go +++ b/wallet/internal/sql/pg/sqlc/accounts.sql.go @@ -33,6 +33,7 @@ func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecr const CreateDerivedAccount = `-- name: CreateDerivedAccount :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -41,15 +42,21 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ( - $1, +SELECT + ks.wallet_id, + ks.id AS scope_id, ( - SELECT coalesce(max(account_number), -1) + 1 - FROM accounts - WHERE scope_id = $1 - ), - $2, $3, $4, $5, $6 -) + SELECT coalesce(max(a.account_number), -1) + 1 + FROM accounts AS a + WHERE a.scope_id = $1 + ) AS account_number, + $2 AS account_name, + $3 AS origin_id, + $4 AS encrypted_public_key, + $5 AS master_fingerprint, + $6 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = $1 RETURNING id, account_number, created_at ` @@ -88,22 +95,31 @@ func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAcc const CreateDerivedAccountWithNumber = `-- name: CreateDerivedAccountWithNumber :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, origin_id, is_watch_only ) -VALUES ($1, $2, $3, $4, $5) +SELECT + ks.wallet_id, + ks.id AS scope_id, + $1 AS account_number, + $2 AS account_name, + $3 AS origin_id, + $4 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = $5 RETURNING id, account_number, created_at ` type CreateDerivedAccountWithNumberParams struct { - ScopeID int64 AccountNumber sql.NullInt64 AccountName string OriginID int16 IsWatchOnly bool + ScopeID int64 } type CreateDerivedAccountWithNumberRow struct { @@ -116,11 +132,11 @@ type CreateDerivedAccountWithNumberRow struct { // Used for testing account number overflow without creating billions of accounts. func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) { row := q.queryRow(ctx, q.createDerivedAccountWithNumberStmt, CreateDerivedAccountWithNumber, - arg.ScopeID, arg.AccountNumber, arg.AccountName, arg.OriginID, arg.IsWatchOnly, + arg.ScopeID, ) var i CreateDerivedAccountWithNumberRow err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) @@ -129,6 +145,7 @@ func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg Create const CreateImportedAccount = `-- name: CreateImportedAccount :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -137,17 +154,27 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ($1, NULL, $2, $3, $4, $5, $6) +SELECT + ks.wallet_id, + ks.id AS scope_id, + NULL AS account_number, + $1 AS account_name, + $2 AS origin_id, + $3 AS encrypted_public_key, + $4 AS master_fingerprint, + $5 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = $6 RETURNING id, created_at ` type CreateImportedAccountParams struct { - ScopeID int64 AccountName string OriginID int16 EncryptedPublicKey []byte MasterFingerprint sql.NullInt64 IsWatchOnly bool + ScopeID int64 } type CreateImportedAccountRow struct { @@ -160,12 +187,12 @@ type CreateImportedAccountRow struct { // a sequential account number. func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) { row := q.queryRow(ctx, q.createImportedAccountStmt, CreateImportedAccount, - arg.ScopeID, arg.AccountName, arg.OriginID, arg.EncryptedPublicKey, arg.MasterFingerprint, arg.IsWatchOnly, + arg.ScopeID, ) var i CreateImportedAccountRow err := row.Scan(&i.ID, &i.CreatedAt) @@ -846,12 +873,12 @@ UPDATE accounts SET account_name = $1 WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = $2 - AND purpose = $3 - AND coin_type = $4 + key_scopes.wallet_id = $2 + AND key_scopes.purpose = $3 + AND key_scopes.coin_type = $4 ) AND account_name = $5 ` @@ -884,12 +911,12 @@ UPDATE accounts SET account_name = $1 WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = $2 - AND purpose = $3 - AND coin_type = $4 + key_scopes.wallet_id = $2 + AND key_scopes.purpose = $3 + AND key_scopes.coin_type = $4 ) AND account_number = $5 ` diff --git a/wallet/internal/sql/pg/sqlc/models.go b/wallet/internal/sql/pg/sqlc/models.go index 9173419113..765db782f8 100644 --- a/wallet/internal/sql/pg/sqlc/models.go +++ b/wallet/internal/sql/pg/sqlc/models.go @@ -11,6 +11,7 @@ import ( type Account struct { ID int64 + WalletID int64 ScopeID int64 AccountNumber sql.NullInt64 AccountName string diff --git a/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql b/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql index e99fc90910..2baed4a234 100644 --- a/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql +++ b/wallet/internal/sql/sqlite/migrations/000005_accounts.up.sql @@ -38,6 +38,9 @@ CREATE TABLE accounts ( -- DB ID of the account, primary key. id INTEGER PRIMARY KEY, + -- Reference to the wallet this account belongs to. + wallet_id INTEGER NOT NULL, + -- Reference to the key scope this account belongs to. scope_id INTEGER NOT NULL, @@ -82,9 +85,13 @@ CREATE TABLE accounts ( -- Imported address counter must be non-negative. CHECK (imported_key_count >= 0), - -- Foreign key constraints to key scope. Using ON DELETE RESTRICT to ensure - -- that the key scope cannot be deleted if accounts still exist. - FOREIGN KEY (scope_id) REFERENCES key_scopes (id) ON DELETE RESTRICT, + -- Composite foreign key to key scopes. This ensures scope_id belongs to + -- the same wallet_id as the account row. Wallet ownership is transitively + -- enforced through key_scopes, which has its own FK to wallets. Using ON + -- DELETE RESTRICT to ensure that the wallet/scope cannot be deleted if + -- accounts still exist. + FOREIGN KEY (wallet_id, scope_id) + REFERENCES key_scopes (wallet_id, id) ON DELETE RESTRICT, -- Foreign key constraint to account origins. Using ON DELETE RESTRICT to -- ensure that the origin cannot be deleted if accounts still exist. @@ -94,6 +101,9 @@ CREATE TABLE accounts ( -- Index on foreign scope_id for faster lookups and joins. CREATE INDEX idx_accounts_scope ON accounts (scope_id); +-- Unique index to support composite foreign keys from addresses. +CREATE UNIQUE INDEX uidx_accounts_wallet_id_id ON accounts (wallet_id, id); + -- Unique partial index to prevent duplicate account numbers within the same -- key scope. Only enforced for non-NULL account numbers (derived accounts). -- Imported accounts have NULL account_number and are excluded from this diff --git a/wallet/internal/sql/sqlite/queries/accounts.sql b/wallet/internal/sql/sqlite/queries/accounts.sql index ed50418fd2..4aff52c2ed 100644 --- a/wallet/internal/sql/sqlite/queries/accounts.sql +++ b/wallet/internal/sql/sqlite/queries/accounts.sql @@ -3,6 +3,7 @@ -- account number from existing accounts. SQLite's _txlock=immediate ensures -- only one writer at a time, preventing concurrent allocation conflicts. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -11,14 +12,20 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ( - ?1, +SELECT + ks.wallet_id, + ks.id AS scope_id, ( - SELECT coalesce(max(account_number), -1) + 1 FROM accounts - WHERE scope_id = ?1 - ), - ?2, ?3, ?4, ?5, ?6 -) + SELECT coalesce(max(a.account_number), -1) + 1 FROM accounts AS a + WHERE a.scope_id = sqlc.arg('scope_id') + ) AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('encrypted_public_key') AS encrypted_public_key, + sqlc.arg('master_fingerprint') AS master_fingerprint, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, account_number, created_at; -- name: CreateImportedAccount :one @@ -26,6 +33,7 @@ RETURNING id, account_number, created_at; -- number. Imported accounts don't follow BIP44 derivation, so they don't need -- a sequential account number. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -34,7 +42,17 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES (?, NULL, ?, ?, ?, ?, ?) +SELECT + ks.wallet_id, + ks.id AS scope_id, + NULL AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('encrypted_public_key') AS encrypted_public_key, + sqlc.arg('master_fingerprint') AS master_fingerprint, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, created_at; -- name: CreateAccountSecret :exec @@ -236,12 +254,12 @@ UPDATE accounts SET account_name = sqlc.arg(new_name) WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = sqlc.arg(wallet_id) - AND purpose = sqlc.arg(purpose) - AND coin_type = sqlc.arg(coin_type) + key_scopes.wallet_id = sqlc.arg('wallet_id') + AND key_scopes.purpose = sqlc.arg('purpose') + AND key_scopes.coin_type = sqlc.arg('coin_type') ) AND account_number = sqlc.arg(account_number); @@ -251,12 +269,12 @@ UPDATE accounts SET account_name = sqlc.arg(new_name) WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = sqlc.arg(wallet_id) - AND purpose = sqlc.arg(purpose) - AND coin_type = sqlc.arg(coin_type) + key_scopes.wallet_id = sqlc.arg('wallet_id') + AND key_scopes.purpose = sqlc.arg('purpose') + AND key_scopes.coin_type = sqlc.arg('coin_type') ) AND account_name = sqlc.arg(old_name); @@ -264,13 +282,22 @@ WHERE -- Test-only: Creates a derived account with a specific account number. -- Used for testing account number overflow without creating billions of accounts. INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, origin_id, is_watch_only ) -VALUES (?, ?, ?, ?, ?) +SELECT + ks.wallet_id, + ks.id AS scope_id, + sqlc.arg('account_number') AS account_number, + sqlc.arg('account_name') AS account_name, + sqlc.arg('origin_id') AS origin_id, + sqlc.arg('is_watch_only') AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = sqlc.arg('scope_id') RETURNING id, account_number, created_at; -- name: GetAndIncrementNextExternalIndex :one diff --git a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go index 481ede7af2..a4789681cd 100644 --- a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go @@ -33,6 +33,7 @@ func (q *Queries) CreateAccountSecret(ctx context.Context, arg CreateAccountSecr const CreateDerivedAccount = `-- name: CreateDerivedAccount :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -41,14 +42,20 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES ( - ?1, +SELECT + ks.wallet_id, + ks.id AS scope_id, ( - SELECT coalesce(max(account_number), -1) + 1 FROM accounts - WHERE scope_id = ?1 - ), - ?2, ?3, ?4, ?5, ?6 -) + SELECT coalesce(max(a.account_number), -1) + 1 FROM accounts AS a + WHERE a.scope_id = ?1 + ) AS account_number, + ?2 AS account_name, + ?3 AS origin_id, + ?4 AS encrypted_public_key, + ?5 AS master_fingerprint, + ?6 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = ?1 RETURNING id, account_number, created_at ` @@ -86,22 +93,31 @@ func (q *Queries) CreateDerivedAccount(ctx context.Context, arg CreateDerivedAcc const CreateDerivedAccountWithNumber = `-- name: CreateDerivedAccountWithNumber :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, origin_id, is_watch_only ) -VALUES (?, ?, ?, ?, ?) +SELECT + ks.wallet_id, + ks.id AS scope_id, + ?1 AS account_number, + ?2 AS account_name, + ?3 AS origin_id, + ?4 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = ?5 RETURNING id, account_number, created_at ` type CreateDerivedAccountWithNumberParams struct { - ScopeID int64 AccountNumber sql.NullInt64 AccountName string OriginID int64 IsWatchOnly bool + ScopeID int64 } type CreateDerivedAccountWithNumberRow struct { @@ -114,11 +130,11 @@ type CreateDerivedAccountWithNumberRow struct { // Used for testing account number overflow without creating billions of accounts. func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg CreateDerivedAccountWithNumberParams) (CreateDerivedAccountWithNumberRow, error) { row := q.queryRow(ctx, q.createDerivedAccountWithNumberStmt, CreateDerivedAccountWithNumber, - arg.ScopeID, arg.AccountNumber, arg.AccountName, arg.OriginID, arg.IsWatchOnly, + arg.ScopeID, ) var i CreateDerivedAccountWithNumberRow err := row.Scan(&i.ID, &i.AccountNumber, &i.CreatedAt) @@ -127,6 +143,7 @@ func (q *Queries) CreateDerivedAccountWithNumber(ctx context.Context, arg Create const CreateImportedAccount = `-- name: CreateImportedAccount :one INSERT INTO accounts ( + wallet_id, scope_id, account_number, account_name, @@ -135,17 +152,27 @@ INSERT INTO accounts ( master_fingerprint, is_watch_only ) -VALUES (?, NULL, ?, ?, ?, ?, ?) +SELECT + ks.wallet_id, + ks.id AS scope_id, + NULL AS account_number, + ?1 AS account_name, + ?2 AS origin_id, + ?3 AS encrypted_public_key, + ?4 AS master_fingerprint, + ?5 AS is_watch_only +FROM key_scopes AS ks +WHERE ks.id = ?6 RETURNING id, created_at ` type CreateImportedAccountParams struct { - ScopeID int64 AccountName string OriginID int64 EncryptedPublicKey []byte MasterFingerprint sql.NullInt64 IsWatchOnly bool + ScopeID int64 } type CreateImportedAccountRow struct { @@ -158,12 +185,12 @@ type CreateImportedAccountRow struct { // a sequential account number. func (q *Queries) CreateImportedAccount(ctx context.Context, arg CreateImportedAccountParams) (CreateImportedAccountRow, error) { row := q.queryRow(ctx, q.createImportedAccountStmt, CreateImportedAccount, - arg.ScopeID, arg.AccountName, arg.OriginID, arg.EncryptedPublicKey, arg.MasterFingerprint, arg.IsWatchOnly, + arg.ScopeID, ) var i CreateImportedAccountRow err := row.Scan(&i.ID, &i.CreatedAt) @@ -812,12 +839,12 @@ UPDATE accounts SET account_name = ?1 WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = ?2 - AND purpose = ?3 - AND coin_type = ?4 + key_scopes.wallet_id = ?2 + AND key_scopes.purpose = ?3 + AND key_scopes.coin_type = ?4 ) AND account_name = ?5 ` @@ -850,12 +877,12 @@ UPDATE accounts SET account_name = ?1 WHERE scope_id IN ( - SELECT id + SELECT key_scopes.id FROM key_scopes WHERE - wallet_id = ?2 - AND purpose = ?3 - AND coin_type = ?4 + key_scopes.wallet_id = ?2 + AND key_scopes.purpose = ?3 + AND key_scopes.coin_type = ?4 ) AND account_number = ?5 ` diff --git a/wallet/internal/sql/sqlite/sqlc/models.go b/wallet/internal/sql/sqlite/sqlc/models.go index 053824aa10..c9ea1c0e84 100644 --- a/wallet/internal/sql/sqlite/sqlc/models.go +++ b/wallet/internal/sql/sqlite/sqlc/models.go @@ -11,6 +11,7 @@ import ( type Account struct { ID int64 + WalletID int64 ScopeID int64 AccountNumber sql.NullInt64 AccountName string From 78a069f4013ceae86b64a3b8d2dd157b8a412977 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 16 Apr 2026 19:39:25 -0300 Subject: [PATCH 558/691] wallet: scope addresses to wallet ownership --- wallet/internal/db/addresses_common.go | 20 +++--- .../internal/db/itest/address_store_test.go | 62 ++++++++++++++++++- wallet/internal/db/pg/addresses.go | 10 +-- wallet/internal/db/sqlite/addresses.go | 10 +-- .../sql/pg/migrations/000006_addresses.up.sql | 13 +++- wallet/internal/sql/pg/queries/addresses.sql | 9 +-- wallet/internal/sql/pg/sqlc/addresses.sql.go | 13 ++-- wallet/internal/sql/pg/sqlc/models.go | 1 + .../sqlite/migrations/000006_addresses.up.sql | 13 +++- .../internal/sql/sqlite/queries/addresses.sql | 9 +-- .../internal/sql/sqlite/sqlc/addresses.sql.go | 13 ++-- wallet/internal/sql/sqlite/sqlc/models.go | 1 + 12 files changed, 133 insertions(+), 41 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index cec7c61ec3..e70ac20043 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -390,8 +390,8 @@ type DerivedAddressAdapters[QTX any, AccountRow any, AccountParams any, GetIntIndex func(QTX) func(context.Context, int64) (int64, error) // CreateAddr returns a function to create an address row. - CreateAddr func(QTX) func(context.Context, int64, AddressType, uint32, - uint32, []byte) (AddrRow, error) + CreateAddr func(QTX) func(context.Context, int64, int64, AddressType, + uint32, uint32, []byte) (AddrRow, error) // RowID extracts the ID from an address row. RowID func(AddrRow) int64 @@ -418,9 +418,9 @@ type ImportedAddressAdapters[QTX any, AccountRow any, // CreateAddr returns a function to create an address row. CreateAddr func(QTX) func(context.Context, CreateArgs) (AddrRow, error) - // CreateParams converts accountID and params to address creation + // CreateParams converts wallet/account IDs and params to address creation // parameters. - CreateParams func(int64, NewImportedAddressParams) CreateArgs + CreateParams func(int64, int64, NewImportedAddressParams) CreateArgs // InsertSecret returns a function to insert address secret. InsertSecret func(QTX) func(context.Context, SecretParams) error @@ -453,10 +453,10 @@ func GetAddressByQuery(ctx context.Context, query GetAddressQuery, // derived address creation logic. It calls derivedAddressInput to prepare // inputs and then createFn to create the address. func createDerivedAddress[T any](ctx context.Context, - params NewDerivedAddressParams, accountID int64, + params NewDerivedAddressParams, walletID int64, accountID int64, getExtIndex func(context.Context, int64) (int64, error), getIntIndex func(context.Context, int64) (int64, error), - createFn func(context.Context, int64, AddressType, uint32, uint32, + createFn func(context.Context, int64, int64, AddressType, uint32, uint32, []byte) (T, error), rowID func(T) int64, rowCreatedAt func(T) time.Time, deriveFn AddressDerivationFunc) (*AddressInfo, error) { @@ -469,7 +469,9 @@ func createDerivedAddress[T any](ctx context.Context, return nil, err } - row, err := createFn(ctx, accountID, addrType, branch, index, scriptPubKey) + row, err := createFn( + ctx, walletID, accountID, addrType, branch, index, scriptPubKey, + ) if err != nil { return nil, fmt.Errorf("create address: %w", err) } @@ -570,7 +572,7 @@ func NewDerivedAddressWithTx[QTX any, AccountRow any, row, err := adapters.GetAccount(ctx, adapters.AccountParams(params)) if err == nil { info, errAddr := createDerivedAddress( - ctx, params, adapters.GetAccountID(row), + ctx, params, int64(params.WalletID), adapters.GetAccountID(row), adapters.GetExtIndex(qtx), adapters.GetIntIndex(qtx), adapters.CreateAddr(qtx), @@ -625,7 +627,7 @@ func NewImportedAddressWithTx[QTX any, AccountRow any, AccountParams any, info, errAddr := newImportedAddressTx( ctx, adapters.CreateAddr(qtx), - adapters.CreateParams(acctID, params), + adapters.CreateParams(int64(params.WalletID), acctID, params), adapters.InsertSecret, qtx, adapters.SecretParams, params, acctID, adapters.RowID, diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index d05a32ad15..78c9d85686 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -935,6 +935,34 @@ func TestNewImportedAddress_NonExistentImportedAccount(t *testing.T) { require.ErrorIs(t, err, db.ErrAccountNotFound) } +// TestNewImportedAddressWalletAccountMismatch verifies that imported address +// creation rejects a wallet/scope lookup that only exists in another wallet. +func TestNewImportedAddressWalletAccountMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + firstWalletID := newWallet(t, store, "wallet-import-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-import-mismatch-b") + + CreateImportedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "imported", + ) + CreateImportedAccount( + t, store, secondWalletID, db.KeyScopeBIP0044, "imported", + ) + + _, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: secondWalletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: RandomBytes(32), + }, + ) + require.ErrorIs(t, err, db.ErrAccountNotFound) +} + // TestGetAddressSecret_DerivedAddress verifies that calling GetAddressSecret // on a derived address returns db.ErrSecretNotFound (not ErrAddressNotFound). // This validates the LEFT JOIN: derived addresses exist in the addresses @@ -1514,6 +1542,36 @@ func TestNewDerivedAddressErrors(t *testing.T) { } } +// TestNewDerivedAddress_WalletAccountMismatch verifies that derived address +// creation rejects a wallet/scope/account lookup that resolves in another +// wallet but not in the caller's wallet. +func TestNewDerivedAddress_WalletAccountMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + firstWalletID := newWallet(t, store, "wallet-derived-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-derived-mismatch-b") + accountName := "shared-name" + + createDerivedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, accountName, + ) + createDerivedAccount( + t, store, secondWalletID, db.KeyScopeBIP0044, accountName, + ) + + info, err := store.NewDerivedAddress( + t.Context(), db.NewDerivedAddressParams{ + WalletID: secondWalletID, + Scope: db.KeyScopeBIP0084, + AccountName: accountName, + Change: false, + }, mockDeriveFunc(), + ) + require.ErrorIs(t, err, db.ErrAccountNotFound) + require.Nil(t, info) +} + // TestNewDerivedAddressConcurrent verifies that concurrent address // creation produces unique sequential indexes without conflicts. func TestNewDerivedAddressConcurrent(t *testing.T) { @@ -1706,7 +1764,7 @@ func TestNewDerivedAddressMaxIndex(t *testing.T) { accountID := GetAccountID(t, queries, scopeID, "max-acct") // Insert address at MaxUint32 - 1 - CreateAddressWithIndex(t, queries, accountID, 0, math.MaxUint32-1) + CreateAddressWithIndex(t, queries, walletID, accountID, 0, math.MaxUint32-1) // Set the counter to MaxUint32 so the next allocation gives us MaxUint32 UpdateAccountNextExternalIndex(t, dbConn, accountID, math.MaxUint32) @@ -1746,7 +1804,7 @@ func TestNewDerivedAddressMaxIndexInternal(t *testing.T) { accountID := GetAccountID(t, queries, scopeID, "max-acct") // Insert address at MaxUint32 - 1 in the internal branch. - CreateAddressWithIndex(t, queries, accountID, 1, math.MaxUint32-1) + CreateAddressWithIndex(t, queries, walletID, accountID, 1, math.MaxUint32-1) // Set the internal counter to MaxUint32 so the next allocation gives us // MaxUint32. diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index d859b095dd..a2ff2f14f6 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -168,11 +168,11 @@ func derivedAddressGetIntIndex(qtx *sqlc.Queries) func(context.Context, // derivedAddressCreateAddr returns the derived address insert helper. func derivedAddressCreateAddr(qtx *sqlc.Queries) func( - context.Context, int64, db.AddressType, uint32, uint32, []byte, + context.Context, int64, int64, db.AddressType, uint32, uint32, []byte, ) (sqlc.CreateDerivedAddressRow, error) { - return func(ctx context.Context, accountID int64, addrType db.AddressType, - branch uint32, index uint32, + return func(ctx context.Context, walletID int64, accountID int64, + addrType db.AddressType, branch uint32, index uint32, scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { branchNum, err := db.Uint32ToInt16(branch) @@ -184,6 +184,7 @@ func derivedAddressCreateAddr(qtx *sqlc.Queries) func( return qtx.CreateDerivedAddress( ctx, sqlc.CreateDerivedAddressParams{ + WalletID: walletID, AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int16(addrType), @@ -236,10 +237,11 @@ func insertAddressSecret(qtx *sqlc.Queries) func(context.Context, } // createImportedAddressParams maps imported params to sqlc params. -func createImportedAddressParams(accountID int64, +func createImportedAddressParams(walletID int64, accountID int64, params db.NewImportedAddressParams) sqlc.CreateImportedAddressParams { return sqlc.CreateImportedAddressParams{ + WalletID: walletID, AccountID: accountID, ScriptPubKey: params.ScriptPubKey, TypeID: int16(params.AddressType), diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 33cfb386ae..fcba1e2708 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -170,16 +170,17 @@ func derivedAddressGetIntIndex( // derivedAddressCreateAddr returns the derived address insert helper. func derivedAddressCreateAddr( qtx *sqlc.Queries, -) func(context.Context, int64, db.AddressType, uint32, uint32, []byte) ( +) func(context.Context, int64, int64, db.AddressType, uint32, uint32, []byte) ( sqlc.CreateDerivedAddressRow, error, ) { - return func(ctx context.Context, accountID int64, addrType db.AddressType, - branch uint32, index uint32, + return func(ctx context.Context, walletID int64, accountID int64, + addrType db.AddressType, branch uint32, index uint32, scriptPubKey []byte) (sqlc.CreateDerivedAddressRow, error) { return qtx.CreateDerivedAddress( ctx, sqlc.CreateDerivedAddressParams{ + WalletID: walletID, AccountID: accountID, ScriptPubKey: scriptPubKey, TypeID: int64(addrType), @@ -234,10 +235,11 @@ func insertAddressSecret(qtx *sqlc.Queries) func(context.Context, } // createImportedAddressParams maps imported params to sqlc params. -func createImportedAddressParams(accountID int64, +func createImportedAddressParams(walletID int64, accountID int64, params db.NewImportedAddressParams) sqlc.CreateImportedAddressParams { return sqlc.CreateImportedAddressParams{ + WalletID: walletID, AccountID: accountID, ScriptPubKey: params.ScriptPubKey, TypeID: int64(params.AddressType), diff --git a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql index 2e9c79ddcb..97799d766e 100644 --- a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql @@ -12,6 +12,9 @@ CREATE TABLE addresses ( -- DB ID of the address, primary key. id BIGSERIAL PRIMARY KEY, + -- Reference to the wallet this address belongs to. + wallet_id BIGINT NOT NULL, + -- Reference to the account this address belongs to. account_id BIGINT NOT NULL, @@ -51,9 +54,13 @@ CREATE TABLE addresses ( -- Address index must be non-negative when set. CHECK (address_index IS NULL OR address_index >= 0), - -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure - -- that the account cannot be deleted if addresses still exist. - FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, + -- Composite foreign key to accounts. This ensures account_id belongs to + -- the same wallet_id as the address row. Wallet ownership is transitively + -- enforced through accounts, which has its own FK to wallets. Using ON + -- DELETE RESTRICT to ensure that the wallet/account cannot be deleted if + -- addresses still exist. + FOREIGN KEY (wallet_id, account_id) + REFERENCES accounts (wallet_id, id) ON DELETE RESTRICT, -- Foreign key constraint to address types. Using ON DELETE RESTRICT to -- ensure that the address type cannot be deleted if addresses still exist. diff --git a/wallet/internal/sql/pg/queries/addresses.sql b/wallet/internal/sql/pg/queries/addresses.sql index a76546339a..1189d8f452 100644 --- a/wallet/internal/sql/pg/queries/addresses.sql +++ b/wallet/internal/sql/pg/queries/addresses.sql @@ -25,9 +25,8 @@ SELECT (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id -INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.script_pub_key = $1 AND ks.wallet_id = $2; +WHERE a.script_pub_key = $1 AND a.wallet_id = $2; -- name: GetAddressSecret :one -- Retrieves secret information for an address. Uses LEFT JOIN to distinguish: @@ -47,18 +46,20 @@ WHERE a.id = $1; -- The index is allocated separately via GetAndIncrementNextExternalIndex -- or GetAndIncrementNextInternalIndex. INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, address_branch, address_index, pub_key -) VALUES ($1, $2, $3, $4, $5, $6) +) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at; -- name: CreateImportedAddress :one -- Creates an imported address (no derivation path, has script/pubkey). INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, @@ -66,7 +67,7 @@ INSERT INTO addresses ( address_index, pub_key ) VALUES ( - $1, $2, $3, NULL, NULL, $4 + $1, $2, $3, $4, NULL, NULL, $5 ) RETURNING id, created_at; diff --git a/wallet/internal/sql/pg/sqlc/addresses.sql.go b/wallet/internal/sql/pg/sqlc/addresses.sql.go index d6ce305551..2bd233363a 100644 --- a/wallet/internal/sql/pg/sqlc/addresses.sql.go +++ b/wallet/internal/sql/pg/sqlc/addresses.sql.go @@ -13,17 +13,19 @@ import ( const CreateDerivedAddress = `-- name: CreateDerivedAddress :one INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, address_branch, address_index, pub_key -) VALUES ($1, $2, $3, $4, $5, $6) +) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at ` type CreateDerivedAddressParams struct { + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int16 @@ -42,6 +44,7 @@ type CreateDerivedAddressRow struct { // or GetAndIncrementNextInternalIndex. func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, + arg.WalletID, arg.AccountID, arg.ScriptPubKey, arg.TypeID, @@ -56,6 +59,7 @@ func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAdd const CreateImportedAddress = `-- name: CreateImportedAddress :one INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, @@ -63,12 +67,13 @@ INSERT INTO addresses ( address_index, pub_key ) VALUES ( - $1, $2, $3, NULL, NULL, $4 + $1, $2, $3, $4, NULL, NULL, $5 ) RETURNING id, created_at ` type CreateImportedAddressParams struct { + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int16 @@ -83,6 +88,7 @@ type CreateImportedAddressRow struct { // Creates an imported address (no derivation path, has script/pubkey). func (q *Queries) CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) { row := q.queryRow(ctx, q.createImportedAddressStmt, CreateImportedAddress, + arg.WalletID, arg.AccountID, arg.ScriptPubKey, arg.TypeID, @@ -108,9 +114,8 @@ SELECT (s.encrypted_script IS NOT NULL)::BOOLEAN AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id -INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.script_pub_key = $1 AND ks.wallet_id = $2 +WHERE a.script_pub_key = $1 AND a.wallet_id = $2 ` type GetAddressByScriptPubKeyParams struct { diff --git a/wallet/internal/sql/pg/sqlc/models.go b/wallet/internal/sql/pg/sqlc/models.go index 765db782f8..147641ec2b 100644 --- a/wallet/internal/sql/pg/sqlc/models.go +++ b/wallet/internal/sql/pg/sqlc/models.go @@ -37,6 +37,7 @@ type AccountSecret struct { type Address struct { ID int64 + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int16 diff --git a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql index 72a58dc139..b4f5a1541f 100644 --- a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql @@ -12,6 +12,9 @@ CREATE TABLE addresses ( -- DB ID of the address, primary key. id INTEGER PRIMARY KEY, + -- Reference to the wallet this address belongs to. + wallet_id INTEGER NOT NULL, + -- Reference to the account this address belongs to. account_id INTEGER NOT NULL, @@ -48,9 +51,13 @@ CREATE TABLE addresses ( -- Address index must be non-negative when set. CHECK (address_index IS NULL OR address_index >= 0), - -- Foreign key constraint to accounts. Using ON DELETE RESTRICT to ensure - -- that the account cannot be deleted if addresses still exist. - FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE RESTRICT, + -- Composite foreign key to accounts. This ensures account_id belongs to + -- the same wallet_id as the address row. Wallet ownership is transitively + -- enforced through accounts, which has its own FK to wallets. Using ON + -- DELETE RESTRICT to ensure that the wallet/account cannot be deleted if + -- addresses still exist. + FOREIGN KEY (wallet_id, account_id) + REFERENCES accounts (wallet_id, id) ON DELETE RESTRICT, -- Foreign key constraint to address types. Using ON DELETE RESTRICT to -- ensure that the address type cannot be deleted if addresses still exist. diff --git a/wallet/internal/sql/sqlite/queries/addresses.sql b/wallet/internal/sql/sqlite/queries/addresses.sql index dc9db4a85d..9b08739823 100644 --- a/wallet/internal/sql/sqlite/queries/addresses.sql +++ b/wallet/internal/sql/sqlite/queries/addresses.sql @@ -25,9 +25,8 @@ SELECT s.encrypted_script IS NOT NULL AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id -INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.script_pub_key = ? AND ks.wallet_id = ?; +WHERE a.script_pub_key = ? AND a.wallet_id = ?; -- name: GetAddressSecret :one -- Retrieves secret information for an address. Uses LEFT JOIN to distinguish: @@ -47,18 +46,20 @@ WHERE a.id = ?; -- The index is allocated separately via GetAndIncrementNextExternalIndex -- or GetAndIncrementNextInternalIndex. INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, address_branch, address_index, pub_key -) VALUES (?1, ?2, ?3, ?4, ?5, ?6) +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id, created_at; -- name: CreateImportedAddress :one -- Creates an imported address (no derivation path, has script/pubkey). INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, @@ -66,7 +67,7 @@ INSERT INTO addresses ( address_index, pub_key ) VALUES ( - ?1, ?2, ?3, NULL, NULL, ?4 + ?1, ?2, ?3, ?4, NULL, NULL, ?5 ) RETURNING id, created_at; diff --git a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go index cd60257e8e..aed450d5a6 100644 --- a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go @@ -13,17 +13,19 @@ import ( const CreateDerivedAddress = `-- name: CreateDerivedAddress :one INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, address_branch, address_index, pub_key -) VALUES (?1, ?2, ?3, ?4, ?5, ?6) +) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id, created_at ` type CreateDerivedAddressParams struct { + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int64 @@ -42,6 +44,7 @@ type CreateDerivedAddressRow struct { // or GetAndIncrementNextInternalIndex. func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAddressParams) (CreateDerivedAddressRow, error) { row := q.queryRow(ctx, q.createDerivedAddressStmt, CreateDerivedAddress, + arg.WalletID, arg.AccountID, arg.ScriptPubKey, arg.TypeID, @@ -56,6 +59,7 @@ func (q *Queries) CreateDerivedAddress(ctx context.Context, arg CreateDerivedAdd const CreateImportedAddress = `-- name: CreateImportedAddress :one INSERT INTO addresses ( + wallet_id, account_id, script_pub_key, type_id, @@ -63,12 +67,13 @@ INSERT INTO addresses ( address_index, pub_key ) VALUES ( - ?1, ?2, ?3, NULL, NULL, ?4 + ?1, ?2, ?3, ?4, NULL, NULL, ?5 ) RETURNING id, created_at ` type CreateImportedAddressParams struct { + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int64 @@ -83,6 +88,7 @@ type CreateImportedAddressRow struct { // Creates an imported address (no derivation path, has script/pubkey). func (q *Queries) CreateImportedAddress(ctx context.Context, arg CreateImportedAddressParams) (CreateImportedAddressRow, error) { row := q.queryRow(ctx, q.createImportedAddressStmt, CreateImportedAddress, + arg.WalletID, arg.AccountID, arg.ScriptPubKey, arg.TypeID, @@ -108,9 +114,8 @@ SELECT s.encrypted_script IS NOT NULL AS has_script FROM addresses AS a INNER JOIN accounts AS acc ON a.account_id = acc.id -INNER JOIN key_scopes AS ks ON acc.scope_id = ks.id LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.script_pub_key = ? AND ks.wallet_id = ? +WHERE a.script_pub_key = ? AND a.wallet_id = ? ` type GetAddressByScriptPubKeyParams struct { diff --git a/wallet/internal/sql/sqlite/sqlc/models.go b/wallet/internal/sql/sqlite/sqlc/models.go index c9ea1c0e84..496c2b06b3 100644 --- a/wallet/internal/sql/sqlite/sqlc/models.go +++ b/wallet/internal/sql/sqlite/sqlc/models.go @@ -37,6 +37,7 @@ type AccountSecret struct { type Address struct { ID int64 + WalletID int64 AccountID int64 ScriptPubKey []byte TypeID int64 From 196fed414f5253cd3d20bf1307841004bc451906 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 16 Apr 2026 19:39:31 -0300 Subject: [PATCH 559/691] wallet: make address scripts unique per wallet --- .../internal/db/itest/address_store_test.go | 139 ++++++++++++++++++ wallet/internal/db/itest/fixtures_pg_test.go | 49 ++++++ .../internal/db/itest/fixtures_sqlite_test.go | 44 ++++++ .../sql/pg/migrations/000006_addresses.up.sql | 10 +- .../sqlite/migrations/000006_addresses.up.sql | 10 +- 5 files changed, 238 insertions(+), 14 deletions(-) diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 78c9d85686..bd10c3470d 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -541,6 +541,145 @@ func TestNewImportedAddressDuplicate(t *testing.T) { require.ErrorContains(t, err, "script_pub_key") } +// TestNewImportedAddressDuplicateAcrossScopes verifies that imported-address +// creation rejects the same script pubkey across different imported scopes in +// one wallet. +func TestNewImportedAddressDuplicateAcrossScopes(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-duplicate-import-cross-scope") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0044, "imported") + CreateImportedAccount(t, store, walletID, db.KeyScopeBIP0084, "imported") + + scriptPubKey := RandomBytes(32) + + _, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AddressType: db.PubKeyHash, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + }, + ) + require.NoError(t, err) + + _, err = store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + }, + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") + require.ErrorContains(t, err, "script_pub_key") +} + +// TestNewImportedAddressDuplicateAcrossWallets verifies that wallet-scoped +// uniqueness allows the same script pubkey to be imported into different +// wallets. +func TestNewImportedAddressDuplicateAcrossWallets(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + firstWalletID := newWallet(t, store, "wallet-duplicate-import-a") + secondWalletID := newWallet(t, store, "wallet-duplicate-import-b") + + CreateImportedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "imported", + ) + CreateImportedAccount( + t, store, secondWalletID, db.KeyScopeBIP0084, "imported", + ) + + scriptPubKey := RandomBytes(32) + + _, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: firstWalletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + }, + ) + require.NoError(t, err) + + info, err := store.NewImportedAddress( + t.Context(), db.NewImportedAddressParams{ + WalletID: secondWalletID, + Scope: db.KeyScopeBIP0084, + AddressType: db.WitnessPubKey, + PubKey: RandomBytes(33), + ScriptPubKey: scriptPubKey, + }, + ) + require.NoError(t, err) + require.NotZero(t, info.ID) + require.NotZero(t, info.AccountID) +} + +// TestCreateImportedAddressRejectsWalletAccountMismatch verifies that the +// composite wallet/account invariant is enforced by the database on direct +// imported-address inserts. +func TestCreateImportedAddressRejectsWalletAccountMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + firstWalletID := newWallet(t, store, "wallet-raw-import-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-raw-import-mismatch-b") + + CreateImportedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, "imported", + ) + CreateImportedAccount( + t, store, secondWalletID, db.KeyScopeBIP0084, "imported", + ) + + firstScopeID := GetKeyScopeID(t, queries, firstWalletID, db.KeyScopeBIP0084) + firstAccountID := GetAccountID(t, queries, firstScopeID, "imported") + + err := createImportedAddressRaw( + t.Context(), queries, secondWalletID, firstAccountID, RandomBytes(32), + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + +// TestCreateDerivedAddressRejectsWalletAccountMismatch verifies that the +// composite wallet/account invariant is enforced by the database on direct +// derived-address inserts. +func TestCreateDerivedAddressRejectsWalletAccountMismatch(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + queries := store.Queries() + firstWalletID := newWallet(t, store, "wallet-raw-derived-mismatch-a") + secondWalletID := newWallet(t, store, "wallet-raw-derived-mismatch-b") + accountName := "raw-derived" + + createDerivedAccount( + t, store, firstWalletID, db.KeyScopeBIP0084, accountName, + ) + createDerivedAccount( + t, store, secondWalletID, db.KeyScopeBIP0084, accountName, + ) + + firstScopeID := GetKeyScopeID(t, queries, firstWalletID, db.KeyScopeBIP0084) + firstAccountID := GetAccountID(t, queries, firstScopeID, accountName) + + err := createDerivedAddressRaw( + t, queries, secondWalletID, firstAccountID, 0, 0, RandomBytes(20), + ) + require.Error(t, err) + require.ErrorContains(t, err, "constraint") +} + // TestGetAddressSecret verifies that GetAddressSecret correctly retrieves // address secrets for watch-only imported addresses and returns an error for // spendable addresses or non-existent address IDs. diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 8633a4f827..d276076cce 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -250,3 +250,52 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, "account-near-max") } + +// createImportedAddressRaw inserts an imported address directly through the +// database so tests can validate wallet/account ownership invariants. +func createImportedAddressRaw(ctx context.Context, queries *sqlc.Queries, + walletID uint32, accountID int64, scriptPubKey []byte) error { + + _, err := queries.CreateImportedAddress( + ctx, sqlc.CreateImportedAddressParams{ + WalletID: int64(walletID), + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int16(db.WitnessPubKey), + PubKey: RandomBytes(33), + }, + ) + + return err +} + +// createDerivedAddressRaw inserts a derived address directly through +// PostgreSQL sqlc queries for testing database-level invariants. +func createDerivedAddressRaw(t *testing.T, queries *sqlc.Queries, + walletID uint32, accountID int64, branch uint32, index uint32, + scriptPubKey []byte) error { + t.Helper() + + branchNum, err := db.Uint32ToInt16(branch) + require.NoError(t, err) + + _, err = queries.CreateDerivedAddress( + t.Context(), sqlc.CreateDerivedAddressParams{ + WalletID: int64(walletID), + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int16(db.WitnessPubKey), + AddressBranch: sql.NullInt16{ + Int16: branchNum, + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: nil, + }, + ) + + return err +} diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 9ba8b38302..c7e2b995e7 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -250,3 +250,47 @@ func setupMaxAccountNumberTest(t *testing.T, store db.AccountStore, CreateAccountWithNumber(t, queries, scopeID, math.MaxUint32-1, "account-near-max") } + +// createImportedAddressRaw inserts an imported address directly through the +// database so tests can validate wallet/account ownership invariants. +func createImportedAddressRaw(ctx context.Context, queries *sqlc.Queries, + walletID uint32, accountID int64, scriptPubKey []byte) error { + + _, err := queries.CreateImportedAddress( + ctx, sqlc.CreateImportedAddressParams{ + WalletID: int64(walletID), + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int64(db.WitnessPubKey), + PubKey: RandomBytes(33), + }, + ) + + return err +} + +// createDerivedAddressRaw inserts a derived address directly through the +// database so tests can validate wallet/account ownership invariants. +func createDerivedAddressRaw(t *testing.T, queries *sqlc.Queries, + walletID uint32, accountID int64, branch uint32, index uint32, + scriptPubKey []byte) error { + _, err := queries.CreateDerivedAddress( + t.Context(), sqlc.CreateDerivedAddressParams{ + WalletID: int64(walletID), + AccountID: accountID, + ScriptPubKey: scriptPubKey, + TypeID: int64(db.WitnessPubKey), + AddressBranch: sql.NullInt64{ + Int64: int64(branch), + Valid: true, + }, + AddressIndex: sql.NullInt64{ + Int64: int64(index), + Valid: true, + }, + PubKey: nil, + }, + ) + + return err +} diff --git a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql index 97799d766e..31125583f0 100644 --- a/wallet/internal/sql/pg/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/pg/migrations/000006_addresses.up.sql @@ -75,13 +75,9 @@ ON addresses (account_id, address_branch, address_index) WHERE address_branch IS NOT NULL AND address_index IS NOT NULL; --- Unique index to prevent duplicate script_pub_key within the same account. -CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key -ON addresses (account_id, script_pub_key); - --- Index on script_pub_key for efficient lookups by script pubkey. --- Used by GetAddressByScriptPubKey. -CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); +-- Unique index to prevent duplicate script_pub_key within the same wallet. +CREATE UNIQUE INDEX uidx_addresses_wallet_script_pub_key +ON addresses (wallet_id, script_pub_key); -- Index on (account_id, id) for efficient pagination of addresses by account. -- Used by ListAddressesByAccount for cursor-based pagination. diff --git a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql index b4f5a1541f..2fe9009af1 100644 --- a/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql +++ b/wallet/internal/sql/sqlite/migrations/000006_addresses.up.sql @@ -73,13 +73,9 @@ WHERE address_branch IS NOT NULL AND address_index IS NOT NULL; --- Unique index to prevent duplicate script_pub_key within the same account. -CREATE UNIQUE INDEX uidx_addresses_account_script_pub_key -ON addresses (account_id, script_pub_key); - --- Index on script_pub_key for efficient lookups by script pubkey. --- Used by GetAddressByScriptPubKey. -CREATE INDEX idx_addresses_script_pub_key ON addresses (script_pub_key); +-- Unique index to prevent duplicate script_pub_key within the same wallet. +CREATE UNIQUE INDEX uidx_addresses_wallet_script_pub_key +ON addresses (wallet_id, script_pub_key); -- Index on (account_id, id) for efficient pagination of addresses by account. -- Used by ListAddressesByAccount for cursor-based pagination. From 143115471dea4859c76498df982a1d6cf3a7627b Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Mon, 20 Apr 2026 23:18:44 -0300 Subject: [PATCH 560/691] wallet: scope GetAddressSecret to wallet_id --- wallet/internal/db/addresses_common.go | 9 +- wallet/internal/db/data_types.go | 14 +++ wallet/internal/db/interface.go | 4 +- .../internal/db/itest/address_store_test.go | 89 ++++++++++++++++--- wallet/internal/db/itest/fixtures_pg_test.go | 7 -- .../internal/db/itest/fixtures_sqlite_test.go | 7 -- wallet/internal/db/pg/addresses.go | 15 +++- wallet/internal/db/sqlite/addresses.go | 16 +++- wallet/internal/sql/pg/queries/addresses.sql | 2 +- wallet/internal/sql/pg/sqlc/addresses.sql.go | 11 ++- wallet/internal/sql/pg/sqlc/querier.go | 2 +- .../internal/sql/sqlite/queries/addresses.sql | 2 +- .../internal/sql/sqlite/sqlc/addresses.sql.go | 11 ++- wallet/internal/sql/sqlite/sqlc/querier.go | 2 +- 14 files changed, 145 insertions(+), 46 deletions(-) diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index e70ac20043..e5ba932de1 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -62,17 +62,18 @@ func AccountKeyFromImportedParams( // information using the provided getter function and converts it to an // AddressSecret with error handling. func GetAddressSecret[Row any](ctx context.Context, - getter func(context.Context, int64) (Row, error), addressID uint32, + getter func(context.Context, int64, int64) (Row, error), + query GetAddressSecretQuery, toSecret func(Row) (*AddressSecret, error)) (*AddressSecret, error) { - row, err := getter(ctx, int64(addressID)) + row, err := getter(ctx, int64(query.WalletID), int64(query.AddressID)) if err == nil { return toSecret(row) } if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("address secret for address %d: %w", - addressID, ErrAddressNotFound) + return nil, fmt.Errorf("address secret for wallet %d address %d: %w", + query.WalletID, query.AddressID, ErrAddressNotFound) } return nil, fmt.Errorf("get address secret: %w", err) diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index 1a7ad61a54..5af70e0fd3 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -704,6 +704,20 @@ type GetAddressQuery struct { ScriptPubKey []byte } +// GetAddressSecretQuery contains the parameters for querying an address +// secret. The query is wallet-scoped: it retrieves the encrypted secret +// material for a specific address within a wallet. +type GetAddressSecretQuery struct { + // WalletID is the ID of the wallet to query. + // + // NOTE: uint32 is used to ensure compatibility with standard SQL + // databases (signed 64-bit integers). + WalletID uint32 + + // AddressID is the ID of the address whose secret should be fetched. + AddressID uint32 +} + // ListAddressesQuery contains the parameters for listing addresses. type ListAddressesQuery struct { // WalletID is the ID of the wallet to query. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 49f531beec..35e5a39e25 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -260,8 +260,8 @@ type AddressStore interface { // GetAddressSecret retrieves the encrypted secret material for a given // address. Returns the AddressSecret containing encrypted private key // and scripts, or an error if the secret does not exist. - GetAddressSecret(ctx context.Context, addressID uint32) (*AddressSecret, - error) + GetAddressSecret(ctx context.Context, + query GetAddressSecretQuery) (*AddressSecret, error) // ListAddressTypes returns all supported address types along with their // readable descriptions, wrapped in AddressTypeInfo values. diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index bd10c3470d..3221edebe9 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -251,16 +251,21 @@ func TestNewImportedAddress(t *testing.T) { t, queries, params.ScriptPubKey, walletID, ) - secret, err := GetAddressSecret(t, queries, addressID) - require.NoError(t, err) + secret, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: uint32(addressID), + }, + ) + if tc.providePrivateKey { + require.NoError(t, err) require.Equal( t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, ) require.Empty(t, secret.EncryptedScript) } else { - require.Empty(t, secret.EncryptedPrivKey) - require.Empty(t, secret.EncryptedScript) + require.ErrorIs(t, err, db.ErrSecretNotFound) } }) } @@ -364,16 +369,31 @@ func TestNewImportedAddressWithEncryptedScript(t *testing.T) { t, queries, params.ScriptPubKey, walletID, ) - secret, err := GetAddressSecret(t, queries, addressID) - require.NoError(t, err) - + secret, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: uint32(addressID), + }, + ) require.Equal(t, tc.encryptedScript, secret.EncryptedScript) if tc.hasPrivateKey { + require.NoError(t, err) require.Equal( t, params.EncryptedPrivateKey, secret.EncryptedPrivKey, ) + + return + } + + if tc.hasScript { + require.NoError(t, err) + require.Equal(t, params.EncryptedScript, secret.EncryptedScript) + + return } + + require.ErrorAs(t, err, &db.ErrSecretNotFound) }) } } @@ -725,7 +745,12 @@ func TestGetAddressSecret(t *testing.T) { require.NoError(t, errNewAddr) if tc.shouldHaveSecret { - secret, err := store.GetAddressSecret(t.Context(), info.ID) + secret, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: info.ID, + }, + ) require.NoError(t, err) require.NotNil(t, secret) require.Equal(t, info.ID, secret.AddressID) @@ -734,15 +759,52 @@ func TestGetAddressSecret(t *testing.T) { ) require.Empty(t, secret.EncryptedScript) } else { - _, err := store.GetAddressSecret(t.Context(), info.ID) + _, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: info.ID, + }, + ) require.ErrorIs(t, err, db.ErrSecretNotFound) } }) } + t.Run("cross-wallet address denied", func(t *testing.T) { + otherWalletID := newWallet(t, store, "wallet-secrets-other") + CreateImportedAccount( + t, store, otherWalletID, db.KeyScopeBIP0044, "imported", + ) + + params := db.NewImportedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0044, + AddressType: db.PubKeyHash, + PubKey: RandomBytes(33), + ScriptPubKey: RandomBytes(32), + EncryptedPrivateKey: RandomBytes(32), + } + + info, err := store.NewImportedAddress(t.Context(), params) + require.NoError(t, err) + + _, err = store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: otherWalletID, + AddressID: info.ID, + }, + ) + require.ErrorIs(t, err, db.ErrAddressNotFound) + }) + // Test non-existent address ID. t.Run("non-existent address", func(t *testing.T) { - _, err := store.GetAddressSecret(t.Context(), 999999) + _, err := store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: 999999, + }, + ) require.ErrorIs(t, err, db.ErrAddressNotFound) }) } @@ -1125,7 +1187,12 @@ func TestGetAddressSecret_DerivedAddress(t *testing.T) { // Attempt to get secret for derived address. // Derived addresses have no row in address_secrets table. - _, err = store.GetAddressSecret(t.Context(), addrInfo.ID) + _, err = store.GetAddressSecret( + t.Context(), db.GetAddressSecretQuery{ + WalletID: walletID, + AddressID: addrInfo.ID, + }, + ) // Expect ErrSecretNotFound (not ErrAddressNotFound) because the // LEFT JOIN returns a row with NULL encrypted_priv_key. diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index d276076cce..9f0a79ce21 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -199,13 +199,6 @@ func getAddressID(t *testing.T, queries *sqlc.Queries, scriptPubKey []byte, return addr.ID } -func GetAddressSecret(t *testing.T, queries *sqlc.Queries, - addressID int64) (sqlc.GetAddressSecretRow, error) { - t.Helper() - - return queries.GetAddressSecret(t.Context(), addressID) -} - // MustDeleteAddress deletes an address by ID for test scenarios. func MustDeleteAddress(t *testing.T, dbConn *sql.DB, addressID uint32) { t.Helper() diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index c7e2b995e7..6901e74208 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -199,13 +199,6 @@ func getAddressID(t *testing.T, queries *sqlc.Queries, return addr.ID } -func GetAddressSecret(t *testing.T, queries *sqlc.Queries, - addressID int64) (sqlc.GetAddressSecretRow, error) { - t.Helper() - - return queries.GetAddressSecret(t.Context(), addressID) -} - // MustDeleteAddress deletes an address by ID for test scenarios. func MustDeleteAddress(t *testing.T, dbConn *sql.DB, addressID uint32) { t.Helper() diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index a2ff2f14f6..74bc32b469 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -68,10 +68,21 @@ func (s *Store) IterAddresses(ctx context.Context, // GetAddressSecret retrieves the encrypted secret information for an address. func (s *Store) GetAddressSecret(ctx context.Context, - addressID uint32) (*db.AddressSecret, error) { + query db.GetAddressSecretQuery) (*db.AddressSecret, error) { + + getSecret := func(ctx context.Context, walletID int64, + addressID int64) (sqlc.GetAddressSecretRow, error) { + + return s.queries.GetAddressSecret( + ctx, sqlc.GetAddressSecretParams{ + WalletID: walletID, + ID: addressID, + }, + ) + } return db.GetAddressSecret( - ctx, s.queries.GetAddressSecret, addressID, addressSecretRowToSecret, + ctx, getSecret, query, addressSecretRowToSecret, ) } diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index fcba1e2708..32a293088a 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -68,11 +68,21 @@ func (s *Store) IterAddresses(ctx context.Context, // GetAddressSecret retrieves the encrypted secret information for an address. func (s *Store) GetAddressSecret(ctx context.Context, - addressID uint32) (*db.AddressSecret, error) { + query db.GetAddressSecretQuery) (*db.AddressSecret, error) { + + getSecret := func(ctx context.Context, walletID int64, + addressID int64) (sqlc.GetAddressSecretRow, error) { + + return s.queries.GetAddressSecret( + ctx, sqlc.GetAddressSecretParams{ + WalletID: walletID, + ID: addressID, + }, + ) + } return db.GetAddressSecret( - ctx, s.queries.GetAddressSecret, addressID, - addressSecretRowToSecret, + ctx, getSecret, query, addressSecretRowToSecret, ) } diff --git a/wallet/internal/sql/pg/queries/addresses.sql b/wallet/internal/sql/pg/queries/addresses.sql index 1189d8f452..63c6822aa4 100644 --- a/wallet/internal/sql/pg/queries/addresses.sql +++ b/wallet/internal/sql/pg/queries/addresses.sql @@ -39,7 +39,7 @@ SELECT s.encrypted_script FROM addresses AS a LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.id = $1; +WHERE a.wallet_id = $1 AND a.id = $2; -- name: CreateDerivedAddress :one -- Creates a derived address with the given index and derived data. diff --git a/wallet/internal/sql/pg/sqlc/addresses.sql.go b/wallet/internal/sql/pg/sqlc/addresses.sql.go index 2bd233363a..fde4d3d29b 100644 --- a/wallet/internal/sql/pg/sqlc/addresses.sql.go +++ b/wallet/internal/sql/pg/sqlc/addresses.sql.go @@ -164,9 +164,14 @@ SELECT s.encrypted_script FROM addresses AS a LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.id = $1 +WHERE a.wallet_id = $1 AND a.id = $2 ` +type GetAddressSecretParams struct { + WalletID int64 + ID int64 +} + type GetAddressSecretRow struct { AddressID int64 EncryptedPrivKey []byte @@ -177,8 +182,8 @@ type GetAddressSecretRow struct { // - Address exists with secret: returns full row // - Address exists without secret (watch-only/derived): returns row with NULL secret fields // - Address does not exist: returns no rows (sql.ErrNoRows) -func (q *Queries) GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) { - row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, id) +func (q *Queries) GetAddressSecret(ctx context.Context, arg GetAddressSecretParams) (GetAddressSecretRow, error) { + row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, arg.WalletID, arg.ID) var i GetAddressSecretRow err := row.Scan(&i.AddressID, &i.EncryptedPrivKey, &i.EncryptedScript) return i, err diff --git a/wallet/internal/sql/pg/sqlc/querier.go b/wallet/internal/sql/pg/sqlc/querier.go index 963865d03c..98bf79857a 100644 --- a/wallet/internal/sql/pg/sqlc/querier.go +++ b/wallet/internal/sql/pg/sqlc/querier.go @@ -156,7 +156,7 @@ type Querier interface { // - Address exists with secret: returns full row // - Address exists without secret (watch-only/derived): returns row with NULL secret fields // - Address does not exist: returns no rows (sql.ErrNoRows) - GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) + GetAddressSecret(ctx context.Context, arg GetAddressSecretParams) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int16) (AddressType, error) // Atomically gets the next external address index and increments the counter. diff --git a/wallet/internal/sql/sqlite/queries/addresses.sql b/wallet/internal/sql/sqlite/queries/addresses.sql index 9b08739823..e2e12c4c31 100644 --- a/wallet/internal/sql/sqlite/queries/addresses.sql +++ b/wallet/internal/sql/sqlite/queries/addresses.sql @@ -39,7 +39,7 @@ SELECT s.encrypted_script FROM addresses AS a LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.id = ?; +WHERE a.wallet_id = ? AND a.id = ?; -- name: CreateDerivedAddress :one -- Creates a derived address with the given index and derived data. diff --git a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go index aed450d5a6..c31c66c70f 100644 --- a/wallet/internal/sql/sqlite/sqlc/addresses.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/addresses.sql.go @@ -164,9 +164,14 @@ SELECT s.encrypted_script FROM addresses AS a LEFT JOIN address_secrets AS s ON a.id = s.address_id -WHERE a.id = ? +WHERE a.wallet_id = ? AND a.id = ? ` +type GetAddressSecretParams struct { + WalletID int64 + ID int64 +} + type GetAddressSecretRow struct { AddressID int64 EncryptedPrivKey []byte @@ -177,8 +182,8 @@ type GetAddressSecretRow struct { // - Address exists with secret: returns full row // - Address exists without secret (watch-only/derived): returns row with NULL secret fields // - Address does not exist: returns no rows (sql.ErrNoRows) -func (q *Queries) GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) { - row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, id) +func (q *Queries) GetAddressSecret(ctx context.Context, arg GetAddressSecretParams) (GetAddressSecretRow, error) { + row := q.queryRow(ctx, q.getAddressSecretStmt, GetAddressSecret, arg.WalletID, arg.ID) var i GetAddressSecretRow err := row.Scan(&i.AddressID, &i.EncryptedPrivKey, &i.EncryptedScript) return i, err diff --git a/wallet/internal/sql/sqlite/sqlc/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go index 8586c40cc1..eef3bf58bd 100644 --- a/wallet/internal/sql/sqlite/sqlc/querier.go +++ b/wallet/internal/sql/sqlite/sqlc/querier.go @@ -153,7 +153,7 @@ type Querier interface { // - Address exists with secret: returns full row // - Address exists without secret (watch-only/derived): returns row with NULL secret fields // - Address does not exist: returns no rows (sql.ErrNoRows) - GetAddressSecret(ctx context.Context, id int64) (GetAddressSecretRow, error) + GetAddressSecret(ctx context.Context, arg GetAddressSecretParams) (GetAddressSecretRow, error) // Returns a single address type by its ID. GetAddressTypeByID(ctx context.Context, id int64) (AddressType, error) // Atomically gets the next external address index and increments the counter. From 70a186c2bd089b4299fe4ff3ff9bc4a55b4bb15c Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:44:40 +0800 Subject: [PATCH 561/691] db/err: preserve domain errors Keep non-SQL domain errors unchanged in shared normalization while still preserving existing SQL wrappers and transport classification. --- wallet/internal/db/err/errors.go | 14 ++------------ wallet/internal/db/err/errors_test.go | 20 ++++++++++++++------ 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/wallet/internal/db/err/errors.go b/wallet/internal/db/err/errors.go index ee6654f342..04e3b47add 100644 --- a/wallet/internal/db/err/errors.go +++ b/wallet/internal/db/err/errors.go @@ -335,9 +335,7 @@ func (e *SQLError) Unwrap() error { // errors into SQLError values. // 5. Generic connection and transport failures are classified as // ReasonUnavailable. -// 6. If the backend is known and no earlier rule matches, err is wrapped as -// ReasonUnknown. -// 7. If the backend is unknown, err is returned unchanged. +// 6. Otherwise err is returned unchanged. // // Normalize is intentionally side-effect free. Callers own follow-up actions // such as stats recording or unhealthy-store transitions. @@ -378,15 +376,7 @@ func Normalize(backend Backend, mapper func(error) *SQLError, err error) error { return transportErr } - // Unknown backends are left untouched so callers do not accidentally invent - // a backend identity the error never had. - if !backend.Valid() { - return err - } - - // Keep backend identity even when the exact backend code is not recognized - // so logs and stats retain useful diagnosis data. - return NewSQLError(backend, ReasonUnknown, "", err) + return err } // extractSQLError extracts the shared SQL error wrapper from err, if present. diff --git a/wallet/internal/db/err/errors_test.go b/wallet/internal/db/err/errors_test.go index 9a4a9b6024..6001809ccd 100644 --- a/wallet/internal/db/err/errors_test.go +++ b/wallet/internal/db/err/errors_test.go @@ -14,8 +14,7 @@ import ( var errConstraint = errors.New("constraint") -// noOpMapper is a test helper that leaves backend-specific classification to -// later Normalize fallbacks. +// noOpMapper is a test helper that disables backend-specific classification. func noOpMapper(error) *SQLError { return nil } @@ -206,9 +205,13 @@ func TestNormalize(t *testing.T) { require.Equal(t, ClassTransient, sqlErr.Class()) require.Equal(t, ReasonUnavailable, sqlErr.Reason) - err = Normalize(BackendSQLite, func(in error) *SQLError { - return NewSQLError(BackendSQLite, ReasonUnknown, "", in) - }, errConstraint) + err = Normalize( + BackendSQLite, + func(in error) *SQLError { + return NewSQLError(BackendSQLite, ReasonUnknown, "", in) + }, + errConstraint, + ) sqlErr = extractSQLError(err) require.NotNil(t, sqlErr) require.Equal(t, ClassPermanent, sqlErr.Class()) @@ -225,7 +228,12 @@ func TestNormalize(t *testing.T) { require.Equal(t, Backend(""), extractSQLError(err).Backend) require.Same(t, errConstraint, - Normalize(Backend(""), noOpMapper, errConstraint)) + Normalize(BackendPostgres, noOpMapper, errConstraint)) + + require.Same(t, errConstraint, + func() error { + return Normalize(Backend(""), noOpMapper, errConstraint) + }()) } // temporaryNetError is a test helper that exposes only the legacy Temporary From eb09ea31d74e417b1cefc40f335ee8cfedfbeafd Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:44:59 +0800 Subject: [PATCH 562/691] db: add runtime stats snapshots Add shared runtime stats types and expose them through the main store interface so callers can read retry, health, ambiguous commit, and SQL error counters. Non-SQL stores return an empty runtime snapshot. --- wallet/internal/db/interface.go | 10 +-- wallet/internal/db/kvdb/driver.go | 6 ++ wallet/internal/db/runtime/runtime.go | 99 +++++++++++++++++++++++++++ wallet/mock_test.go | 7 ++ 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 35e5a39e25..fdcc8bf222 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -6,6 +6,7 @@ import ( "iter" "github.com/btcsuite/btcwallet/wallet/internal/db/page" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" ) var ( @@ -127,10 +128,13 @@ var ( // TODO(yy): Break down wallet managers into independent components. // // TODO(yy): Remove the linter ignore once Store grows beyond UTXOStore. -// -//nolint:iface // Transitional alias until Store grows beyond UTXOStore. type Store interface { UTXOStore + + // StatsSnapshot returns the current runtime counters tracked by the + // backend. + // Backends without SQL classification support may return an empty snapshot. + StatsSnapshot() dbruntime.StatsSnapshot } // WalletStore defines the methods for wallet-level operations. @@ -363,8 +367,6 @@ type TxStore interface { } // UTXOStore defines the database actions for managing the UTXO set. -// -//nolint:iface // Store is a transitional wrapper over UTXOStore. type UTXOStore interface { // GetUtxo retrieves a single unspent transaction output (UTXO) by its // outpoint. It returns a UtxoInfo struct containing the UTXO's details diff --git a/wallet/internal/db/kvdb/driver.go b/wallet/internal/db/kvdb/driver.go index 0eb5b35eaf..f69fbd70aa 100644 --- a/wallet/internal/db/kvdb/driver.go +++ b/wallet/internal/db/kvdb/driver.go @@ -2,6 +2,7 @@ package kvdb import ( "github.com/btcsuite/btcwallet/wallet/internal/db" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -26,3 +27,8 @@ func NewStore(dbConn walletdb.DB, txStore wtxmgr.TxStore) *Store { txStore: txStore, } } + +// StatsSnapshot returns an empty runtime snapshot for the kvdb backend. +func (s *Store) StatsSnapshot() dbruntime.StatsSnapshot { + return dbruntime.StatsSnapshot{} +} diff --git a/wallet/internal/db/runtime/runtime.go b/wallet/internal/db/runtime/runtime.go index 1c1b83300e..861fb7f777 100644 --- a/wallet/internal/db/runtime/runtime.go +++ b/wallet/internal/db/runtime/runtime.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "sync/atomic" "time" dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" @@ -161,6 +162,104 @@ type readConfig struct { timer func(time.Duration) *time.Timer } +// StatsSnapshot is a read-only copy of the shared runtime counters. +type StatsSnapshot struct { + // Unhealthy reports whether fatal SQL failures poisoned the store. + Unhealthy bool + + // RetryAttempts counts read retry attempts. + RetryAttempts uint64 + + // RetrySuccesses counts reads that later succeeded after retry. + RetrySuccesses uint64 + + // RetryExhausted counts read retries that exhausted their budget. + RetryExhausted uint64 + + // AmbiguousTxCommits counts commit failures with unknown outcome. + AmbiguousTxCommits uint64 + + // Errors snapshots the classified SQL error counters. + Errors dberr.StatsSnapshot +} + +// Stats stores low-overhead atomic counters for shared runtime behavior. +type Stats struct { + // unhealthy reports whether fatal SQL failures poisoned the store. + unhealthy atomic.Bool + + // errStats aggregates classified SQL error counts. + errStats dberr.Stats + + // retryAttempts counts read retry attempts. + retryAttempts atomic.Uint64 + + // retrySuccesses counts reads that later succeeded after retry. + retrySuccesses atomic.Uint64 + + // retryExhausted counts read retries that exhausted their budget. + retryExhausted atomic.Uint64 + + // ambiguousTxCommits counts commit failures with unknown outcome. + ambiguousTxCommits atomic.Uint64 +} + +// CheckHealthy reports whether fatal SQL failures already poisoned the store. +func (s *Stats) CheckHealthy() error { + if s.unhealthy.Load() { + return ErrStoreUnhealthy + } + + return nil +} + +// RecordError updates the shared SQL error counters and unhealthy state. +func (s *Stats) RecordError(err error) { + s.errStats.Record(err) + + var sqlErr *dberr.SQLError + if !errors.As(err, &sqlErr) { + return + } + + if sqlErr.Class() == dberr.ClassFatal { + s.unhealthy.Store(true) + } +} + +// RecordRetryAttempt records one read retry attempt. +func (s *Stats) RecordRetryAttempt() { + s.retryAttempts.Add(1) +} + +// RecordRetrySuccess records one successful read retry outcome. +func (s *Stats) RecordRetrySuccess() { + s.retrySuccesses.Add(1) +} + +// RecordRetryExhausted records one read retry sequence that exhausted its +// budget. +func (s *Stats) RecordRetryExhausted() { + s.retryExhausted.Add(1) +} + +// RecordAmbiguousTxCommit records one commit failure with unknown outcome. +func (s *Stats) RecordAmbiguousTxCommit() { + s.ambiguousTxCommits.Add(1) +} + +// Snapshot returns a read-only copy of the runtime counters. +func (s *Stats) Snapshot(backend dberr.Backend) StatsSnapshot { + return StatsSnapshot{ + Unhealthy: s.unhealthy.Load(), + RetryAttempts: s.retryAttempts.Load(), + RetrySuccesses: s.retrySuccesses.Load(), + RetryExhausted: s.retryExhausted.Load(), + AmbiguousTxCommits: s.ambiguousTxCommits.Load(), + Errors: s.errStats.Snapshot(backend), + } +} + // Read executes a read-only SQL callback with transient retry handling. // // Read returns the callback result from the first successful attempt. On any diff --git a/wallet/mock_test.go b/wallet/mock_test.go index 84127a5620..330ede9ad3 100644 --- a/wallet/mock_test.go +++ b/wallet/mock_test.go @@ -22,6 +22,7 @@ import ( "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/neutrino" @@ -42,6 +43,12 @@ type mockStore struct { // interface. var _ db.Store = (*mockStore)(nil) +// StatsSnapshot returns an empty runtime snapshot for tests that use the mock +// kvdb store. +func (m *mockStore) StatsSnapshot() dbruntime.StatsSnapshot { + return dbruntime.StatsSnapshot{} +} + // GetUtxo implements the db.UTXOStore interface. func (m *mockStore) GetUtxo(ctx context.Context, query db.GetUtxoQuery) (*db.UtxoInfo, error) { From ceadcf9cebe9453ff1ab33a1dfa70690b03adafc Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:48:52 +0800 Subject: [PATCH 563/691] db/sqlite: add runtime store helpers Add sqlite-local runtime adapters and store state so later call-site changes can share read retry, SQL error classification, unhealthy-store tracking, and write execution semantics. --- wallet/internal/db/sqlite/runtime.go | 125 ++++++++++++++++++++++ wallet/internal/db/sqlite/runtime_test.go | 118 ++++++++++++++++++++ wallet/internal/db/sqlite/store.go | 9 +- 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 wallet/internal/db/sqlite/runtime.go create mode 100644 wallet/internal/db/sqlite/runtime_test.go diff --git a/wallet/internal/db/sqlite/runtime.go b/wallet/internal/db/sqlite/runtime.go new file mode 100644 index 0000000000..11938e8929 --- /dev/null +++ b/wallet/internal/db/sqlite/runtime.go @@ -0,0 +1,125 @@ +package sqlite + +import ( + "context" + "database/sql" + "time" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" +) + +var ( + // Compile-time interface checks keep Store aligned with the shared runtime + // hook contracts. + _ dbruntime.ReadHooks = (*Store)(nil) + _ dbruntime.WriteHooks = (*Store)(nil) +) + +// Default SQLite read retry settings. +const ( + // defaultReadMaxAttempts keeps retries bounded to one initial attempt plus + // up to two retries for short-lived lock contention. + defaultReadMaxAttempts = 3 + + // defaultReadBaseDelay starts backoff at 10 ms so brief lock contention can + // clear without noticeably delaying callers. + defaultReadBaseDelay = 10 * time.Millisecond + + // defaultReadMaxDelay caps retry backoff at 100 ms so repeated transient + // errors do not stall wallet operations for too long. + defaultReadMaxDelay = 100 * time.Millisecond +) + +// execRead executes one SQLite read operation through the shared runtime +// helper. +func (s *Store) execRead(ctx context.Context, + fn func(*sqlc.Queries) error) error { + + _, err := dbruntime.Read( + ctx, s, s.queries, defaultReadConfig(), + func(_ context.Context, q *sqlc.Queries) (struct{}, error) { + return struct{}{}, fn(q) + }, + ) + + return err +} + +// execWrite executes one SQLite write operation through the shared runtime +// helper. +func (s *Store) execWrite(ctx context.Context, + fn func(*sqlc.Queries) error) error { + + _, err := dbruntime.Write( + ctx, s, + func(tx *sql.Tx) *sqlc.Queries { + return s.queries.WithTx(tx) + }, + func(qtx *sqlc.Queries) (struct{}, error) { + return struct{}{}, fn(qtx) + }, + ) + + return err +} + +// defaultReadConfig returns the SQLite read retry policy. +func defaultReadConfig() dbruntime.ReadConfig { + // TODO(yy): make it configurable. + return dbruntime.ReadConfig{ + MaxAttempts: defaultReadMaxAttempts, + BaseDelay: defaultReadBaseDelay, + MaxDelay: defaultReadMaxDelay, + } +} + +// CheckHealthy reports whether a prior fatal SQL backend error poisoned the +// store. +func (s *Store) CheckHealthy() error { + return s.runtimeStats.CheckHealthy() +} + +// ClassifyError normalizes one SQLite backend error into the shared SQL error +// model while preserving ordinary domain errors unchanged. +func (s *Store) ClassifyError(err error) error { + return dberr.Normalize(dberr.BackendSQLite, mapErr, err) +} + +// RecordError records one classified SQLite backend error and marks the store +// unhealthy after fatal failures. +func (s *Store) RecordError(err error) { + s.runtimeStats.RecordError(err) +} + +// RecordRetryAttempt records one SQLite read retry attempt. +func (s *Store) RecordRetryAttempt() { + s.runtimeStats.RecordRetryAttempt() +} + +// RecordRetrySuccess records one successful SQLite read retry outcome. +func (s *Store) RecordRetrySuccess() { + s.runtimeStats.RecordRetrySuccess() +} + +// RecordRetryExhausted records one exhausted SQLite read retry sequence. +func (s *Store) RecordRetryExhausted() { + s.runtimeStats.RecordRetryExhausted() +} + +// RecordAmbiguousTxCommit records one SQLite commit failure with unknown +// outcome. +func (s *Store) RecordAmbiguousTxCommit() { + s.runtimeStats.RecordAmbiguousTxCommit() +} + +// RawDB returns the SQLite database handle used by shared runtime writes. +func (s *Store) RawDB() *sql.DB { + return s.db +} + +// StatsSnapshot returns the current SQLite runtime counters. +func (s *Store) StatsSnapshot() dbruntime.StatsSnapshot { + return s.runtimeStats.Snapshot(dberr.BackendSQLite) +} diff --git a/wallet/internal/db/sqlite/runtime_test.go b/wallet/internal/db/sqlite/runtime_test.go new file mode 100644 index 0000000000..021f9cac61 --- /dev/null +++ b/wallet/internal/db/sqlite/runtime_test.go @@ -0,0 +1,118 @@ +package sqlite + +import ( + "database/sql" + "path/filepath" + "testing" + + db "github.com/btcsuite/btcwallet/wallet/internal/db" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/stretchr/testify/require" +) + +// TestClassifyErrorReturnsOriginalErrors verifies that SQLite classification +// preserves domain and already-classified errors unchanged. +func TestClassifyErrorReturnsOriginalErrors(t *testing.T) { + t.Parallel() + + store := &Store{} + errDup := dberr.NewSQLError( + dberr.BackendSQLite, dberr.ReasonConstraint, "19", sql.ErrTxDone, + ) + tests := []struct { + name string + err error + }{ + {name: "wallet not found", err: db.ErrWalletNotFound}, + {name: "tx not found", err: db.ErrTxNotFound}, + {name: "generic error", err: sql.ErrNoRows}, + {name: "existing sql error", err: errDup}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + require.Same(t, test.err, store.ClassifyError(test.err)) + }) + } +} + +// TestClassifyErrorTransportError verifies that SQLite transport failures are +// classified as shared unavailable SQL errors. +func TestClassifyErrorTransportError(t *testing.T) { + t.Parallel() + + store := &Store{} + classifiedErr := store.ClassifyError(sql.ErrConnDone) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) +} + +// TestClassifyErrorBackendConstraint verifies that SQLite constraint failures +// are classified as shared SQL constraint errors. +func TestClassifyErrorBackendConstraint(t *testing.T) { + t.Parallel() + + store := &Store{} + dbPath := filepath.Join(t.TempDir(), "wallet.db") + dbConn, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + ctx := t.Context() + _, err = dbConn.ExecContext( + ctx, `CREATE TABLE demo (id INTEGER PRIMARY KEY, val TEXT UNIQUE)`, + ) + require.NoError(t, err) + + _, err = dbConn.ExecContext( + ctx, `INSERT INTO demo (val) VALUES ('dup')`, + ) + require.NoError(t, err) + + _, err = dbConn.ExecContext( + ctx, `INSERT INTO demo (val) VALUES ('dup')`, + ) + require.Error(t, err) + + classifiedErr := store.ClassifyError(err) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) +} + +// TestClassifyErrorUnknownBackendError verifies that unmapped SQLite-native +// errors still remain wrapped as shared SQL errors with ReasonUnknown. +func TestClassifyErrorUnknownBackendError(t *testing.T) { + t.Parallel() + + store := &Store{} + dbPath := filepath.Join(t.TempDir(), "wallet.db") + dbConn, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + ctx := t.Context() + _, err = dbConn.ExecContext( + ctx, `CREATE TABLE demo (id INTEGER PRIMARY KEY, val TEXT)`, + ) + require.NoError(t, err) + + _, err = dbConn.ExecContext(ctx, `SELECT * FROM demo WHERE`) + require.Error(t, err) + + classifiedErr := store.ClassifyError(err) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, dberr.ReasonUnknown, sqlErr.Reason) + require.Equal(t, dberr.BackendSQLite, sqlErr.Backend) +} diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index a93ed3fd72..c936dfdc43 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" _ "modernc.org/sqlite" // Import sqlite driver for sqlite database/sql support. @@ -13,8 +14,14 @@ import ( // Store is the SQLite implementation of the WalletStore interface. type Store struct { - db *sql.DB + // db is the shared SQLite connection pool. + db *sql.DB + + // queries executes SQLite statements on db. queries *sqlc.Queries + + // runtimeStats tracks shared runtime counters and unhealthy state. + runtimeStats dbruntime.Stats } // NewStore creates a new SQLite-based WalletStore. It handles the full From 6a1361eab80bf40ce8b9f538aa4c12be6d84e8f9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:49:33 +0800 Subject: [PATCH 564/691] db/sqlite: route write paths through runtime Switch sqlite mutating store operations to the package-local runtime write helper so transaction execution, SQL error classification, and ambiguous commit handling stay centralized. --- wallet/internal/db/sqlite/accounts.go | 18 ++++++++------ wallet/internal/db/sqlite/addresses.go | 4 ++-- wallet/internal/db/sqlite/store.go | 13 ---------- wallet/internal/db/sqlite/txstore_createtx.go | 14 +++++------ wallet/internal/db/sqlite/txstore_deletetx.go | 2 +- .../db/sqlite/txstore_invalidateunmined.go | 2 +- wallet/internal/db/sqlite/txstore_rollback.go | 2 +- wallet/internal/db/sqlite/txstore_updatetx.go | 2 +- .../db/sqlite/utxostore_leaseoutput.go | 2 +- .../db/sqlite/utxostore_releaseoutput.go | 2 +- wallet/internal/db/sqlite/wallet.go | 24 ++++++++++--------- 11 files changed, 39 insertions(+), 46 deletions(-) diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index b4507f8bcf..1a193e22eb 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -41,11 +41,13 @@ func (s *Store) ListAccounts(ctx context.Context, func (s *Store) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { - renameQueries := accountRenameQueries{q: s.queries} + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + renameQueries := accountRenameQueries{q: qtx} - return db.RenameAccountByQuery( - ctx, params, renameQueries.byNumber, renameQueries.byName, - ) + return db.RenameAccountByQuery( + ctx, params, renameQueries.byNumber, renameQueries.byName, + ) + }) } // CreateDerivedAccount creates a new derived account with the given name and @@ -61,7 +63,7 @@ func (s *Store) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -89,7 +91,9 @@ func (s *Store) CreateDerivedAccount(ctx context.Context, accNumber, err := db.Int64ToUint32(row.AccountNumber.Int64) if err != nil { - return fmt.Errorf("%w: %w", db.ErrMaxAccountNumberReached, err) + return fmt.Errorf("%w: %w", + db.ErrMaxAccountNumberReached, err, + ) } info = db.BuildAccountInfo( @@ -145,7 +149,7 @@ func (s *Store) CreateImportedAccount(ctx context.Context, var props *db.AccountProperties - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { var err error props, err = db.CreateImportedAccount( diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 32a293088a..1662e6e10b 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -108,7 +108,7 @@ func (s *Store) NewDerivedAddress(ctx context.Context, } return db.NewDerivedAddressWithTx( - ctx, params, s.ExecuteTx, adapters, deriveFn, + ctx, params, s.execWrite, adapters, deriveFn, ) } @@ -134,7 +134,7 @@ func (s *Store) NewImportedAddress(ctx context.Context, RowCreatedAt: importedAddressRowCreatedAt, } - return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return db.NewImportedAddressWithTx(ctx, params, s.execWrite, adapters) } // getAccountFromKey returns a helper to look up accounts by key. diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go index c936dfdc43..09acdc32d1 100644 --- a/wallet/internal/db/sqlite/store.go +++ b/wallet/internal/db/sqlite/store.go @@ -88,16 +88,3 @@ func (s *Store) Close() error { return nil } - -// ExecuteTx executes a function within a database transaction. The function -// receives a transactional query executor and should perform all database -// operations using it. The transaction will be automatically committed on -// success or rolled back on error. -func (s *Store) ExecuteTx(ctx context.Context, - fn func(*sqlc.Queries) error) error { - - return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { - qtx := s.queries.WithTx(tx) - return fn(qtx) - }) -} diff --git a/wallet/internal/db/sqlite/txstore_createtx.go b/wallet/internal/db/sqlite/txstore_createtx.go index 71466f0312..7428a555a0 100644 --- a/wallet/internal/db/sqlite/txstore_createtx.go +++ b/wallet/internal/db/sqlite/txstore_createtx.go @@ -15,12 +15,12 @@ import ( // CreateTx atomically records a wallet-scoped transaction row, its wallet-owned // credits, and any spend edges created by its inputs. // -// The full write runs inside ExecuteTx so the transaction row, created UTXOs, -// spent-parent markers, and any required invalidation are either committed -// together or not at all. Received timestamps are normalized to UTC before -// Insert. When the wallet already stores the same unmined transaction hash, -// CreateTx may promote that existing row to confirmed state instead of -// inserting a duplicate. +// The full write runs inside the shared write helper so the transaction row, +// created UTXOs, spent-parent markers, and any required invalidation are +// either committed together or not at all. Received timestamps are normalized +// to UTC before Insert. When the wallet already stores the same unmined +// transaction hash, CreateTx may promote that existing row to confirmed state +// instead of inserting a duplicate. func (s *Store) CreateTx(ctx context.Context, params db.CreateTxParams) error { @@ -29,7 +29,7 @@ func (s *Store) CreateTx(ctx context.Context, return err } - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.CreateTxWithOps(ctx, req, &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, diff --git a/wallet/internal/db/sqlite/txstore_deletetx.go b/wallet/internal/db/sqlite/txstore_deletetx.go index d5f59eb3ee..769d109830 100644 --- a/wallet/internal/db/sqlite/txstore_deletetx.go +++ b/wallet/internal/db/sqlite/txstore_deletetx.go @@ -21,7 +21,7 @@ import ( func (s *Store) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } diff --git a/wallet/internal/db/sqlite/txstore_invalidateunmined.go b/wallet/internal/db/sqlite/txstore_invalidateunmined.go index 3e914f1217..1193771e02 100644 --- a/wallet/internal/db/sqlite/txstore_invalidateunmined.go +++ b/wallet/internal/db/sqlite/txstore_invalidateunmined.go @@ -16,7 +16,7 @@ import ( func (s *Store) InvalidateUnminedTx(ctx context.Context, params db.InvalidateUnminedTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.InvalidateUnminedTxWithOps( ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) diff --git a/wallet/internal/db/sqlite/txstore_rollback.go b/wallet/internal/db/sqlite/txstore_rollback.go index 21a05a1c3f..de26c7e185 100644 --- a/wallet/internal/db/sqlite/txstore_rollback.go +++ b/wallet/internal/db/sqlite/txstore_rollback.go @@ -16,7 +16,7 @@ import ( func (s *Store) RollbackToBlock(ctx context.Context, height uint32) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.RollbackToBlockWithOps(ctx, height, rollbackToBlockOps{qtx: qtx}) }) diff --git a/wallet/internal/db/sqlite/txstore_updatetx.go b/wallet/internal/db/sqlite/txstore_updatetx.go index e23a92e412..9521831ca5 100644 --- a/wallet/internal/db/sqlite/txstore_updatetx.go +++ b/wallet/internal/db/sqlite/txstore_updatetx.go @@ -20,7 +20,7 @@ import ( func (s *Store) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } diff --git a/wallet/internal/db/sqlite/utxostore_leaseoutput.go b/wallet/internal/db/sqlite/utxostore_leaseoutput.go index 1be9ccb8a7..502fbff245 100644 --- a/wallet/internal/db/sqlite/utxostore_leaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_leaseoutput.go @@ -21,7 +21,7 @@ func (s *Store) LeaseOutput(ctx context.Context, var lease *db.LeasedOutput - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &leaseOutputOps{qtx: qtx}, ) diff --git a/wallet/internal/db/sqlite/utxostore_releaseoutput.go b/wallet/internal/db/sqlite/utxostore_releaseoutput.go index e2a453c3d0..19f9533523 100644 --- a/wallet/internal/db/sqlite/utxostore_releaseoutput.go +++ b/wallet/internal/db/sqlite/utxostore_releaseoutput.go @@ -19,7 +19,7 @@ import ( func (s *Store) ReleaseOutput(ctx context.Context, params db.ReleaseOutputParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.ReleaseOutputWithOps( ctx, params, &releaseOutputOps{qtx: qtx}, ) diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index 0e3f29b6b2..e1d6506169 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -23,7 +23,7 @@ func (s *Store) CreateWallet(ctx context.Context, var info *db.WalletInfo - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { walletParams := sqlc.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, @@ -196,7 +196,7 @@ func (s *Store) IterWallets(ctx context.Context, func (s *Store) UpdateWallet(ctx context.Context, params db.UpdateWalletParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExists( @@ -272,17 +272,19 @@ func (s *Store) UpdateWalletSecrets(ctx context.Context, WalletID: int64(params.WalletID), } - rowsAffected, err := s.queries.UpdateWalletSecrets(ctx, secretsParams) - if err != nil { - return fmt.Errorf("update wallet secrets: %w", err) - } + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + rowsAffected, err := qtx.UpdateWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf("update wallet secrets: %w", err) + } - if rowsAffected == 0 { - return fmt.Errorf("wallet secrets for wallet %d: %w", - params.WalletID, db.ErrWalletNotFound) - } + if rowsAffected == 0 { + return fmt.Errorf("wallet secrets for wallet %d: %w", + params.WalletID, db.ErrWalletNotFound) + } - return nil + return nil + }) } // walletRowParams holds the parameters needed to build a From 8866d9679bb4c8c45d0990c733dc971cbae438cd Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:49:56 +0800 Subject: [PATCH 565/691] db/sqlite: route read paths through runtime Switch sqlite read-only store operations to the package-local runtime read helper so retry policy and SQL error classification stay centralized without direct query plumbing in each method. --- wallet/internal/db/sqlite/accounts.go | 43 +++++-- wallet/internal/db/sqlite/address_types.go | 40 +++++- wallet/internal/db/sqlite/addresses.go | 65 +++++++--- wallet/internal/db/sqlite/txstore_gettx.go | 40 ++++-- wallet/internal/db/sqlite/txstore_listtxns.go | 31 ++++- .../internal/db/sqlite/utxostore_balance.go | 37 ++++-- .../internal/db/sqlite/utxostore_getutxo.go | 43 ++++--- .../db/sqlite/utxostore_listleasedoutputs.go | 49 ++++--- .../internal/db/sqlite/utxostore_listutxos.go | 43 ++++--- wallet/internal/db/sqlite/wallet.go | 120 +++++++++++------- 10 files changed, 349 insertions(+), 162 deletions(-) diff --git a/wallet/internal/db/sqlite/accounts.go b/wallet/internal/db/sqlite/accounts.go index 1a193e22eb..50825e5d3b 100644 --- a/wallet/internal/db/sqlite/accounts.go +++ b/wallet/internal/db/sqlite/accounts.go @@ -17,11 +17,24 @@ var _ db.AccountStore = (*Store)(nil) func (s *Store) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { - getQueries := accountGetQueries{q: s.queries} + var account *db.AccountInfo - return db.GetAccountByQuery( - ctx, query, getQueries.byNumber, getQueries.byName, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + getQueries := accountGetQueries{q: q} + + var err error + + account, err = db.GetAccountByQuery( + ctx, query, getQueries.byNumber, getQueries.byName, + ) + + return err + }) + if err != nil { + return nil, err + } + + return account, nil } // ListAccounts returns a slice of AccountInfo for all accounts, optionally @@ -29,11 +42,25 @@ func (s *Store) GetAccount(ctx context.Context, func (s *Store) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { - listQueries := accountListQueries{q: s.queries} + var accounts []db.AccountInfo - return db.ListAccountsByQuery( - ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + listQueries := accountListQueries{q: q} + + var err error + + accounts, err = db.ListAccountsByQuery( + ctx, query, listQueries.byScope, listQueries.byName, + listQueries.all, + ) + + return err + }) + if err != nil { + return nil, err + } + + return accounts, nil } // RenameAccount changes the name of an account. The account can be identified diff --git a/wallet/internal/db/sqlite/address_types.go b/wallet/internal/db/sqlite/address_types.go index ab12a86503..56efbad6bc 100644 --- a/wallet/internal/db/sqlite/address_types.go +++ b/wallet/internal/db/sqlite/address_types.go @@ -28,9 +28,22 @@ func addressTypeRowToInfo(row sqlc.AddressType) (db.AddressTypeInfo, func (s *Store) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { - return db.ListAddressTypes( - ctx, s.queries.ListAddressTypes, addressTypeRowToInfo, - ) + var infos []db.AddressTypeInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + infos, err = db.ListAddressTypes( + ctx, q.ListAddressTypes, addressTypeRowToInfo, + ) + + return err + }) + if err != nil { + return nil, err + } + + return infos, nil } // GetAddressType returns the AddressTypeInfo associated with the given address @@ -38,8 +51,21 @@ func (s *Store) ListAddressTypes(ctx context.Context) ( func (s *Store) GetAddressType(ctx context.Context, id db.AddressType) (db.AddressTypeInfo, error) { - return db.GetAddressTypeByID( - ctx, s.queries.GetAddressTypeByID, int64(id), id, - addressTypeRowToInfo, - ) + var info db.AddressTypeInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + info, err = db.GetAddressTypeByID( + ctx, q.GetAddressTypeByID, int64(id), id, + addressTypeRowToInfo, + ) + + return err + }) + if err != nil { + return db.AddressTypeInfo{}, err + } + + return info, nil } diff --git a/wallet/internal/db/sqlite/addresses.go b/wallet/internal/db/sqlite/addresses.go index 1662e6e10b..f7f93f31b2 100644 --- a/wallet/internal/db/sqlite/addresses.go +++ b/wallet/internal/db/sqlite/addresses.go @@ -19,19 +19,32 @@ var _ db.AddressStore = (*Store)(nil) func (s *Store) GetAddress(ctx context.Context, query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, - q db.GetAddressQuery) (*db.AddressInfo, error) { - - return db.GetAddress( - ctx, s.queries.GetAddressByScriptPubKey, - sqlc.GetAddressByScriptPubKeyParams{ - WalletID: int64(q.WalletID), - ScriptPubKey: q.ScriptPubKey, - }, addressRowToInfo, - ) + var info *db.AddressInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + getByScript := func(ctx context.Context, + query db.GetAddressQuery) (*db.AddressInfo, error) { + + return db.GetAddress( + ctx, q.GetAddressByScriptPubKey, + sqlc.GetAddressByScriptPubKeyParams{ + WalletID: int64(query.WalletID), + ScriptPubKey: query.ScriptPubKey, + }, addressRowToInfo, + ) + } + + var err error + + info, err = db.GetAddressByQuery(ctx, query, getByScript) + + return err + }) + if err != nil { + return nil, err } - return db.GetAddressByQuery(ctx, query, getByScript) + return info, nil } // ListAddresses returns a page of addresses matching the given query. @@ -42,7 +55,15 @@ func (s *Store) ListAddresses(ctx context.Context, return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit } - items, err := listAddressesByAccount(ctx, s.queries, query) + var items []db.AddressInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + items, err = listAddressesByAccount(ctx, q, query) + + return err + }) if err != nil { return page.Result[db.AddressInfo, uint32]{}, err } @@ -81,9 +102,23 @@ func (s *Store) GetAddressSecret(ctx context.Context, ) } - return db.GetAddressSecret( - ctx, getSecret, query, addressSecretRowToSecret, - ) + var secret *db.AddressSecret + + err := s.execRead(ctx, func(_ *sqlc.Queries) error { + var err error + + secret, err = db.GetAddressSecret( + ctx, getSecret, query, + addressSecretRowToSecret, + ) + + return err + }) + if err != nil { + return nil, err + } + + return secret, nil } // NewDerivedAddress creates a new address for a given account and key diff --git a/wallet/internal/db/sqlite/txstore_gettx.go b/wallet/internal/db/sqlite/txstore_gettx.go index 9870a85772..2c3a6d56e1 100644 --- a/wallet/internal/db/sqlite/txstore_gettx.go +++ b/wallet/internal/db/sqlite/txstore_gettx.go @@ -18,24 +18,36 @@ import ( func (s *Store) GetTx(ctx context.Context, query db.GetTxQuery) (*db.TxInfo, error) { - row, err := s.queries.GetTransactionByHash( - ctx, sqlc.GetTransactionByHashParams{ - WalletID: int64(query.WalletID), - TxHash: query.Txid[:], - }, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("tx %s: %w", query.Txid, db.ErrTxNotFound) + var info *db.TxInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetTransactionByHash( + ctx, sqlc.GetTransactionByHashParams{ + WalletID: int64(query.WalletID), + TxHash: query.Txid[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("tx %s: %w", + query.Txid, db.ErrTxNotFound) + } + + return fmt.Errorf("get tx: %w", err) } - return nil, fmt.Errorf("get tx: %w", err) + info, err = txInfoFromRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, + ) + + return err + }) + if err != nil { + return nil, err } - return txInfoFromRow( - row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, - row.BlockHash, row.BlockTimestamp, row.TxStatus, row.TxLabel, - ) + return info, nil } // txInfoFromRow converts one normalized sqlite query row into the public diff --git a/wallet/internal/db/sqlite/txstore_listtxns.go b/wallet/internal/db/sqlite/txstore_listtxns.go index 9581a9dfc5..a5fe9118c9 100644 --- a/wallet/internal/db/sqlite/txstore_listtxns.go +++ b/wallet/internal/db/sqlite/txstore_listtxns.go @@ -18,11 +18,28 @@ import ( func (s *Store) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { - if query.UnminedOnly { - return s.listTxnsWithoutBlock(ctx, query.WalletID) + var txns []db.TxInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + if query.UnminedOnly { + var err error + + txns, err = s.listTxnsWithoutBlock(ctx, q, query.WalletID) + + return err + } + + var err error + + txns, err = s.listConfirmedTxns(ctx, q, query) + + return err + }) + if err != nil { + return nil, err } - return s.listConfirmedTxns(ctx, query) + return txns, nil } // listTxnsWithoutBlock loads every transaction row that currently has no @@ -30,9 +47,9 @@ func (s *Store) ListTxns(ctx context.Context, // retained invalid history that rollback or invalidation flows left without a // confirming block. func (s *Store) listTxnsWithoutBlock(ctx context.Context, - walletID uint32) ([]db.TxInfo, error) { + q *sqlc.Queries, walletID uint32) ([]db.TxInfo, error) { - rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) + rows, err := q.ListTransactionsWithoutBlock(ctx, int64(walletID)) if err != nil { return nil, fmt.Errorf("list txns without block: %w", err) } @@ -56,9 +73,9 @@ func (s *Store) listTxnsWithoutBlock(ctx context.Context, // listConfirmedTxns loads the confirmed height-range view used by // ListTxns when callers query mined history. func (s *Store) listConfirmedTxns(ctx context.Context, - query db.ListTxnsQuery) ([]db.TxInfo, error) { + q *sqlc.Queries, query db.ListTxnsQuery) ([]db.TxInfo, error) { - rows, err := s.queries.ListTransactionsByHeightRange( + rows, err := q.ListTransactionsByHeightRange( ctx, sqlc.ListTransactionsByHeightRangeParams{ WalletID: int64(query.WalletID), StartHeight: int64(query.StartHeight), diff --git a/wallet/internal/db/sqlite/utxostore_balance.go b/wallet/internal/db/sqlite/utxostore_balance.go index 4a8cda5005..25c0d07252 100644 --- a/wallet/internal/db/sqlite/utxostore_balance.go +++ b/wallet/internal/db/sqlite/utxostore_balance.go @@ -16,20 +16,33 @@ func (s *Store) Balance(ctx context.Context, nowUTC := time.Now().UTC() - balance, err := s.queries.Balance(ctx, sqlc.BalanceParams{ - NowUtc: nowUTC, - WalletID: int64(params.WalletID), - AccountNumber: db.NullableUint32ToSQLInt64(params.Account), - MinConfirms: db.NullableInt32ToSQLInt64(params.MinConfs), - MaxConfirms: db.NullableInt32ToSQLInt64(params.MaxConfs), - CoinbaseMaturity: db.NullableInt32ToSQLInt64(params.CoinbaseMaturity), + var result db.BalanceResult + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + balance, err := q.Balance(ctx, sqlc.BalanceParams{ + NowUtc: nowUTC, + WalletID: int64(params.WalletID), + AccountNumber: db.NullableUint32ToSQLInt64(params.Account), + MinConfirms: db.NullableInt32ToSQLInt64(params.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt64(params.MaxConfs), + CoinbaseMaturity: db.NullableInt32ToSQLInt64( + params.CoinbaseMaturity, + ), + }) + if err != nil { + return fmt.Errorf("balance: %w", err) + } + + result = db.BalanceResult{ + Total: btcutil.Amount(balance.TotalBalance), + Locked: btcutil.Amount(balance.LockedBalance), + } + + return nil }) if err != nil { - return db.BalanceResult{}, fmt.Errorf("balance: %w", err) + return db.BalanceResult{}, err } - return db.BalanceResult{ - Total: btcutil.Amount(balance.TotalBalance), - Locked: btcutil.Amount(balance.LockedBalance), - }, nil + return result, nil } diff --git a/wallet/internal/db/sqlite/utxostore_getutxo.go b/wallet/internal/db/sqlite/utxostore_getutxo.go index c470041807..24941e8624 100644 --- a/wallet/internal/db/sqlite/utxostore_getutxo.go +++ b/wallet/internal/db/sqlite/utxostore_getutxo.go @@ -18,26 +18,37 @@ import ( func (s *Store) GetUtxo(ctx context.Context, query db.GetUtxoQuery) (*db.UtxoInfo, error) { - row, err := s.queries.GetUtxoByOutpoint( - ctx, sqlc.GetUtxoByOutpointParams{ - WalletID: int64(query.WalletID), - TxHash: query.OutPoint.Hash[:], - OutputIndex: int64(query.OutPoint.Index), - }, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, - db.ErrUtxoNotFound) + var utxo *db.UtxoInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetUtxoByOutpoint( + ctx, sqlc.GetUtxoByOutpointParams{ + WalletID: int64(query.WalletID), + TxHash: query.OutPoint.Hash[:], + OutputIndex: int64(query.OutPoint.Index), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("utxo %s: %w", query.OutPoint, + db.ErrUtxoNotFound) + } + + return fmt.Errorf("get utxo: %w", err) } - return nil, fmt.Errorf("get utxo: %w", err) + utxo, err = utxoInfoFromRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + + return err + }) + if err != nil { + return nil, err } - return utxoInfoFromRow( - row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, - row.ReceivedTime, row.IsCoinbase, row.BlockHeight, - ) + return utxo, nil } // utxoInfoFromRow converts one normalized sqlite query row into the diff --git a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go index b7a6317dcb..5a81ff91e7 100644 --- a/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/sqlite/utxostore_listleasedoutputs.go @@ -15,31 +15,40 @@ func (s *Store) ListLeasedOutputs(ctx context.Context, nowUTC := time.Now().UTC() - rows, err := s.queries.ListActiveUtxoLeases( - ctx, sqlc.ListActiveUtxoLeasesParams{ - WalletID: int64(walletID), - NowUtc: nowUTC, - }, - ) - if err != nil { - return nil, fmt.Errorf("list active utxo leases: %w", err) - } - - leases := make([]db.LeasedOutput, len(rows)) - for i, row := range rows { - outputIndex, err := db.Int64ToUint32(row.OutputIndex) + var leases []db.LeasedOutput + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListActiveUtxoLeases( + ctx, sqlc.ListActiveUtxoLeasesParams{ + WalletID: int64(walletID), + NowUtc: nowUTC, + }, + ) if err != nil { - return nil, fmt.Errorf("lease output index: %w", err) + return fmt.Errorf("list active utxo leases: %w", err) } - lease, err := db.BuildLeasedOutput( - row.TxHash, outputIndex, row.LockID, row.ExpiresAt, - ) - if err != nil { - return nil, err + leases = make([]db.LeasedOutput, len(rows)) + for i, row := range rows { + outputIndex, err := db.Int64ToUint32(row.OutputIndex) + if err != nil { + return fmt.Errorf("lease output index: %w", err) + } + + lease, err := db.BuildLeasedOutput( + row.TxHash, outputIndex, row.LockID, row.ExpiresAt, + ) + if err != nil { + return err + } + + leases[i] = *lease } - leases[i] = *lease + return nil + }) + if err != nil { + return nil, err } return leases, nil diff --git a/wallet/internal/db/sqlite/utxostore_listutxos.go b/wallet/internal/db/sqlite/utxostore_listutxos.go index 2fa7e94e88..f02f676a8f 100644 --- a/wallet/internal/db/sqlite/utxostore_listutxos.go +++ b/wallet/internal/db/sqlite/utxostore_listutxos.go @@ -15,27 +15,36 @@ import ( func (s *Store) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { - rows, err := s.queries.ListUtxos(ctx, sqlc.ListUtxosParams{ - WalletID: int64(query.WalletID), - AccountNumber: db.NullableUint32ToSQLInt64(query.Account), - MinConfirms: db.NullableInt32ToSQLInt64(query.MinConfs), - MaxConfirms: db.NullableInt32ToSQLInt64(query.MaxConfs), - }) - if err != nil { - return nil, fmt.Errorf("list utxos: %w", err) - } + var utxos []db.UtxoInfo - utxos := make([]db.UtxoInfo, len(rows)) - for i, row := range rows { - utxo, err := utxoInfoFromRow( - row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, - row.ReceivedTime, row.IsCoinbase, row.BlockHeight, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListUtxos(ctx, sqlc.ListUtxosParams{ + WalletID: int64(query.WalletID), + AccountNumber: db.NullableUint32ToSQLInt64(query.Account), + MinConfirms: db.NullableInt32ToSQLInt64(query.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt64(query.MaxConfs), + }) if err != nil { - return nil, err + return fmt.Errorf("list utxos: %w", err) + } + + utxos = make([]db.UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := utxoInfoFromRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + if err != nil { + return err + } + + utxos[i] = *utxo } - utxos[i] = *utxo + return nil + }) + if err != nil { + return nil, err } return utxos, nil diff --git a/wallet/internal/db/sqlite/wallet.go b/wallet/internal/db/sqlite/wallet.go index e1d6506169..a5d37a1e96 100644 --- a/wallet/internal/db/sqlite/wallet.go +++ b/wallet/internal/db/sqlite/wallet.go @@ -117,30 +117,41 @@ func (s *Store) CreateWallet(ctx context.Context, func (s *Store) GetWallet(ctx context.Context, name string) (*db.WalletInfo, error) { - row, err := s.queries.GetWalletByName(ctx, name) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("wallet %q: %w", name, - db.ErrWalletNotFound) + var info *db.WalletInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetWalletByName(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("wallet %q: %w", name, + db.ErrWalletNotFound) + } + + return fmt.Errorf("get wallet: %w", err) } - return nil, fmt.Errorf("get wallet: %w", err) - } + info, err = buildWalletInfo(walletRowParams{ + id: row.ID, + name: row.WalletName, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayTimestamp: row.BirthdayTimestamp, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) - return buildWalletInfo(walletRowParams{ - id: row.ID, - name: row.WalletName, - isImported: row.IsImported, - managerVersion: row.ManagerVersion, - isWatchOnly: row.IsWatchOnly, - syncedHeight: row.SyncedHeight, - syncedBlockHash: row.SyncedBlockHash, - syncedBlockTimestamp: row.SyncedBlockTimestamp, - birthdayHeight: row.BirthdayHeight, - birthdayTimestamp: row.BirthdayTimestamp, - birthdayBlockHash: row.BirthdayBlockHash, - birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + return err }) + if err != nil { + return nil, err + } + + return info, nil } // ListWallets returns a page of wallets matching the given query. @@ -151,23 +162,29 @@ func (s *Store) ListWallets(ctx context.Context, return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit } - rows, err := s.queries.ListWallets( - ctx, listWalletsParams(query.Page), - ) - if err != nil { - return page.Result[db.WalletInfo, uint32]{}, - fmt.Errorf("list wallets page: %w", err) - } + var items []db.WalletInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListWallets(ctx, listWalletsParams(query.Page)) + if err != nil { + return fmt.Errorf("list wallets page: %w", err) + } + + items = make([]db.WalletInfo, len(rows)) + for i, row := range rows { + item, errMap := listWalletRowToInfo(row) + if errMap != nil { + return fmt.Errorf("list wallets page: map row: %w", + errMap) + } - items := make([]db.WalletInfo, len(rows)) - for i, row := range rows { - item, errMap := listWalletRowToInfo(row) - if errMap != nil { - return page.Result[db.WalletInfo, uint32]{}, - fmt.Errorf("list wallets page: map row: %w", errMap) + items[i] = *item } - items[i] = *item + return nil + }) + if err != nil { + return page.Result[db.WalletInfo, uint32]{}, err } result := page.BuildResult( @@ -241,23 +258,34 @@ func (s *Store) UpdateWallet(ctx context.Context, func (s *Store) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { - secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("secrets for wallet %d: %w", - walletID, db.ErrWalletNotFound) + var encrypted []byte + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + secrets, err := q.GetWalletSecrets(ctx, int64(walletID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("secrets for wallet %d: %w", + walletID, db.ErrWalletNotFound) + } + + return fmt.Errorf("get wallet secrets: %w", err) } - return nil, fmt.Errorf("get wallet secrets: %w", err) - } + if len(secrets.EncryptedMasterHdPrivKey) == 0 { + return fmt.Errorf( + "encrypted master privkey for wallet %d: %w", walletID, + db.ErrSecretNotFound) + } - if len(secrets.EncryptedMasterHdPrivKey) == 0 { - return nil, fmt.Errorf( - "encrypted master privkey for wallet %d: %w", walletID, - db.ErrSecretNotFound) + encrypted = secrets.EncryptedMasterHdPrivKey + + return nil + }) + if err != nil { + return nil, err } - return secrets.EncryptedMasterHdPrivKey, nil + return encrypted, nil } // UpdateWalletSecrets updates the secrets for the wallet. From c4ef45301f212c78b95221250fa6287668e77b23 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:54:41 +0800 Subject: [PATCH 566/691] db/pg: add runtime store helpers Add postgres-local runtime adapters and store state so later call-site changes can share read retry, SQL error classification, unhealthy-store tracking, and write execution semantics. --- wallet/internal/db/pg/runtime.go | 125 ++++++++++++++++++++++++++ wallet/internal/db/pg/runtime_test.go | 99 ++++++++++++++++++++ wallet/internal/db/pg/store.go | 9 +- 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 wallet/internal/db/pg/runtime.go create mode 100644 wallet/internal/db/pg/runtime_test.go diff --git a/wallet/internal/db/pg/runtime.go b/wallet/internal/db/pg/runtime.go new file mode 100644 index 0000000000..fbf7469ae6 --- /dev/null +++ b/wallet/internal/db/pg/runtime.go @@ -0,0 +1,125 @@ +package pg + +import ( + "context" + "database/sql" + "time" + + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" +) + +var ( + // Compile-time interface checks keep Store aligned with the shared runtime + // hook contracts. + _ dbruntime.ReadHooks = (*Store)(nil) + _ dbruntime.WriteHooks = (*Store)(nil) +) + +// Default PostgreSQL read retry settings. +const ( + // defaultReadMaxAttempts keeps retries bounded to one initial attempt plus + // up to two retries for short-lived serialization or lock failures. + defaultReadMaxAttempts = 3 + + // defaultReadBaseDelay starts backoff at 10 ms so brief transient conflicts + // can clear without noticeably delaying callers. + defaultReadBaseDelay = 10 * time.Millisecond + + // defaultReadMaxDelay caps retry backoff at 100 ms so repeated transient + // errors do not stall wallet operations for too long. + defaultReadMaxDelay = 100 * time.Millisecond +) + +// execRead executes one PostgreSQL read operation through the shared runtime +// helper. +func (s *Store) execRead(ctx context.Context, + fn func(*sqlc.Queries) error) error { + + _, err := dbruntime.Read( + ctx, s, s.queries, defaultReadConfig(), + func(_ context.Context, q *sqlc.Queries) (struct{}, error) { + return struct{}{}, fn(q) + }, + ) + + return err +} + +// execWrite executes one PostgreSQL write operation through the shared runtime +// helper. +func (s *Store) execWrite(ctx context.Context, + fn func(*sqlc.Queries) error) error { + + _, err := dbruntime.Write( + ctx, s, + func(tx *sql.Tx) *sqlc.Queries { + return s.queries.WithTx(tx) + }, + func(qtx *sqlc.Queries) (struct{}, error) { + return struct{}{}, fn(qtx) + }, + ) + + return err +} + +// defaultReadConfig returns the PostgreSQL read retry policy. +func defaultReadConfig() dbruntime.ReadConfig { + // TODO(yy): make it configurable. + return dbruntime.ReadConfig{ + MaxAttempts: defaultReadMaxAttempts, + BaseDelay: defaultReadBaseDelay, + MaxDelay: defaultReadMaxDelay, + } +} + +// CheckHealthy reports whether a prior fatal SQL backend error poisoned the +// store. +func (s *Store) CheckHealthy() error { + return s.runtimeStats.CheckHealthy() +} + +// ClassifyError normalizes one PostgreSQL backend error into the shared SQL +// error model while preserving ordinary domain errors unchanged. +func (s *Store) ClassifyError(err error) error { + return dberr.Normalize(dberr.BackendPostgres, mapErr, err) +} + +// RecordError records one classified PostgreSQL backend error and marks the +// store unhealthy after fatal failures. +func (s *Store) RecordError(err error) { + s.runtimeStats.RecordError(err) +} + +// RecordRetryAttempt records one PostgreSQL read retry attempt. +func (s *Store) RecordRetryAttempt() { + s.runtimeStats.RecordRetryAttempt() +} + +// RecordRetrySuccess records one successful PostgreSQL read retry outcome. +func (s *Store) RecordRetrySuccess() { + s.runtimeStats.RecordRetrySuccess() +} + +// RecordRetryExhausted records one exhausted PostgreSQL read retry sequence. +func (s *Store) RecordRetryExhausted() { + s.runtimeStats.RecordRetryExhausted() +} + +// RecordAmbiguousTxCommit records one PostgreSQL commit failure with unknown +// outcome. +func (s *Store) RecordAmbiguousTxCommit() { + s.runtimeStats.RecordAmbiguousTxCommit() +} + +// RawDB returns the PostgreSQL database handle used by shared runtime writes. +func (s *Store) RawDB() *sql.DB { + return s.db +} + +// StatsSnapshot returns the current PostgreSQL runtime counters. +func (s *Store) StatsSnapshot() dbruntime.StatsSnapshot { + return s.runtimeStats.Snapshot(dberr.BackendPostgres) +} diff --git a/wallet/internal/db/pg/runtime_test.go b/wallet/internal/db/pg/runtime_test.go new file mode 100644 index 0000000000..61d3971e38 --- /dev/null +++ b/wallet/internal/db/pg/runtime_test.go @@ -0,0 +1,99 @@ +package pg + +import ( + "database/sql" + "io" + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" +) + +// TestClassifyErrorReturnsOriginalErrors verifies that PostgreSQL +// classification preserves domain and already-classified errors unchanged. +func TestClassifyErrorReturnsOriginalErrors(t *testing.T) { + t.Parallel() + + store := &Store{} + errDup := dberr.NewSQLError( + dberr.BackendPostgres, + dberr.ReasonConstraint, + codeUniqueViolation, + sql.ErrTxDone, + ) + tests := []struct { + name string + err error + }{ + {name: "wallet not found", err: db.ErrWalletNotFound}, + {name: "tx not found", err: db.ErrTxNotFound}, + {name: "generic error", err: io.ErrClosedPipe}, + {name: "existing sql error", err: errDup}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + require.Same(t, test.err, store.ClassifyError(test.err)) + }) + } +} + +// TestClassifyErrorTransportError verifies that PostgreSQL transport failures +// are classified as shared unavailable SQL errors. +func TestClassifyErrorTransportError(t *testing.T) { + t.Parallel() + + store := &Store{} + classifiedErr := store.ClassifyError(&pgconn.ConnectError{}) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, dberr.ReasonUnavailable, sqlErr.Reason) +} + +// TestClassifyErrorBackendErrors verifies that PostgreSQL backend-native +// errors stay wrapped as shared SQL errors for both known and unknown codes. +func TestClassifyErrorBackendErrors(t *testing.T) { + t.Parallel() + + store := &Store{} + tests := []struct { + name string + err error + wantReason dberr.Reason + }{ + { + name: "known code", + err: &pgconn.PgError{ + Code: codeUniqueViolation, + Message: "duplicate key", + }, + wantReason: dberr.ReasonConstraint, + }, + { + name: "unknown code", + err: &pgconn.PgError{ + Code: "99999", + Message: "unknown error", + }, + wantReason: dberr.ReasonUnknown, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + classifiedErr := store.ClassifyError(test.err) + + var sqlErr *dberr.SQLError + require.ErrorAs(t, classifiedErr, &sqlErr) + require.Equal(t, test.wantReason, sqlErr.Reason) + require.Equal(t, dberr.BackendPostgres, sqlErr.Backend) + }) + } +} diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index 4b734d84c0..d87a7884de 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/btcsuite/btcwallet/wallet/internal/db" + dbruntime "github.com/btcsuite/btcwallet/wallet/internal/db/runtime" "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" _ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver for postgres database/sql support. @@ -14,8 +15,14 @@ import ( // Store is the PostgreSQL implementation of the // WalletStore interface. type Store struct { - db *sql.DB + // db is the shared PostgreSQL connection pool. + db *sql.DB + + // queries executes PostgreSQL statements on db. queries *sqlc.Queries + + // runtimeStats tracks shared runtime counters and unhealthy state. + runtimeStats dbruntime.Stats } // NewStore creates a new PostgreSQL-based WalletStore. It handles From 969ac1570766a6b23d8d968ab2faac5dc0b9b5cb Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:55:01 +0800 Subject: [PATCH 567/691] db/pg: route write paths through runtime Switch postgres mutating store operations to the package-local runtime write helper so transaction execution, SQL error classification, and ambiguous commit handling stay centralized. --- wallet/internal/db/pg/accounts.go | 18 ++++++++------ wallet/internal/db/pg/addresses.go | 4 ++-- wallet/internal/db/pg/store.go | 13 ---------- wallet/internal/db/pg/txstore_createtx.go | 14 +++++------ wallet/internal/db/pg/txstore_deletetx.go | 2 +- .../db/pg/txstore_invalidateunmined.go | 2 +- wallet/internal/db/pg/txstore_rollback.go | 2 +- wallet/internal/db/pg/txstore_updatetx.go | 2 +- .../internal/db/pg/utxostore_leaseoutput.go | 2 +- .../internal/db/pg/utxostore_releaseoutput.go | 2 +- wallet/internal/db/pg/wallet.go | 24 ++++++++++--------- 11 files changed, 39 insertions(+), 46 deletions(-) diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index 180a5efeec..2ef6b44ff9 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -41,11 +41,13 @@ func (s *Store) ListAccounts(ctx context.Context, func (s *Store) RenameAccount(ctx context.Context, params db.RenameAccountParams) error { - renameQueries := accountRenameQueries{q: s.queries} + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + renameQueries := accountRenameQueries{q: qtx} - return db.RenameAccountByQuery( - ctx, params, renameQueries.byNumber, renameQueries.byName, - ) + return db.RenameAccountByQuery( + ctx, params, renameQueries.byNumber, renameQueries.byName, + ) + }) } // CreateDerivedAccount creates a new derived account with the given name and @@ -61,7 +63,7 @@ func (s *Store) CreateDerivedAccount(ctx context.Context, var info *db.AccountInfo - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { scopeID, err := ensureKeyScope( ctx, qtx, params.WalletID, params.Scope, ) @@ -99,7 +101,9 @@ func (s *Store) CreateDerivedAccount(ctx context.Context, accNumber, err := db.Int64ToUint32(row.AccountNumber.Int64) if err != nil { - return fmt.Errorf("%w: %w", db.ErrMaxAccountNumberReached, err) + return fmt.Errorf("%w: %w", + db.ErrMaxAccountNumberReached, err, + ) } info = db.BuildAccountInfo( @@ -125,7 +129,7 @@ func (s *Store) CreateImportedAccount(ctx context.Context, var props *db.AccountProperties - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { var err error props, err = db.CreateImportedAccount( diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index 74bc32b469..d54e0dce4f 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -108,7 +108,7 @@ func (s *Store) NewDerivedAddress(ctx context.Context, } return db.NewDerivedAddressWithTx( - ctx, params, s.ExecuteTx, adapters, deriveFn, + ctx, params, s.execWrite, adapters, deriveFn, ) } @@ -134,7 +134,7 @@ func (s *Store) NewImportedAddress(ctx context.Context, RowCreatedAt: importedAddressRowCreatedAt, } - return db.NewImportedAddressWithTx(ctx, params, s.ExecuteTx, adapters) + return db.NewImportedAddressWithTx(ctx, params, s.execWrite, adapters) } // getAccountFromKey returns a helper to look up accounts by key. diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go index d87a7884de..7c373256da 100644 --- a/wallet/internal/db/pg/store.go +++ b/wallet/internal/db/pg/store.go @@ -82,16 +82,3 @@ func (s *Store) Close() error { return nil } - -// ExecuteTx executes a function within a database transaction. The function -// receives a transactional query executor and should perform all database -// operations using it. The transaction will be automatically committed on -// success or rolled back on error. -func (s *Store) ExecuteTx(ctx context.Context, - fn func(*sqlc.Queries) error) error { - - return db.ExecInTx(ctx, s.db, func(tx *sql.Tx) error { - qtx := s.queries.WithTx(tx) - return fn(qtx) - }) -} diff --git a/wallet/internal/db/pg/txstore_createtx.go b/wallet/internal/db/pg/txstore_createtx.go index 0d70ba973f..d922250047 100644 --- a/wallet/internal/db/pg/txstore_createtx.go +++ b/wallet/internal/db/pg/txstore_createtx.go @@ -15,12 +15,12 @@ import ( // CreateTx atomically records a wallet-scoped transaction row, its // wallet-owned credits, and any spend edges created by its inputs. // -// The full write runs inside ExecuteTx so the transaction row, created UTXOs, -// spent-parent markers, and any required invalidation are either committed -// together or not at all. Received timestamps are normalized to UTC before -// Insert. When the wallet already stores the same unmined transaction hash, -// CreateTx may promote that existing row to confirmed state instead of -// inserting a duplicate. +// The full write runs inside the shared write helper so the transaction row, +// created UTXOs, spent-parent markers, and any required invalidation are +// either committed together or not at all. Received timestamps are normalized +// to UTC before Insert. When the wallet already stores the same unmined +// transaction hash, CreateTx may promote that existing row to confirmed state +// instead of inserting a duplicate. func (s *Store) CreateTx(ctx context.Context, params db.CreateTxParams) error { @@ -29,7 +29,7 @@ func (s *Store) CreateTx(ctx context.Context, return err } - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.CreateTxWithOps(ctx, req, &createTxOps{ invalidateUnminedTxOps: invalidateUnminedTxOps{ qtx: qtx, diff --git a/wallet/internal/db/pg/txstore_deletetx.go b/wallet/internal/db/pg/txstore_deletetx.go index 9e980a4edf..21bfa13630 100644 --- a/wallet/internal/db/pg/txstore_deletetx.go +++ b/wallet/internal/db/pg/txstore_deletetx.go @@ -21,7 +21,7 @@ import ( func (s *Store) DeleteTx(ctx context.Context, params db.DeleteTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.DeleteTxWithOps(ctx, params, deleteTxOps{qtx: qtx}) }) } diff --git a/wallet/internal/db/pg/txstore_invalidateunmined.go b/wallet/internal/db/pg/txstore_invalidateunmined.go index ca9c0765a7..31aaba99d5 100644 --- a/wallet/internal/db/pg/txstore_invalidateunmined.go +++ b/wallet/internal/db/pg/txstore_invalidateunmined.go @@ -16,7 +16,7 @@ import ( func (s *Store) InvalidateUnminedTx(ctx context.Context, params db.InvalidateUnminedTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.InvalidateUnminedTxWithOps( ctx, params, invalidateUnminedTxOps{qtx: qtx}, ) diff --git a/wallet/internal/db/pg/txstore_rollback.go b/wallet/internal/db/pg/txstore_rollback.go index ef4978cd52..b63f5d3a6d 100644 --- a/wallet/internal/db/pg/txstore_rollback.go +++ b/wallet/internal/db/pg/txstore_rollback.go @@ -16,7 +16,7 @@ import ( func (s *Store) RollbackToBlock(ctx context.Context, height uint32) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.RollbackToBlockWithOps(ctx, height, rollbackToBlockOps{qtx: qtx}) }) diff --git a/wallet/internal/db/pg/txstore_updatetx.go b/wallet/internal/db/pg/txstore_updatetx.go index 131902632d..b2abe67332 100644 --- a/wallet/internal/db/pg/txstore_updatetx.go +++ b/wallet/internal/db/pg/txstore_updatetx.go @@ -20,7 +20,7 @@ import ( func (s *Store) UpdateTx(ctx context.Context, params db.UpdateTxParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.UpdateTxWithOps(ctx, params, &updateTxOps{qtx: qtx}) }) } diff --git a/wallet/internal/db/pg/utxostore_leaseoutput.go b/wallet/internal/db/pg/utxostore_leaseoutput.go index d0f685e270..9443e69ba6 100644 --- a/wallet/internal/db/pg/utxostore_leaseoutput.go +++ b/wallet/internal/db/pg/utxostore_leaseoutput.go @@ -21,7 +21,7 @@ func (s *Store) LeaseOutput(ctx context.Context, var lease *db.LeasedOutput - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { acquiredLease, err := db.LeaseOutputWithOps( ctx, params, &leaseOutputOps{qtx: qtx}, ) diff --git a/wallet/internal/db/pg/utxostore_releaseoutput.go b/wallet/internal/db/pg/utxostore_releaseoutput.go index 4555f3b932..12d66b6dc3 100644 --- a/wallet/internal/db/pg/utxostore_releaseoutput.go +++ b/wallet/internal/db/pg/utxostore_releaseoutput.go @@ -19,7 +19,7 @@ import ( func (s *Store) ReleaseOutput(ctx context.Context, params db.ReleaseOutputParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { return db.ReleaseOutputWithOps( ctx, params, &releaseOutputOps{qtx: qtx}, ) diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index e72d5c3662..346bade14d 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -23,7 +23,7 @@ func (s *Store) CreateWallet(ctx context.Context, var info *db.WalletInfo - err := s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + err := s.execWrite(ctx, func(qtx *sqlc.Queries) error { walletParams := sqlc.CreateWalletParams{ WalletName: params.Name, IsImported: params.IsImported, @@ -194,7 +194,7 @@ func (s *Store) IterWallets(ctx context.Context, func (s *Store) UpdateWallet(ctx context.Context, params db.UpdateWalletParams) error { - return s.ExecuteTx(ctx, func(qtx *sqlc.Queries) error { + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { // Insert blocks if needed. if params.SyncedTo != nil { err := ensureBlockExists(ctx, qtx, params.SyncedTo) @@ -271,17 +271,19 @@ func (s *Store) UpdateWalletSecrets(ctx context.Context, WalletID: int64(params.WalletID), } - rowsAffected, err := s.queries.UpdateWalletSecrets(ctx, secretsParams) - if err != nil { - return fmt.Errorf("update wallet secrets: %w", err) - } + return s.execWrite(ctx, func(qtx *sqlc.Queries) error { + rowsAffected, err := qtx.UpdateWalletSecrets(ctx, secretsParams) + if err != nil { + return fmt.Errorf("update wallet secrets: %w", err) + } - if rowsAffected == 0 { - return fmt.Errorf("wallet secrets for wallet %d: %w", - params.WalletID, db.ErrWalletNotFound) - } + if rowsAffected == 0 { + return fmt.Errorf("wallet secrets for wallet %d: %w", + params.WalletID, db.ErrWalletNotFound) + } - return nil + return nil + }) } // walletRowParams holds the parameters needed to build a WalletInfo From 3f1400facb5d51ac796cbcf03d7ff51798aed355 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 21 Apr 2026 21:55:26 +0800 Subject: [PATCH 568/691] db/pg: route read paths through runtime Switch postgres read-only store operations to the package-local runtime read helper so retry policy and SQL error classification stay centralized without direct query plumbing in each method. --- wallet/internal/db/pg/accounts.go | 43 +++++-- wallet/internal/db/pg/address_types.go | 40 ++++-- wallet/internal/db/pg/addresses.go | 64 +++++++--- wallet/internal/db/pg/txstore_gettx.go | 40 +++--- wallet/internal/db/pg/txstore_listtxns.go | 31 +++-- wallet/internal/db/pg/utxostore_balance.go | 37 ++++-- wallet/internal/db/pg/utxostore_getutxo.go | 43 ++++--- .../db/pg/utxostore_listleasedoutputs.go | 49 +++++--- wallet/internal/db/pg/utxostore_listutxos.go | 33 +++-- wallet/internal/db/pg/wallet.go | 118 +++++++++++------- 10 files changed, 343 insertions(+), 155 deletions(-) diff --git a/wallet/internal/db/pg/accounts.go b/wallet/internal/db/pg/accounts.go index 2ef6b44ff9..ac90985add 100644 --- a/wallet/internal/db/pg/accounts.go +++ b/wallet/internal/db/pg/accounts.go @@ -17,11 +17,24 @@ var _ db.AccountStore = (*Store)(nil) func (s *Store) GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) { - getQueries := accountGetQueries{q: s.queries} + var account *db.AccountInfo - return db.GetAccountByQuery( - ctx, query, getQueries.byNumber, getQueries.byName, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + getQueries := accountGetQueries{q: q} + + var err error + + account, err = db.GetAccountByQuery( + ctx, query, getQueries.byNumber, getQueries.byName, + ) + + return err + }) + if err != nil { + return nil, err + } + + return account, nil } // ListAccounts returns a slice of AccountInfo for all accounts, optionally @@ -29,11 +42,25 @@ func (s *Store) GetAccount(ctx context.Context, func (s *Store) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { - listQueries := accountListQueries{q: s.queries} + var accounts []db.AccountInfo - return db.ListAccountsByQuery( - ctx, query, listQueries.byScope, listQueries.byName, listQueries.all, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + listQueries := accountListQueries{q: q} + + var err error + + accounts, err = db.ListAccountsByQuery( + ctx, query, listQueries.byScope, listQueries.byName, + listQueries.all, + ) + + return err + }) + if err != nil { + return nil, err + } + + return accounts, nil } // RenameAccount changes the name of an account. The account can be identified diff --git a/wallet/internal/db/pg/address_types.go b/wallet/internal/db/pg/address_types.go index 919bfb6c98..420f5000f3 100644 --- a/wallet/internal/db/pg/address_types.go +++ b/wallet/internal/db/pg/address_types.go @@ -26,9 +26,22 @@ func addressTypeRowToInfo(row sqlc.AddressType) (db.AddressTypeInfo, error) { func (s *Store) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { - return db.ListAddressTypes( - ctx, s.queries.ListAddressTypes, addressTypeRowToInfo, - ) + var infos []db.AddressTypeInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + infos, err = db.ListAddressTypes( + ctx, q.ListAddressTypes, addressTypeRowToInfo, + ) + + return err + }) + if err != nil { + return nil, err + } + + return infos, nil } // GetAddressType returns the AddressTypeInfo associated with the given address @@ -36,8 +49,21 @@ func (s *Store) ListAddressTypes(ctx context.Context) ( func (s *Store) GetAddressType(ctx context.Context, id db.AddressType) (db.AddressTypeInfo, error) { - return db.GetAddressTypeByID( - ctx, s.queries.GetAddressTypeByID, int16(id), id, - addressTypeRowToInfo, - ) + var info db.AddressTypeInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + info, err = db.GetAddressTypeByID( + ctx, q.GetAddressTypeByID, int16(id), id, + addressTypeRowToInfo, + ) + + return err + }) + if err != nil { + return db.AddressTypeInfo{}, err + } + + return info, nil } diff --git a/wallet/internal/db/pg/addresses.go b/wallet/internal/db/pg/addresses.go index d54e0dce4f..b82e2dce97 100644 --- a/wallet/internal/db/pg/addresses.go +++ b/wallet/internal/db/pg/addresses.go @@ -19,19 +19,32 @@ var _ db.AddressStore = (*Store)(nil) func (s *Store) GetAddress(ctx context.Context, query db.GetAddressQuery) (*db.AddressInfo, error) { - getByScript := func(ctx context.Context, - q db.GetAddressQuery) (*db.AddressInfo, error) { - - return db.GetAddress( - ctx, s.queries.GetAddressByScriptPubKey, - sqlc.GetAddressByScriptPubKeyParams{ - ScriptPubKey: q.ScriptPubKey, - WalletID: int64(q.WalletID), - }, addressRowToInfo, - ) + var info *db.AddressInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + getByScript := func(ctx context.Context, + query db.GetAddressQuery) (*db.AddressInfo, error) { + + return db.GetAddress( + ctx, q.GetAddressByScriptPubKey, + sqlc.GetAddressByScriptPubKeyParams{ + ScriptPubKey: query.ScriptPubKey, + WalletID: int64(query.WalletID), + }, addressRowToInfo, + ) + } + + var err error + + info, err = db.GetAddressByQuery(ctx, query, getByScript) + + return err + }) + if err != nil { + return nil, err } - return db.GetAddressByQuery(ctx, query, getByScript) + return info, nil } // ListAddresses returns a page of addresses matching the given query. @@ -42,7 +55,15 @@ func (s *Store) ListAddresses(ctx context.Context, return page.Result[db.AddressInfo, uint32]{}, db.ErrInvalidPageLimit } - items, err := listAddressesByAccount(ctx, s.queries, query) + var items []db.AddressInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + var err error + + items, err = listAddressesByAccount(ctx, q, query) + + return err + }) if err != nil { return page.Result[db.AddressInfo, uint32]{}, err } @@ -81,9 +102,22 @@ func (s *Store) GetAddressSecret(ctx context.Context, ) } - return db.GetAddressSecret( - ctx, getSecret, query, addressSecretRowToSecret, - ) + var secret *db.AddressSecret + + err := s.execRead(ctx, func(_ *sqlc.Queries) error { + var err error + + secret, err = db.GetAddressSecret( + ctx, getSecret, query, addressSecretRowToSecret, + ) + + return err + }) + if err != nil { + return nil, err + } + + return secret, nil } // NewDerivedAddress creates a new address for a given account and key diff --git a/wallet/internal/db/pg/txstore_gettx.go b/wallet/internal/db/pg/txstore_gettx.go index c117d8fcef..dabe153d80 100644 --- a/wallet/internal/db/pg/txstore_gettx.go +++ b/wallet/internal/db/pg/txstore_gettx.go @@ -18,24 +18,36 @@ import ( func (s *Store) GetTx(ctx context.Context, query db.GetTxQuery) (*db.TxInfo, error) { - row, err := s.queries.GetTransactionByHash( - ctx, sqlc.GetTransactionByHashParams{ - WalletID: int64(query.WalletID), - TxHash: query.Txid[:], - }, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("tx %s: %w", query.Txid, db.ErrTxNotFound) + var info *db.TxInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetTransactionByHash( + ctx, sqlc.GetTransactionByHashParams{ + WalletID: int64(query.WalletID), + TxHash: query.Txid[:], + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("tx %s: %w", + query.Txid, db.ErrTxNotFound) + } + + return fmt.Errorf("get tx: %w", err) } - return nil, fmt.Errorf("get tx: %w", err) + info, err = txInfoFromRow( + row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, + row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, + ) + + return err + }) + if err != nil { + return nil, err } - return txInfoFromRow( - row.TxHash, row.RawTx, row.ReceivedTime, row.BlockHeight, - row.BlockHash, row.BlockTimestamp, int64(row.TxStatus), row.TxLabel, - ) + return info, nil } // txInfoFromRow converts one normalized postgres query row into the public diff --git a/wallet/internal/db/pg/txstore_listtxns.go b/wallet/internal/db/pg/txstore_listtxns.go index 222a69b2ea..8372cd1e88 100644 --- a/wallet/internal/db/pg/txstore_listtxns.go +++ b/wallet/internal/db/pg/txstore_listtxns.go @@ -18,11 +18,28 @@ import ( func (s *Store) ListTxns(ctx context.Context, query db.ListTxnsQuery) ([]db.TxInfo, error) { - if query.UnminedOnly { - return s.listTxnsWithoutBlock(ctx, query.WalletID) + var txns []db.TxInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + if query.UnminedOnly { + var err error + + txns, err = s.listTxnsWithoutBlock(ctx, q, query.WalletID) + + return err + } + + var err error + + txns, err = s.listConfirmedTxns(ctx, q, query) + + return err + }) + if err != nil { + return nil, err } - return s.listConfirmedTxns(ctx, query) + return txns, nil } // listTxnsWithoutBlock loads every transaction row that currently has no @@ -30,9 +47,9 @@ func (s *Store) ListTxns(ctx context.Context, // retained invalid history that rollback or invalidation flows left without a // confirming block. func (s *Store) listTxnsWithoutBlock(ctx context.Context, - walletID uint32) ([]db.TxInfo, error) { + q *sqlc.Queries, walletID uint32) ([]db.TxInfo, error) { - rows, err := s.queries.ListTransactionsWithoutBlock(ctx, int64(walletID)) + rows, err := q.ListTransactionsWithoutBlock(ctx, int64(walletID)) if err != nil { return nil, fmt.Errorf("list txns without block: %w", err) } @@ -56,7 +73,7 @@ func (s *Store) listTxnsWithoutBlock(ctx context.Context, // listConfirmedTxns loads the confirmed height-range view used by ListTxns // when callers query mined history. func (s *Store) listConfirmedTxns(ctx context.Context, - query db.ListTxnsQuery) ([]db.TxInfo, error) { + q *sqlc.Queries, query db.ListTxnsQuery) ([]db.TxInfo, error) { startHeight, err := db.Uint32ToInt32(query.StartHeight) if err != nil { @@ -68,7 +85,7 @@ func (s *Store) listConfirmedTxns(ctx context.Context, return nil, fmt.Errorf("convert end height: %w", err) } - rows, err := s.queries.ListTransactionsByHeightRange( + rows, err := q.ListTransactionsByHeightRange( ctx, sqlc.ListTransactionsByHeightRangeParams{ WalletID: int64(query.WalletID), StartHeight: startHeight, diff --git a/wallet/internal/db/pg/utxostore_balance.go b/wallet/internal/db/pg/utxostore_balance.go index 55125a21a2..8fcfca97cc 100644 --- a/wallet/internal/db/pg/utxostore_balance.go +++ b/wallet/internal/db/pg/utxostore_balance.go @@ -16,20 +16,33 @@ func (s *Store) Balance(ctx context.Context, nowUTC := time.Now().UTC() - balance, err := s.queries.Balance(ctx, sqlc.BalanceParams{ - NowUtc: nowUTC, - WalletID: int64(params.WalletID), - AccountNumber: db.NullableUint32ToSQLInt64(params.Account), - MinConfirms: db.NullableInt32ToSQLInt32(params.MinConfs), - MaxConfirms: db.NullableInt32ToSQLInt32(params.MaxConfs), - CoinbaseMaturity: db.NullableInt32ToSQLInt32(params.CoinbaseMaturity), + var result db.BalanceResult + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + balance, err := q.Balance(ctx, sqlc.BalanceParams{ + NowUtc: nowUTC, + WalletID: int64(params.WalletID), + AccountNumber: db.NullableUint32ToSQLInt64(params.Account), + MinConfirms: db.NullableInt32ToSQLInt32(params.MinConfs), + MaxConfirms: db.NullableInt32ToSQLInt32(params.MaxConfs), + CoinbaseMaturity: db.NullableInt32ToSQLInt32( + params.CoinbaseMaturity, + ), + }) + if err != nil { + return fmt.Errorf("balance: %w", err) + } + + result = db.BalanceResult{ + Total: btcutil.Amount(balance.TotalBalance), + Locked: btcutil.Amount(balance.LockedBalance), + } + + return nil }) if err != nil { - return db.BalanceResult{}, fmt.Errorf("balance: %w", err) + return db.BalanceResult{}, err } - return db.BalanceResult{ - Total: btcutil.Amount(balance.TotalBalance), - Locked: btcutil.Amount(balance.LockedBalance), - }, nil + return result, nil } diff --git a/wallet/internal/db/pg/utxostore_getutxo.go b/wallet/internal/db/pg/utxostore_getutxo.go index 4a2b4f8114..b457d7193d 100644 --- a/wallet/internal/db/pg/utxostore_getutxo.go +++ b/wallet/internal/db/pg/utxostore_getutxo.go @@ -23,26 +23,37 @@ func (s *Store) GetUtxo(ctx context.Context, return nil, fmt.Errorf("convert output index: %w", err) } - row, err := s.queries.GetUtxoByOutpoint( - ctx, sqlc.GetUtxoByOutpointParams{ - WalletID: int64(query.WalletID), - TxHash: query.OutPoint.Hash[:], - OutputIndex: outputIndex, - }, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("utxo %s: %w", query.OutPoint, - db.ErrUtxoNotFound) + var utxo *db.UtxoInfo + + err = s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetUtxoByOutpoint( + ctx, sqlc.GetUtxoByOutpointParams{ + WalletID: int64(query.WalletID), + TxHash: query.OutPoint.Hash[:], + OutputIndex: outputIndex, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("utxo %s: %w", query.OutPoint, + db.ErrUtxoNotFound) + } + + return fmt.Errorf("get utxo: %w", err) } - return nil, fmt.Errorf("get utxo: %w", err) + utxo, err = utxoInfoFromRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + + return err + }) + if err != nil { + return nil, err } - return utxoInfoFromRow( - row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, - row.ReceivedTime, row.IsCoinbase, row.BlockHeight, - ) + return utxo, nil } // utxoInfoFromRow converts one normalized postgres query row into the public diff --git a/wallet/internal/db/pg/utxostore_listleasedoutputs.go b/wallet/internal/db/pg/utxostore_listleasedoutputs.go index 7df070a56b..d3272c5104 100644 --- a/wallet/internal/db/pg/utxostore_listleasedoutputs.go +++ b/wallet/internal/db/pg/utxostore_listleasedoutputs.go @@ -15,31 +15,40 @@ func (s *Store) ListLeasedOutputs(ctx context.Context, nowUTC := time.Now().UTC() - rows, err := s.queries.ListActiveUtxoLeases( - ctx, sqlc.ListActiveUtxoLeasesParams{ - WalletID: int64(walletID), - NowUtc: nowUTC, - }, - ) - if err != nil { - return nil, fmt.Errorf("list active utxo leases: %w", err) - } - - leases := make([]db.LeasedOutput, len(rows)) - for i, row := range rows { - outputIndex, err := db.Int64ToUint32(int64(row.OutputIndex)) + var leases []db.LeasedOutput + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListActiveUtxoLeases( + ctx, sqlc.ListActiveUtxoLeasesParams{ + WalletID: int64(walletID), + NowUtc: nowUTC, + }, + ) if err != nil { - return nil, fmt.Errorf("lease output index: %w", err) + return fmt.Errorf("list active utxo leases: %w", err) } - lease, err := db.BuildLeasedOutput( - row.TxHash, outputIndex, row.LockID, row.ExpiresAt, - ) - if err != nil { - return nil, err + leases = make([]db.LeasedOutput, len(rows)) + for i, row := range rows { + outputIndex, err := db.Int64ToUint32(int64(row.OutputIndex)) + if err != nil { + return fmt.Errorf("lease output index: %w", err) + } + + lease, err := db.BuildLeasedOutput( + row.TxHash, outputIndex, row.LockID, row.ExpiresAt, + ) + if err != nil { + return err + } + + leases[i] = *lease } - leases[i] = *lease + return nil + }) + if err != nil { + return nil, err } return leases, nil diff --git a/wallet/internal/db/pg/utxostore_listutxos.go b/wallet/internal/db/pg/utxostore_listutxos.go index 39de82d47a..6fa3befc4d 100644 --- a/wallet/internal/db/pg/utxostore_listutxos.go +++ b/wallet/internal/db/pg/utxostore_listutxos.go @@ -15,22 +15,31 @@ import ( func (s *Store) ListUTXOs(ctx context.Context, query db.ListUtxosQuery) ([]db.UtxoInfo, error) { - rows, err := s.queries.ListUtxos(ctx, buildListUtxosParams(query)) - if err != nil { - return nil, fmt.Errorf("list utxos: %w", err) - } + var utxos []db.UtxoInfo - utxos := make([]db.UtxoInfo, len(rows)) - for i, row := range rows { - utxo, err := utxoInfoFromRow( - row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, - row.ReceivedTime, row.IsCoinbase, row.BlockHeight, - ) + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListUtxos(ctx, buildListUtxosParams(query)) if err != nil { - return nil, err + return fmt.Errorf("list utxos: %w", err) } - utxos[i] = *utxo + utxos = make([]db.UtxoInfo, len(rows)) + for i, row := range rows { + utxo, err := utxoInfoFromRow( + row.TxHash, row.OutputIndex, row.Amount, row.ScriptPubKey, + row.ReceivedTime, row.IsCoinbase, row.BlockHeight, + ) + if err != nil { + return err + } + + utxos[i] = *utxo + } + + return nil + }) + if err != nil { + return nil, err } return utxos, nil diff --git a/wallet/internal/db/pg/wallet.go b/wallet/internal/db/pg/wallet.go index 346bade14d..70b8422f79 100644 --- a/wallet/internal/db/pg/wallet.go +++ b/wallet/internal/db/pg/wallet.go @@ -117,30 +117,41 @@ func (s *Store) CreateWallet(ctx context.Context, func (s *Store) GetWallet(ctx context.Context, name string) (*db.WalletInfo, error) { - row, err := s.queries.GetWalletByName(ctx, name) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("wallet %q: %w", name, - db.ErrWalletNotFound) + var info *db.WalletInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetWalletByName(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("wallet %q: %w", name, + db.ErrWalletNotFound) + } + + return fmt.Errorf("get wallet: %w", err) } - return nil, fmt.Errorf("get wallet: %w", err) - } + info, err = buildWalletInfo(walletRowParams{ + id: row.ID, + name: row.WalletName, + isImported: row.IsImported, + managerVersion: row.ManagerVersion, + isWatchOnly: row.IsWatchOnly, + syncedHeight: row.SyncedHeight, + syncedBlockHash: row.SyncedBlockHash, + syncedBlockTimestamp: row.SyncedBlockTimestamp, + birthdayHeight: row.BirthdayHeight, + birthdayTimestamp: row.BirthdayTimestamp, + birthdayBlockHash: row.BirthdayBlockHash, + birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + }) - return buildWalletInfo(walletRowParams{ - id: row.ID, - name: row.WalletName, - isImported: row.IsImported, - managerVersion: row.ManagerVersion, - isWatchOnly: row.IsWatchOnly, - syncedHeight: row.SyncedHeight, - syncedBlockHash: row.SyncedBlockHash, - syncedBlockTimestamp: row.SyncedBlockTimestamp, - birthdayHeight: row.BirthdayHeight, - birthdayTimestamp: row.BirthdayTimestamp, - birthdayBlockHash: row.BirthdayBlockHash, - birthdayBlockTimestamp: row.BirthdayBlockTimestamp, + return err }) + if err != nil { + return nil, err + } + + return info, nil } // ListWallets returns a page of wallets matching the given query. @@ -151,21 +162,29 @@ func (s *Store) ListWallets(ctx context.Context, return page.Result[db.WalletInfo, uint32]{}, db.ErrInvalidPageLimit } - rows, err := s.queries.ListWallets(ctx, listWalletsParams(query.Page)) - if err != nil { - return page.Result[db.WalletInfo, uint32]{}, - fmt.Errorf("list wallets page: %w", err) - } + var items []db.WalletInfo + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + rows, err := q.ListWallets(ctx, listWalletsParams(query.Page)) + if err != nil { + return fmt.Errorf("list wallets page: %w", err) + } + + items = make([]db.WalletInfo, len(rows)) + for i, row := range rows { + item, errMap := listWalletRowToInfo(row) + if errMap != nil { + return fmt.Errorf("list wallets page: map row: %w", + errMap) + } - items := make([]db.WalletInfo, len(rows)) - for i, row := range rows { - item, errMap := listWalletRowToInfo(row) - if errMap != nil { - return page.Result[db.WalletInfo, uint32]{}, - fmt.Errorf("list wallets page: map row: %w", errMap) + items[i] = *item } - items[i] = *item + return nil + }) + if err != nil { + return page.Result[db.WalletInfo, uint32]{}, err } result := page.BuildResult( @@ -240,23 +259,34 @@ func (s *Store) UpdateWallet(ctx context.Context, func (s *Store) GetEncryptedHDSeed(ctx context.Context, walletID uint32) ([]byte, error) { - secrets, err := s.queries.GetWalletSecrets(ctx, int64(walletID)) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("secrets for wallet %d: %w", - walletID, db.ErrWalletNotFound) + var encrypted []byte + + err := s.execRead(ctx, func(q *sqlc.Queries) error { + secrets, err := q.GetWalletSecrets(ctx, int64(walletID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("secrets for wallet %d: %w", + walletID, db.ErrWalletNotFound) + } + + return fmt.Errorf("get wallet secrets: %w", err) } - return nil, fmt.Errorf("get wallet secrets: %w", err) - } + if len(secrets.EncryptedMasterHdPrivKey) == 0 { + return fmt.Errorf( + "encrypted master privkey for wallet %d: %w", walletID, + db.ErrSecretNotFound) + } - if len(secrets.EncryptedMasterHdPrivKey) == 0 { - return nil, fmt.Errorf( - "encrypted master privkey for wallet %d: %w", walletID, - db.ErrSecretNotFound) + encrypted = secrets.EncryptedMasterHdPrivKey + + return nil + }) + if err != nil { + return nil, err } - return secrets.EncryptedMasterHdPrivKey, nil + return encrypted, nil } // UpdateWalletSecrets updates the secrets for the wallet. From 6dfc08fe41408105c36d77440b9d4d12d9e4e8cf Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 15 Apr 2026 19:11:56 +0800 Subject: [PATCH 569/691] db: drop legacy tx helper Remove the old shared transaction helper now that both SQL backends use the runtime write path directly, keeping transaction policy in one place. --- wallet/internal/db/tx.go | 41 ---------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 wallet/internal/db/tx.go diff --git a/wallet/internal/db/tx.go b/wallet/internal/db/tx.go deleted file mode 100644 index 3a66af29e8..0000000000 --- a/wallet/internal/db/tx.go +++ /dev/null @@ -1,41 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "fmt" -) - -// ExecInTx executes a function within a database transaction. It handles -// the transaction lifecycle: begin, commit, and rollback on error. -// -// This is a helper function used by the public ExecuteTx methods on -// pg.Store and sqlite.Store. It guarantees that the transaction -// will be either committed (on success) or rolled back (on error or panic). -func ExecInTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin tx: %w", err) - } - - // Rollback can be called safely even when the transaction is already - // closed. If the transaction commits, this call does nothing. If the - // rollback fails because of a connection issue, it is still fine since - // the transaction was never committed, and the database remains - // unchanged. - defer func() { - _ = tx.Rollback() - }() - - err = fn(tx) - if err != nil { - return err - } - - err = tx.Commit() - if err != nil { - return fmt.Errorf("commit tx: %w", err) - } - - return nil -} From 0bdd33fb3a69f9e38dffb0d72a1299984c9f4075 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Mon, 20 Apr 2026 20:35:55 +0800 Subject: [PATCH 570/691] db/itest: verify SQL error classification Exercise duplicate constraint failures against real SQLite and PostgreSQL stores so the shared SQL error wrapper is validated end to end. The itests also verify that constraint failures increment the runtime SQL stats counters while validation errors leave those counters unchanged. --- .../internal/db/itest/account_store_test.go | 21 +++++++++- .../internal/db/itest/fixtures_common_test.go | 2 + wallet/internal/db/itest/fixtures_pg_test.go | 20 ++++++++++ .../internal/db/itest/fixtures_sqlite_test.go | 20 ++++++++++ wallet/internal/db/itest/wallet_store_test.go | 39 +++++++++++++++---- 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/wallet/internal/db/itest/account_store_test.go b/wallet/internal/db/itest/account_store_test.go index db06fb306a..ca9e2307b9 100644 --- a/wallet/internal/db/itest/account_store_test.go +++ b/wallet/internal/db/itest/account_store_test.go @@ -153,10 +153,29 @@ func TestCreateDerivedAccountDuplicateName(t *testing.T) { _, err := store.CreateDerivedAccount(t.Context(), params) require.NoError(t, err) + before := store.StatsSnapshot() + // Attempt to create second account with same name in same scope. _, err = store.CreateDerivedAccount(t.Context(), params) require.Error(t, err) - require.ErrorContains(t, err, "constraint") + requireConstraintSQLError(t, err) + + after := store.StatsSnapshot() + require.Equal(t, before.Unhealthy, after.Unhealthy) + require.Equal(t, before.RetryAttempts, after.RetryAttempts) + require.Equal(t, before.RetrySuccesses, after.RetrySuccesses) + require.Equal(t, before.RetryExhausted, after.RetryExhausted) + require.Equal(t, before.AmbiguousTxCommits, after.AmbiguousTxCommits) + require.Equal(t, before.Errors.Backend, after.Errors.Backend) + require.Equal(t, before.Errors.TotalErrs+1, after.Errors.TotalErrs) + require.Equal( + t, + before.Errors.PermanentErrs+1, + after.Errors.PermanentErrs, + ) + require.Equal(t, before.Errors.Constraint+1, after.Errors.Constraint) + require.Equal(t, before.Errors.TransientErrs, after.Errors.TransientErrs) + require.Equal(t, before.Errors.FatalErrs, after.Errors.FatalErrs) } // TestCreateDerivedAccountSameNameDifferentScopes verifies that accounts with diff --git a/wallet/internal/db/itest/fixtures_common_test.go b/wallet/internal/db/itest/fixtures_common_test.go index 870d05a743..2e9e3f548f 100644 --- a/wallet/internal/db/itest/fixtures_common_test.go +++ b/wallet/internal/db/itest/fixtures_common_test.go @@ -1,3 +1,5 @@ +//go:build itest + package itest import ( diff --git a/wallet/internal/db/itest/fixtures_pg_test.go b/wallet/internal/db/itest/fixtures_pg_test.go index 9f0a79ce21..e81de26356 100644 --- a/wallet/internal/db/itest/fixtures_pg_test.go +++ b/wallet/internal/db/itest/fixtures_pg_test.go @@ -5,16 +5,36 @@ package itest import ( "context" "database/sql" + "errors" "fmt" "math" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" "github.com/btcsuite/btcwallet/wallet/internal/db/pg" "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" "github.com/stretchr/testify/require" ) +// testBackend returns the SQL backend expected by PostgreSQL itests. +func testBackend() dberr.Backend { + return dberr.BackendPostgres +} + +// requireConstraintSQLError verifies that a real PostgreSQL constraint failure +// reaches callers as the shared SQL error wrapper. +func requireConstraintSQLError(t *testing.T, err error) { + t.Helper() + + var sqlErr *dberr.SQLError + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, testBackend(), sqlErr.Backend) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) + require.Equal(t, dberr.ClassPermanent, sqlErr.Class()) + require.True(t, errors.Is(err, sqlErr)) +} + // CreateBlockFixture inserts a test block into the database and returns it. func CreateBlockFixture(t *testing.T, queries *sqlc.Queries, height uint32) db.Block { diff --git a/wallet/internal/db/itest/fixtures_sqlite_test.go b/wallet/internal/db/itest/fixtures_sqlite_test.go index 6901e74208..61d2417dd7 100644 --- a/wallet/internal/db/itest/fixtures_sqlite_test.go +++ b/wallet/internal/db/itest/fixtures_sqlite_test.go @@ -5,16 +5,36 @@ package itest import ( "context" "database/sql" + "errors" "fmt" "math" "testing" "github.com/btcsuite/btcwallet/wallet/internal/db" + dberr "github.com/btcsuite/btcwallet/wallet/internal/db/err" "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" "github.com/stretchr/testify/require" ) +// testBackend returns the SQL backend expected by SQLite itests. +func testBackend() dberr.Backend { + return dberr.BackendSQLite +} + +// requireConstraintSQLError verifies that a real SQLite constraint failure +// reaches callers as the shared SQL error wrapper. +func requireConstraintSQLError(t *testing.T, err error) { + t.Helper() + + var sqlErr *dberr.SQLError + require.ErrorAs(t, err, &sqlErr) + require.Equal(t, testBackend(), sqlErr.Backend) + require.Equal(t, dberr.ReasonConstraint, sqlErr.Reason) + require.Equal(t, dberr.ClassPermanent, sqlErr.Class()) + require.True(t, errors.Is(err, sqlErr)) +} + // CreateBlockFixture inserts a test block into the database and returns it. func CreateBlockFixture(t *testing.T, queries *sqlc.Queries, height uint32) db.Block { diff --git a/wallet/internal/db/itest/wallet_store_test.go b/wallet/internal/db/itest/wallet_store_test.go index b9150e23c4..22f282181c 100644 --- a/wallet/internal/db/itest/wallet_store_test.go +++ b/wallet/internal/db/itest/wallet_store_test.go @@ -79,16 +79,29 @@ func TestCreateWallet_DuplicateName(t *testing.T) { _, err := store.CreateWallet(t.Context(), params) require.NoError(t, err) + before := store.StatsSnapshot() + // Attempt to create second wallet with same name. _, err = store.CreateWallet(t.Context(), params) require.Error(t, err, "expected error creating duplicate wallet") - - // We still do not normalize this error across database backends, - // and each engine returns its own message. Because of that, - // we only check for the shared parts of the message here. - require.ErrorContains(t, err, "wallets") - require.ErrorContains(t, err, "name") - require.ErrorContains(t, err, "constraint") + requireConstraintSQLError(t, err) + + after := store.StatsSnapshot() + require.Equal(t, before.Unhealthy, after.Unhealthy) + require.Equal(t, before.RetryAttempts, after.RetryAttempts) + require.Equal(t, before.RetrySuccesses, after.RetrySuccesses) + require.Equal(t, before.RetryExhausted, after.RetryExhausted) + require.Equal(t, before.AmbiguousTxCommits, after.AmbiguousTxCommits) + require.Equal(t, before.Errors.Backend, after.Errors.Backend) + require.Equal(t, before.Errors.TotalErrs+1, after.Errors.TotalErrs) + require.Equal( + t, + before.Errors.PermanentErrs+1, + after.Errors.PermanentErrs, + ) + require.Equal(t, before.Errors.Constraint+1, after.Errors.Constraint) + require.Equal(t, before.Errors.TransientErrs, after.Errors.TransientErrs) + require.Equal(t, before.Errors.FatalErrs, after.Errors.FatalErrs) } // TestCreateWallet_Variants tests different wallet types. @@ -156,10 +169,14 @@ func TestGetWallet_NotFound(t *testing.T) { t.Parallel() store := NewTestStore(t) + before := store.StatsSnapshot() _, err := store.GetWallet(t.Context(), "non-existent-wallet") require.Error(t, err) require.ErrorIs(t, err, db.ErrWalletNotFound) + + after := store.StatsSnapshot() + require.Equal(t, before, after) } // TestListWallets verifies that ListWallets correctly returns wallets and @@ -204,9 +221,13 @@ func TestListWalletsZeroLimit(t *testing.T) { t.Parallel() store := NewTestStore(t) + before := store.StatsSnapshot() _, err := store.ListWallets(t.Context(), db.ListWalletsQuery{}) require.ErrorIs(t, err, db.ErrInvalidPageLimit) + + after := store.StatsSnapshot() + require.Equal(t, before, after) } // TestListWalletsPagination verifies that ListWallets paginates correctly and @@ -763,6 +784,7 @@ func TestUpdateWallet_NotFound(t *testing.T) { t.Parallel() store := NewTestStore(t) + before := store.StatsSnapshot() updateParams := db.UpdateWalletParams{ WalletID: 99999, // Non-existent ID. @@ -771,6 +793,9 @@ func TestUpdateWallet_NotFound(t *testing.T) { err := store.UpdateWallet(t.Context(), updateParams) require.Error(t, err) require.ErrorIs(t, err, db.ErrWalletNotFound) + + after := store.StatsSnapshot() + require.Equal(t, before, after) } // TestGetEncryptedHDSeed verifies retrieving the encrypted HD seed. From 324d01c266ed1fbab1aa5f6286827ed5030781c5 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 21 Apr 2026 22:10:11 -0300 Subject: [PATCH 571/691] wallet: guard nil derived address callbacks --- wallet/internal/db/addresses_common.go | 32 +++++++--- wallet/internal/db/addresses_common_test.go | 63 +++++++++++++++++++ .../internal/db/itest/address_store_test.go | 59 +++++++++++++++++ 3 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 wallet/internal/db/addresses_common_test.go diff --git a/wallet/internal/db/addresses_common.go b/wallet/internal/db/addresses_common.go index e5ba932de1..6f512137d1 100644 --- a/wallet/internal/db/addresses_common.go +++ b/wallet/internal/db/addresses_common.go @@ -14,6 +14,16 @@ import ( const DefaultImportedAccountName = "imported" var ( + // errNilAddressDerivationFunc is returned when derived address creation is + // called without a derivation callback. + errNilAddressDerivationFunc = errors.New( + "address derivation callback is nil", + ) + + // errNilDerivedAddressData is returned when the derivation callback reports + // success but does not return any derived address data. + errNilDerivedAddressData = errors.New("derived address data is nil") + // errInvalidOriginID is returned when an origin ID from the database is // outside the valid range [DerivedAccount, ImportedAccount]. In practice, // this should never happen, but it's possible if the database is modified @@ -529,8 +539,7 @@ func derivedAddressInput(ctx context.Context, indexValue, err := getIdx(ctx, accountID) if err != nil { - return 0, 0, 0, nil, fmt.Errorf( - "get next address index: %w", err) + return 0, 0, 0, nil, fmt.Errorf("get next address index: %w", err) } if indexValue > math.MaxUint32 { @@ -539,20 +548,22 @@ func derivedAddressInput(ctx context.Context, index, err := Int64ToUint32(indexValue) if err != nil { - return 0, 0, 0, nil, fmt.Errorf( - "address index: %w", err) + return 0, 0, 0, nil, fmt.Errorf("address index: %w", err) } acctID, err := Int64ToUint32(accountID) if err != nil { - return 0, 0, 0, nil, fmt.Errorf( - "account ID: %w", err) + return 0, 0, 0, nil, fmt.Errorf("account ID: %w", err) } derivedData, err := deriveFn(ctx, acctID, branch, index) if err != nil { - return 0, 0, 0, nil, fmt.Errorf( - "derive address: %w", err) + return 0, 0, 0, nil, fmt.Errorf("derive address: %w", err) + } + + if derivedData == nil { + return 0, 0, 0, nil, fmt.Errorf("derive address: %w", + errNilDerivedAddressData) } return addrType, branch, index, derivedData.ScriptPubKey, nil @@ -567,6 +578,11 @@ func NewDerivedAddressWithTx[QTX any, AccountRow any, adapters DerivedAddressAdapters[QTX, AccountRow, AccountParams, AddrRow], deriveFn AddressDerivationFunc) (*AddressInfo, error) { + if deriveFn == nil { + return nil, fmt.Errorf("derive address: %w", + errNilAddressDerivationFunc) + } + var result *AddressInfo err := executeTx(ctx, func(qtx QTX) error { diff --git a/wallet/internal/db/addresses_common_test.go b/wallet/internal/db/addresses_common_test.go new file mode 100644 index 0000000000..df6f19a2d1 --- /dev/null +++ b/wallet/internal/db/addresses_common_test.go @@ -0,0 +1,63 @@ +package db + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestNewDerivedAddressWithTxNilDeriveFn verifies that the shared helper +// rejects a missing derivation callback before opening a transaction. +func TestNewDerivedAddressWithTxNilDeriveFn(t *testing.T) { + t.Parallel() + + executed := false + + _, err := NewDerivedAddressWithTx( + t.Context(), NewDerivedAddressParams{}, + func(context.Context, func(struct{}) error) error { + executed = true + + return nil + }, + DerivedAddressAdapters[struct{}, struct{}, struct{}, struct{}]{}, nil, + ) + + require.False(t, executed) + require.ErrorIs(t, err, errNilAddressDerivationFunc) +} + +// TestDerivedAddressInputNilDerivedData verifies that the shared derivation +// path rejects a nil callback result before dereferencing it. +func TestDerivedAddressInputNilDerivedData(t *testing.T) { + t.Parallel() + + params := NewDerivedAddressParams{ + Scope: KeyScopeBIP0084, + } + + deriveFn := func(context.Context, uint32, uint32, + uint32) (*DerivedAddressData, error) { + + var derivedData *DerivedAddressData + + return derivedData, nil + } + + addrType, branch, index, scriptPubKey, err := derivedAddressInput( + t.Context(), params, 1, + func(context.Context, int64) (int64, error) { + return 7, nil + }, + func(context.Context, int64) (int64, error) { + return 11, nil + }, deriveFn, + ) + + require.Zero(t, addrType) + require.Zero(t, branch) + require.Zero(t, index) + require.Nil(t, scriptPubKey) + require.ErrorIs(t, err, errNilDerivedAddressData) +} diff --git a/wallet/internal/db/itest/address_store_test.go b/wallet/internal/db/itest/address_store_test.go index 3221edebe9..8c4d8aadaf 100644 --- a/wallet/internal/db/itest/address_store_test.go +++ b/wallet/internal/db/itest/address_store_test.go @@ -1109,6 +1109,65 @@ func TestNewDerivedAddress(t *testing.T) { } } +// TestNewDerivedAddressDerivationGuards verifies that NewDerivedAddress returns +// errors instead of panicking when the derivation callback is nil or returns +// nil derived data. +func TestNewDerivedAddressDerivationGuards(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-derived-guards") + accountName := "derived-guard-test" + createDerivedAccount(t, store, walletID, db.KeyScopeBIP0084, accountName) + + params := db.NewDerivedAddressParams{ + WalletID: walletID, + Scope: db.KeyScopeBIP0084, + AccountName: accountName, + Change: false, + } + + t.Run("nil derive callback", func(t *testing.T) { + var ( + info *db.AddressInfo + err error + ) + + require.NotPanics( + t, func() { + info, err = store.NewDerivedAddress(t.Context(), params, nil) + }, + ) + + require.Nil(t, info) + require.Error(t, err) + }) + + t.Run("nil derived data", func(t *testing.T) { + deriveFn := func(ctx context.Context, accountID uint32, branch uint32, + index uint32) (*db.DerivedAddressData, error) { + + return nil, nil + } + + var ( + info *db.AddressInfo + err error + ) + + require.NotPanics( + t, func() { + info, err = store.NewDerivedAddress( + t.Context(), params, deriveFn, + ) + }, + ) + + require.Nil(t, info) + require.Error(t, err) + }) +} + // TestNewImportedAddress_NonExistentImportedAccount verifies that calling // NewImportedAddress when the implicit "imported" account doesn't exist // returns db.ErrAccountNotFound. This validates that the implicit account From 7d52af93402d15f77d138428a252572a203f7963 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 17 Apr 2026 11:06:54 +0800 Subject: [PATCH 572/691] waddrmgr: fix address cache bookkeeping --- waddrmgr/manager.go | 46 +++--- waddrmgr/manager_test.go | 77 ++++++++- waddrmgr/scoped_manager.go | 152 +++++++++++++----- waddrmgr/scoped_manager_test.go | 276 ++++++++++++++++++++++++++++++++ 4 files changed, 489 insertions(+), 62 deletions(-) diff --git a/waddrmgr/manager.go b/waddrmgr/manager.go index 1343fb8d2f..2d57ab8791 100644 --- a/waddrmgr/manager.go +++ b/waddrmgr/manager.go @@ -430,24 +430,31 @@ func (m *Manager) IsWatchOnlyAccount(ns walletdb.ReadBucket, keyScope KeyScope, // TODO(yy): Rename this to wipe memory for priv keys. func (m *Manager) lock() { for _, manager := range m.scopedManagers { + manager.mtx.Lock() + // Clear all of the account private keys. - for _, acctInfo := range manager.accountInfo() { + for _, acctInfo := range manager.acctInfo { if acctInfo.acctKeyPriv != nil { acctInfo.acctKeyPriv.Zero() } acctInfo.acctKeyPriv = nil + + // loadAccountInfo warms the last external/internal addresses while + // unlocked, so those managed-address instances may also hold + // cleartext private key bytes outside the LRU cache. + wipeAddressSecret(acctInfo.lastExternalAddr) + wipeAddressSecret(acctInfo.lastInternalAddr) } // Remove clear text private keys and scripts from all address // entries. - for _, ma := range manager.addresses() { - switch addr := ma.(type) { - case *managedAddress: - addr.lock() - case *scriptAddress: - addr.lock() - } - } + manager.addrs.Range(func(_ addrKey, cachedAddr *cachedAddr) bool { + wipeAddressSecret(cachedAddr.addr) + + return true + }) + + manager.mtx.Unlock() } // Remove clear text private master and crypto keys from memory. @@ -608,7 +615,7 @@ func (m *Manager) NewScopedKeyManager(ns walletdb.ReadWriteBucket, scope: scope, addrSchema: addrSchema, rootManager: m, - addrs: make(map[addrKey]ManagedAddress), + addrs: newAddrCache(defaultAddrCacheSize), acctInfo: make(map[uint32]*accountInfo), privKeyCache: lru.NewCache[DerivationPath, *cachedKey]( defaultPrivKeyCacheSize, @@ -1132,17 +1139,17 @@ func (m *Manager) ConvertToWatchingOnly(ns walletdb.ReadWriteBucket) error { // Clear and remove all of the encrypted acount private keys. for _, manager := range m.scopedManagers { + manager.mtx.Lock() + for _, acctInfo := range manager.acctInfo { zero.Bytes(acctInfo.acctKeyEncrypted) acctInfo.acctKeyEncrypted = nil } - } - // Clear and remove encrypted private keys and encrypted scripts from - // all address entries. - for _, manager := range m.scopedManagers { - for _, ma := range manager.addrs { - switch addr := ma.(type) { + // Clear and remove encrypted private keys and encrypted scripts from + // all address entries. + manager.addrs.Range(func(_ addrKey, cachedAddr *cachedAddr) bool { + switch addr := cachedAddr.addr.(type) { case *managedAddress: zero.Bytes(addr.privKeyEncrypted) addr.privKeyEncrypted = nil @@ -1150,7 +1157,10 @@ func (m *Manager) ConvertToWatchingOnly(ns walletdb.ReadWriteBucket) error { zero.Bytes(addr.scriptEncrypted) addr.scriptEncrypted = nil } - } + + return true + }) + manager.mtx.Unlock() } // Clear and remove encrypted private and script crypto keys. @@ -1667,7 +1677,7 @@ func loadManager(ns walletdb.ReadBucket, pubPassphrase []byte, scopedManagers[scope] = &ScopedKeyManager{ scope: scope, addrSchema: *scopeSchema, - addrs: make(map[addrKey]ManagedAddress), + addrs: newAddrCache(defaultAddrCacheSize), acctInfo: make(map[uint32]*accountInfo), privKeyCache: lru.NewCache[DerivationPath, *cachedKey]( defaultPrivKeyCacheSize, diff --git a/waddrmgr/manager_test.go b/waddrmgr/manager_test.go index 49a78e12cb..b4251f7e0d 100644 --- a/waddrmgr/manager_test.go +++ b/waddrmgr/manager_test.go @@ -1402,7 +1402,11 @@ func testChangePassphrase(tc *testContext) bool { func testNewAccount(tc *testContext) bool { if tc.watchingOnly { // Creating new accounts in watching-only mode should return ErrWatchingOnly - err := tc.manager.CanAddAccount() + err := walletdb.Update(tc.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + _, err := tc.manager.NewAccount(ns, "acct-watch-only") + return err + }) if !checkManagerError( tc.t, "Create account in watching-only mode", err, ErrWatchingOnly, @@ -1413,7 +1417,11 @@ func testNewAccount(tc *testContext) bool { return true } // Creating new accounts when wallet is locked should return ErrLocked - err := tc.manager.CanAddAccount() + err := walletdb.Update(tc.db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + _, err := tc.manager.NewAccount(ns, "acct-locked") + return err + }) if !checkManagerError( tc.t, "Create account when wallet is locked", err, ErrLocked, ) { @@ -2586,6 +2594,71 @@ func TestScopedKeyManagerManagement(t *testing.T) { } } +// TestManagerLockWipesLastAccountAddresses verifies that locking the root +// manager also zeroes the cleartext private key bytes held by the account-level +// last external and internal managed addresses. +func TestManagerLockWipesLastAccountAddresses(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then warm the default account info + // while unlocked so its cached last addresses hold cleartext private keys. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + var ( + lastExternalAddr *managedAddress + lastInternalAddr *managedAddress + ) + + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + scopedMgr.mtx.Lock() + defer scopedMgr.mtx.Unlock() + + acctInfo, err := scopedMgr.loadAccountInfo(ns, DefaultAccountNum) + if err != nil { + return err + } + + var ok bool + + lastExternalAddr, ok = acctInfo.lastExternalAddr.(*managedAddress) + require.True(t, ok) + + lastInternalAddr, ok = acctInfo.lastInternalAddr.(*managedAddress) + require.True(t, ok) + + return nil + }) + require.NoError(t, err) + require.NotEmpty(t, lastExternalAddr.privKeyCT) + require.NotEmpty(t, lastInternalAddr.privKeyCT) + + // Act: Lock the root manager, which should wipe all cached cleartext key + // material. + mgr.mtx.Lock() + mgr.lock() + mgr.mtx.Unlock() + + // Assert: The account-level last addresses no longer retain cleartext key + // bytes. + require.Nil(t, lastExternalAddr.privKeyCT) + require.Nil(t, lastInternalAddr.privKeyCT) +} + // TestRootHDKeyNeutering tests that callers are unable to create new scoped // managers once the root HD key has been deleted from the database. func TestRootHDKeyNeutering(t *testing.T) { diff --git a/waddrmgr/scoped_manager.go b/waddrmgr/scoped_manager.go index 3b5c456d49..6435fda122 100644 --- a/waddrmgr/scoped_manager.go +++ b/waddrmgr/scoped_manager.go @@ -4,7 +4,6 @@ import ( "crypto/sha256" "encoding/binary" "fmt" - "maps" "sync" "github.com/btcsuite/btcd/btcec/v2" @@ -61,6 +60,13 @@ const ( // the wallet. With the default sisize, we'll allocate up to 320 KB to // caching private keys (ignoring pointer overhead, etc). defaultPrivKeyCacheSize = 10_000 + + // defaultAddrCacheSize is the default number of managed addresses that + // each scoped manager keeps hot in memory. We bound this by entry count + // rather than bytes because cached address records vary by type, but at + // the default size the LRU bookkeeping is still only on the order of + // 10 MiB while covering recent lookups for large wallets. + defaultAddrCacheSize = 100_000 ) // DerivationPath represents a derivation path from a particular key manager's @@ -334,9 +340,9 @@ type ScopedKeyManager struct { // derive any new accounts of child keys of accounts. rootManager *Manager - // addrs is a cached map of all the addresses that we currently - // manage. - addrs map[addrKey]ManagedAddress + // addrs caches recently used managed addresses. The cache is bounded so a + // long-lived wallet does not retain an ever-growing in-memory address map. + addrs *lru.Cache[addrKey, *cachedAddr] // acctInfo houses information about accounts including what is needed // to generate deterministic chained keys for each created account. @@ -718,6 +724,49 @@ type cachedKey struct { key btcec.PrivateKey } +// cachedAddr is an address-cache entry in the scoped manager LRU. +type cachedAddr struct { + addr ManagedAddress +} + +// secretWiper is implemented by cached address types that can eagerly zero +// sensitive material from memory. +type secretWiper interface { + lock() +} + +// wipeAddressSecret zeroes any cleartext secret material held by one managed +// address when the concrete type supports eager wiping. +func wipeAddressSecret(addr ManagedAddress) { + if addr == nil { + return + } + + if addr, ok := addr.(secretWiper); ok { + addr.lock() + } +} + +// newAddrCache constructs one bounded address cache with eviction-time +// zeroing for cached secret material. +func newAddrCache(capacity uint64) *lru.Cache[addrKey, *cachedAddr] { + return lru.NewCache[addrKey, *cachedAddr]( + capacity, + lru.WithDeleteCallback(func(_ addrKey, cachedAddr *cachedAddr) { + // The callback may be invoked with a nil entry during + // cache-internal delete paths, so guard before touching cached + // state. + if cachedAddr == nil { + return + } + + // This zeroes cached secret material on eviction. It is not a mutex + // lock, so there is no corresponding unlock step. + wipeAddressSecret(cachedAddr.addr) + }), + ) +} + // Size returns the size of this element. Rather than have the cache limit // based on bytes, we simply report that each element is of size 1, meaning we // can set our cached based on the amount of keys we want to store, rather than @@ -726,6 +775,11 @@ func (c *cachedKey) Size() (uint64, error) { return 1, nil } +// Size returns the size of the cached address entry. +func (c *cachedAddr) Size() (uint64, error) { + return 1, nil +} + // DeriveFromKeyPathCache is identical to DeriveFromKeyPath, however it'll fail // if the account refracted in the DerivationPath isn't already in the // in-memory cache. Callers looking for faster private key retrieval can opt to @@ -990,7 +1044,10 @@ func (s *ScopedKeyManager) loadAndCacheAddress(ns walletdb.ReadBucket, } // Cache and return the new managed address. - s.addrs[addrKey(managedAddr.Address().ScriptAddress())] = managedAddr + _, _ = s.addrs.Put( + addrKey(managedAddr.Address().ScriptAddress()), + &cachedAddr{addr: managedAddr}, + ) return managedAddr, nil } @@ -1001,7 +1058,8 @@ func (s *ScopedKeyManager) loadAndCacheAddress(ns walletdb.ReadBucket, // This function MUST be called with the manager lock held for reads. func (s *ScopedKeyManager) existsAddress(ns walletdb.ReadBucket, addressID []byte) bool { // Check the in-memory map first since it's faster than a db access. - if _, ok := s.addrs[addrKey(addressID)]; ok { + _, err := s.addrs.Get(addrKey(addressID)) + if err == nil { return true } @@ -1031,9 +1089,11 @@ func (s *ScopedKeyManager) Address(ns walletdb.ReadBucket, // NOTE: Not using a defer on the lock here since a write lock is // needed if the lookup fails. s.mtx.RLock() - if ma, ok := s.addrs[addrKey(address.ScriptAddress())]; ok { + + cachedAddr, err := s.addrs.Get(addrKey(address.ScriptAddress())) + if err == nil { s.mtx.RUnlock() - return ma, nil + return cachedAddr.addr, nil } s.mtx.RUnlock() @@ -1288,8 +1348,7 @@ func (s *ScopedKeyManager) nextAddresses(ns walletdb.ReadWriteBucket, if ma.Address().String() != diskAddr.Address().String() { // The address didn't match up, so we'll manually // delete it from the cache. - delete( - s.addrs, + s.addrs.Delete( addrKey(diskAddr.Address().ScriptAddress()), ) @@ -1319,7 +1378,10 @@ func (s *ScopedKeyManager) nextAddresses(ns walletdb.ReadWriteBucket, for _, info := range addressInfo { ma := info.managedAddr - s.addrs[addrKey(ma.Address().ScriptAddress())] = ma + _, _ = s.addrs.Put( + addrKey(ma.Address().ScriptAddress()), + &cachedAddr{addr: ma}, + ) // Add the new managed address to the list of addresses // that need their private keys derived when the @@ -1529,7 +1591,10 @@ func (s *ScopedKeyManager) extendAddresses(ns walletdb.ReadWriteBucket, // added to the db. for _, info := range addressInfo { ma := info.managedAddr - s.addrs[addrKey(ma.Address().ScriptAddress())] = ma + _, _ = s.addrs.Put( + addrKey(ma.Address().ScriptAddress()), + &cachedAddr{addr: ma}, + ) // Add the new managed address to the list of addresses that // need their private keys derived when the address manager is @@ -1885,7 +1950,16 @@ func (s *ScopedKeyManager) newAccount(ns walletdb.ReadWriteBucket, } // Save last account metadata - return putLastAccount(ns, &s.scope, account) + err = putLastAccount(ns, &s.scope, account) + if err != nil { + return err + } + + // Warm the account cache explicitly so callers do not need a follow-up + // AccountProperties read to make the new account visible in memory. + _, err = s.loadAccountInfo(ns, account) + + return err } // NewAccountWatchingOnly is similar to NewAccount, but for watch-only wallets. @@ -1974,7 +2048,16 @@ func (s *ScopedKeyManager) newAccountWatchingOnly(ns walletdb.ReadWriteBucket, } // Save last account metadata - return putLastAccount(ns, &s.scope, account) + err = putLastAccount(ns, &s.scope, account) + if err != nil { + return err + } + + // Warm the account cache explicitly so callers do not need a follow-up + // AccountProperties read to make the new account visible in memory. + _, err = s.loadAccountInfo(ns, account) + + return err } // RenameAccount renames an account stored in the manager based on the given @@ -2333,7 +2416,10 @@ func (s *ScopedKeyManager) toImportedPrivateManagedAddress( // Add the new managed address to the cache of recent addresses and // return it. - s.addrs[addrKey(managedAddr.Address().ScriptAddress())] = managedAddr + _, _ = s.addrs.Put( + addrKey(managedAddr.Address().ScriptAddress()), + &cachedAddr{addr: managedAddr}, + ) return managedAddr, nil } @@ -2356,7 +2442,10 @@ func (s *ScopedKeyManager) toImportedPublicManagedAddress( // Add the new managed address to the cache of recent addresses and // return it. - s.addrs[addrKey(managedAddr.Address().ScriptAddress())] = managedAddr + _, _ = s.addrs.Put( + addrKey(managedAddr.Address().ScriptAddress()), + &cachedAddr{addr: managedAddr}, + ) return managedAddr, nil } @@ -2560,7 +2649,10 @@ func (s *ScopedKeyManager) importScriptAddress(ns walletdb.ReadWriteBucket, // Add the new managed address to the cache of recent addresses and // return it. - s.addrs[addrKey(scriptIdent)] = managedAddr + _, _ = s.addrs.Put( + addrKey(managedAddr.Address().ScriptAddress()), + &cachedAddr{addr: managedAddr}, + ) if updateStartBlock { // Now that the database has been updated, update the start block in @@ -2614,7 +2706,7 @@ func (s *ScopedKeyManager) MarkUsed(ns walletdb.ReadWriteBucket, // Clear caches which might have stale entries for used addresses s.mtx.Lock() - delete(s.addrs, addrKey(addressID)) + s.addrs.Delete(addrKey(addressID)) s.mtx.Unlock() return nil } @@ -2953,27 +3045,3 @@ func (s *ScopedKeyManager) deriveAddr(acctInfo *accountInfo, account, branch, return addr, script, nil } - -// accountInfo returns a copy of the account info map. -func (s *ScopedKeyManager) accountInfo() map[uint32]*accountInfo { - s.mtx.RLock() - defer s.mtx.RUnlock() - - acctInfoCopy := make(map[uint32]*accountInfo, len(s.acctInfo)) - maps.Copy(acctInfoCopy, s.acctInfo) - - return acctInfoCopy -} - -// addresses returns a slice of all managed addresses. -func (s *ScopedKeyManager) addresses() []ManagedAddress { - s.mtx.RLock() - defer s.mtx.RUnlock() - - addrs := make([]ManagedAddress, 0, len(s.addrs)) - for _, ma := range s.addrs { - addrs = append(addrs, ma) - } - - return addrs -} diff --git a/waddrmgr/scoped_manager_test.go b/waddrmgr/scoped_manager_test.go index 494343f307..bef41d3f08 100644 --- a/waddrmgr/scoped_manager_test.go +++ b/waddrmgr/scoped_manager_test.go @@ -1,6 +1,7 @@ package waddrmgr import ( + "bytes" "math" "testing" @@ -11,6 +12,281 @@ import ( "github.com/stretchr/testify/require" ) +// TestNewAccountCachesAccountInfo verifies that creating a new account makes it +// visible in the scoped-manager cache without requiring a follow-up read. +func TestNewAccountCachesAccountInfo(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then fetch the scoped manager. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + var account uint32 + + // Act: Create the new account through the scoped manager. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + account, err = scopedMgr.NewAccount(ns, "acct-1") + + return err + }) + require.NoError(t, err) + require.Contains(t, scopedMgr.ActiveAccounts(), account) + + // Assert: The new account is immediately visible in the cache. + scopedMgr.mtx.RLock() + acctInfo, ok := scopedMgr.acctInfo[account] + scopedMgr.mtx.RUnlock() + require.True(t, ok) + require.Equal(t, "acct-1", acctInfo.acctName) + require.Equal(t, uint32(0), acctInfo.nextExternalIndex) + require.Equal(t, uint32(0), acctInfo.nextInternalIndex) +} + +// TestNewAccountWatchingOnlyCachesAccountInfo verifies that importing a +// watch-only account updates the scoped-manager cache immediately. +func TestNewAccountWatchingOnlyCachesAccountInfo(t *testing.T) { + t.Parallel() + + // Arrange: Create the manager, fetch the scoped manager, and derive the + // account public key to import. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + accountKey := deriveTestAccountKey(t) + require.NotNil(t, accountKey) + + acctKeyPub, err := accountKey.Neuter() + require.NoError(t, err) + + var account uint32 + + // Act: Import the watching-only account. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + account, err = scopedMgr.NewAccountWatchingOnly( + ns, "watch-1", acctKeyPub, 123, nil, + ) + + return err + }) + require.NoError(t, err) + require.Contains(t, scopedMgr.ActiveAccounts(), account) + + // Assert: The imported account metadata is immediately visible in the + // cache. + scopedMgr.mtx.RLock() + acctInfo, ok := scopedMgr.acctInfo[account] + scopedMgr.mtx.RUnlock() + require.True(t, ok) + require.Equal(t, "watch-1", acctInfo.acctName) + require.Equal(t, uint32(123), acctInfo.masterKeyFingerprint) + require.Nil(t, acctInfo.addrSchema) +} + +// TestScopedManagerAddressCacheBounded verifies that the scoped-manager address +// cache stays within its configured capacity while still reloading evicted +// addresses from disk. +func TestScopedManagerAddressCacheBounded(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then replace the scoped address + // cache with a one-entry cache. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + scopedMgr.addrs = newAddrCache(1) + + var firstAddr ManagedAddress + + // Act: Derive two addresses so the first is evicted, then load the first + // address again from disk. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + addrs, err := scopedMgr.NextExternalAddresses(ns, DefaultAccountNum, 2) + if err != nil { + return err + } + + firstAddr = addrs[0] + + return nil + }) + require.NoError(t, err) + require.Equal(t, 1, scopedMgr.addrs.Len()) + + // Assert: The evicted address can still be reloaded and the cache remains + // bounded. + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + addr, err := scopedMgr.Address(ns, firstAddr.Address()) + require.NoError(t, err) + require.Equal(t, firstAddr.Address().String(), addr.Address().String()) + + return nil + }) + require.NoError(t, err) + require.Equal(t, 1, scopedMgr.addrs.Len()) +} + +// TestForEachActiveAddressIgnoresCacheEviction verifies that active-address +// iteration still walks the full DB-backed address set after cache eviction. +func TestForEachActiveAddressIgnoresCacheEviction(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then force the scoped address cache + // down to one entry. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + scopedMgr.addrs = newAddrCache(1) + + // Act: Derive two addresses so one is evicted, then iterate the active + // addresses from the DB-backed manager state. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + _, err := scopedMgr.NextExternalAddresses(ns, DefaultAccountNum, 2) + + return err + }) + require.NoError(t, err) + require.Equal(t, 1, scopedMgr.addrs.Len()) + + var seen []string + + // Assert: Iteration still sees both active addresses even though the cache + // only holds one entry. + err = walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + return scopedMgr.ForEachActiveAddress( + ns, func(addr btcutil.Address) error { + seen = append(seen, addr.String()) + return nil + }, + ) + }) + require.NoError(t, err) + require.Len(t, seen, 2) +} + +// TestScopedManagerAddressEvictionLocksSecrets verifies that evicting an +// address from the bounded cache zeroes any cleartext private key bytes held by +// that managed address. +func TestScopedManagerAddressEvictionLocksSecrets(t *testing.T) { + t.Parallel() + + // Arrange: Create and unlock a manager, then force the scoped address cache + // down to one entry. + teardown, db, mgr := setupManager(t) + t.Cleanup(teardown) + + err := walletdb.View(db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + return mgr.Unlock(ns, privPassphrase) + }) + require.NoError(t, err) + + acctStore, err := mgr.FetchScopedKeyManager(KeyScopeBIP0084) + require.NoError(t, err) + + scopedMgr, ok := acctStore.(*ScopedKeyManager) + require.True(t, ok) + + scopedMgr.addrs = newAddrCache(1) + + var firstAddr *managedAddress + + // Act: Derive one address, capture its cleartext key bytes, then derive a + // second address so the first is evicted. + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + addrs, err := scopedMgr.NextExternalAddresses(ns, DefaultAccountNum, 1) + if err != nil { + return err + } + + var ok bool + + firstAddr, ok = addrs[0].(*managedAddress) + if !ok { + return nil + } + + return nil + }) + require.NoError(t, err) + require.NotNil(t, firstAddr) + require.NotEmpty(t, firstAddr.privKeyCT) + + firstPrivKeyCT := firstAddr.privKeyCT + + err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { + ns := tx.ReadWriteBucket(waddrmgrNamespaceKey) + + _, err := scopedMgr.NextExternalAddresses(ns, DefaultAccountNum, 1) + + return err + }) + require.NoError(t, err) + + // Assert: Eviction zeroes and clears the original managed address's + // cleartext private key bytes. + require.Nil(t, firstAddr.privKeyCT) + require.True(t, bytes.Equal( + firstPrivKeyCT, make([]byte, len(firstPrivKeyCT)), + )) + require.Equal(t, 1, scopedMgr.addrs.Len()) +} + // TestDeriveAddrs verifies that DeriveAddrs correctly derives addresses using // in-memory state, producing the same results as database-backed derivation. func TestDeriveAddrs(t *testing.T) { From a4259503004cd549c3a846c0c38f94adf95c1327 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 16 Apr 2026 19:26:47 +0800 Subject: [PATCH 573/691] wallet: add wallet-owned address metadata types --- wallet/address_info_test.go | 67 +++++++++++++++++++++++ wallet/address_manager.go | 104 +++++++++++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 wallet/address_info_test.go diff --git a/wallet/address_info_test.go b/wallet/address_info_test.go new file mode 100644 index 0000000000..65035bce92 --- /dev/null +++ b/wallet/address_info_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package wallet + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/stretchr/testify/require" +) + +// TestAddressInfoFromManagedAddressPubKey verifies conversion of a managed +// pubkey address into wallet-owned metadata. +func TestAddressInfoFromManagedAddressPubKey(t *testing.T) { + t.Parallel() + + // Arrange: Create one managed pubkey address mock with derivation data. + _, mocks := createStartedWalletWithMocks(t) + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + addr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(privKey.PubKey().SerializeCompressed()), + &chainParams, + ) + require.NoError(t, err) + + mocks.pubKeyAddr.On("Address").Return(addr).Once() + mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() + mocks.pubKeyAddr.On("Imported").Return(false).Once() + mocks.pubKeyAddr.On("Internal").Return(true).Once() + mocks.pubKeyAddr.On("Compressed").Return(true).Once() + mocks.pubKeyAddr.On("PubKey").Return(privKey.PubKey()).Once() + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0084, + waddrmgr.DerivationPath{ + Account: 1, + Branch: 1, + Index: 7, + MasterKeyFingerprint: 99, + }, + true, + ).Once() + + // Act: Convert the managed address into wallet-owned metadata. + info, err := addressInfoFromManagedAddress(mocks.pubKeyAddr) + require.NoError(t, err) + + // Assert: The converted metadata retains the managed address fields and + // derivation information. + require.Equal(t, addr, info.Addr) + require.Equal(t, waddrmgr.WitnessPubKey, info.AddrType) + require.False(t, info.Imported) + require.True(t, info.Internal) + require.True(t, info.Compressed) + require.Equal(t, privKey.PubKey(), info.PubKey) + require.NotNil(t, info.Derivation) + require.Equal(t, waddrmgr.KeyScopeBIP0084, info.Derivation.KeyScope) + require.Equal(t, uint32(1), info.Derivation.Account) + require.Equal(t, uint32(1), info.Derivation.Branch) + require.Equal(t, uint32(7), info.Derivation.Index) + require.Equal(t, uint32(99), info.Derivation.MasterKeyFingerprint) +} diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 73549de18b..3b20b6bd1e 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -5,8 +5,6 @@ // Package wallet provides the AddressManager interface for generating and // inspecting wallet addresses and scripts. // -// TODO(yy): bring wrapcheck back when implementing the `Store` interface. -// //nolint:wrapcheck package wallet @@ -67,6 +65,72 @@ type AddressProperty struct { Balance btcutil.Amount } +// AddressInfo describes wallet-owned metadata about one managed address. +type AddressInfo struct { + // Addr is the bitcoin address itself. + Addr btcutil.Address + + // AddrType identifies the wallet-managed address type for this concrete + // address. + AddrType waddrmgr.AddressType + + // Imported reports whether the address was imported instead of derived + // from a wallet scope. + Imported bool + + // Internal reports whether the address belongs to the wallet's internal + // branch. + Internal bool + + // Compressed reports whether the underlying pubkey address uses + // compressed keys. + Compressed bool + + // PubKey is set for managed pubkey addresses. + PubKey *btcec.PublicKey + + // Derivation is set when the wallet knows how to derive the address from a + // wallet scope. + Derivation *AddressDerivation +} + +// AddressDerivation captures the wallet derivation metadata for one address. +type AddressDerivation struct { + // KeyScope identifies the scope that owns the address. + KeyScope waddrmgr.KeyScope + + // Account is the BIP-32 account within the scope. + Account uint32 + + // Branch is the BIP-32 branch within the scope. + Branch uint32 + + // Index is the child index within the branch. + Index uint32 + + // MasterKeyFingerprint is the root fingerprint used by + // hardware-wallet-aware flows. + MasterKeyFingerprint uint32 +} + +// OutputScriptInfo captures the address metadata and scripts needed to spend a +// wallet-controlled output. +type OutputScriptInfo struct { + AddressInfo + + // WitnessProgram is the script passed as the witness subscript for witness + // signing. For native P2WPKH and P2TR spends, this is the output pkScript + // itself. For nested P2WPKH-in-P2SH spends, this is the inner witness + // program, for example `OP_0 <20-byte-key-hash>`. + WitnessProgram []byte + + // RedeemScript is the P2SH redeem script that must be pushed into sigScript + // when the output is wrapped in P2SH. This is only set for nested witness + // spends, where it is a single push of the inner witness program. Native + // witness spends, such as P2WPKH and P2TR, leave this nil. + RedeemScript []byte +} + // Script represents the script information required to spend a UTXO. type Script struct { // Addr is the managed address of the UTXO. @@ -136,6 +200,42 @@ type AddressManager interface { // A compile time check to ensure that Wallet implements the interface. var _ AddressManager = (*Wallet)(nil) +// addressInfoFromManagedAddress converts one legacy managed address into the +// wallet-owned metadata shape used by the prep work. +func addressInfoFromManagedAddress( + managedAddr waddrmgr.ManagedAddress) (AddressInfo, error) { + + info := AddressInfo{ + Addr: managedAddr.Address(), + AddrType: managedAddr.AddrType(), + Imported: managedAddr.Imported(), + Internal: managedAddr.Internal(), + Compressed: managedAddr.Compressed(), + } + + pubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return info, nil + } + + info.PubKey = pubKeyAddr.PubKey() + + keyScope, derivationPath, ok := pubKeyAddr.DerivationInfo() + if !ok { + return info, nil + } + + info.Derivation = &AddressDerivation{ + KeyScope: keyScope, + Account: derivationPath.Account, + Branch: derivationPath.Branch, + Index: derivationPath.Index, + MasterKeyFingerprint: derivationPath.MasterKeyFingerprint, + } + + return info, nil +} + // NewAddress returns a new address for the given account and address type. // This method is a low-level primitive that will always derive a new, unused // address from the end of the address chain. From 119a692dcec608f0fada8af558c64c21fe2839b5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 16 Apr 2026 19:26:56 +0800 Subject: [PATCH 574/691] wallet: rename AddressInfo to GetAddressInfo --- wallet/address_manager.go | 12 ++++++------ wallet/address_manager_benchmark_test.go | 6 +++--- wallet/address_manager_test.go | 14 +++++++------- wallet/psbt_manager.go | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index 3b20b6bd1e..c39e0d4d56 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -168,9 +168,9 @@ type AddressManager interface { addrType waddrmgr.AddressType, change bool) ( btcutil.Address, error) - // AddressInfo returns detailed information about a managed address. If + // GetAddressInfo returns detailed information about a managed address. If // the address is not known to the wallet, an error is returned. - AddressInfo(ctx context.Context, + GetAddressInfo(ctx context.Context, a btcutil.Address) (waddrmgr.ManagedAddress, error) // ListAddresses lists all addresses for a given account, including @@ -522,7 +522,7 @@ func (w *Wallet) findUnusedAddress(manager waddrmgr.AccountStore, return unusedAddr, err } -// AddressInfo returns detailed information regarding a wallet address. +// GetAddressInfo returns detailed information regarding a wallet address. // // This method provides metadata about a managed address, such as its type, // derivation path, and whether it's internal or compressed. @@ -546,7 +546,7 @@ func (w *Wallet) findUnusedAddress(manager waddrmgr.AccountStore, // - The operation is a direct database lookup, making its complexity roughly // O(1) or O(log N) depending on the database backend's indexing strategy // for addresses. It is a very fast operation. -func (w *Wallet) AddressInfo(_ context.Context, +func (w *Wallet) GetAddressInfo(_ context.Context, a btcutil.Address) (waddrmgr.ManagedAddress, error) { err := w.state.validateStarted() @@ -850,7 +850,7 @@ func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( // We'll then use the address to look up the managed address from the // database. - managedAddr, err := w.AddressInfo(ctx, addr) + managedAddr, err := w.GetAddressInfo(ctx, addr) if err != nil { return Script{}, fmt.Errorf("unable to get address info "+ "for %s: %w", addr.String(), err) @@ -939,7 +939,7 @@ func (w *Wallet) GetDerivationInfo(ctx context.Context, } // We'll use the address to look up the derivation path. - managedAddr, err := w.AddressInfo(ctx, addr) + managedAddr, err := w.GetAddressInfo(ctx, addr) if err != nil { return nil, err } diff --git a/wallet/address_manager_benchmark_test.go b/wallet/address_manager_benchmark_test.go index 8032ea38af..e2cf4627ea 100644 --- a/wallet/address_manager_benchmark_test.go +++ b/wallet/address_manager_benchmark_test.go @@ -110,11 +110,11 @@ func BenchmarkListAddressesAPI(b *testing.B) { } } -// BenchmarkAddressInfoAPI benchmarks AddressInfo API and its deprecated +// BenchmarkGetAddressInfoAPI benchmarks GetAddressInfo API and its deprecated // variant using same key scope and identical test data across multiple // dataset sizes. Test names start with dataset size to group API comparisons // for benchstat analysis. -func BenchmarkAddressInfoAPI(b *testing.B) { +func BenchmarkGetAddressInfoAPI(b *testing.B) { const ( // startGrowthIteration is the starting iteration index for the // growth sequence. @@ -198,7 +198,7 @@ func BenchmarkAddressInfoAPI(b *testing.B) { b.ResetTimer() for b.Loop() { - _, err := bw.AddressInfo(b.Context(), testAddr) + _, err := bw.GetAddressInfo(b.Context(), testAddr) require.NoError(b, err) } }) diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 11f4c85f6a..91bf09202f 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -211,7 +211,7 @@ func TestNewAddress(t *testing.T) { // Verify that the address is correctly marked as // internal or external. - addrInfo, err := w.AddressInfo(t.Context(), addr) + addrInfo, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) require.Equal(t, tc.change, addrInfo.Internal()) }) @@ -409,9 +409,9 @@ func TestGetUnusedAddress(t *testing.T) { require.Equal(t, changeAddr.String(), unusedChangeAddr.String()) } -// TestAddressInfo tests the AddressInfo method to ensure it returns correct +// TestGetAddressInfo tests the GetAddressInfo method to ensure it returns // information for both internal and external addresses. -func TestAddressInfo(t *testing.T) { +func TestGetAddressInfo(t *testing.T) { t.Parallel() // Create a new test wallet. @@ -445,7 +445,7 @@ func TestAddressInfo(t *testing.T) { deps.addr.On("Imported").Return(false).Once() deps.addr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() - extInfo, err := w.AddressInfo(t.Context(), extAddr) + extInfo, err := w.GetAddressInfo(t.Context(), extAddr) require.NoError(t, err) // Check the external address info. @@ -481,7 +481,7 @@ func TestAddressInfo(t *testing.T) { deps.addr.On("Imported").Return(false).Once() deps.addr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() - intInfo, err := w.AddressInfo(t.Context(), intAddr) + intInfo, err := w.GetAddressInfo(t.Context(), intAddr) require.NoError(t, err) // Check the internal address info. @@ -798,7 +798,7 @@ func TestImportPublicKey(t *testing.T) { deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() - info, err := w.AddressInfo(t.Context(), addr) + info, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) require.NotNil(t, info) } @@ -858,7 +858,7 @@ func TestImportTaprootScript(t *testing.T) { deps.addrStore.On("Address", mock.Anything, addr). Return(deps.taprootAddr, nil).Once() - info, err := w.AddressInfo(t.Context(), addr) + info, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) require.NotNil(t, info) } diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 612daf9db5..9e200c6699 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -431,7 +431,7 @@ func (w *Wallet) decorateInput(ctx context.Context, pInput *psbt.PInput, // We'll then use the address to look up the managed address from the // database. This will give us access to the derivation information. - managedAddr, err := w.AddressInfo(ctx, addr) + managedAddr, err := w.GetAddressInfo(ctx, addr) if err != nil { return fmt.Errorf("unable to get address info for %s: %w", addr.String(), err) @@ -678,7 +678,7 @@ func (w *Wallet) addChangeOutputInfo(ctx context.Context, packet *psbt.Packet, } // Then, we'll get the managed address for the change output. - changeAddr, err := w.AddressInfo(ctx, changeScriptInfo.Addr.Address()) + changeAddr, err := w.GetAddressInfo(ctx, changeScriptInfo.Addr.Address()) if err != nil { return err } From 447438ddd12ddc4f5651131af12f0094f9b427d8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 16 Apr 2026 20:34:26 +0800 Subject: [PATCH 575/691] wallet: return address metadata from lookup paths --- wallet/address_manager.go | 95 ++++++++++++++++++++++++++++------ wallet/address_manager_test.go | 71 +++++++++++++++++-------- wallet/psbt_manager.go | 38 +++++++------- wallet/psbt_manager_test.go | 12 ++--- 4 files changed, 153 insertions(+), 63 deletions(-) diff --git a/wallet/address_manager.go b/wallet/address_manager.go index c39e0d4d56..36b1f1dced 100644 --- a/wallet/address_manager.go +++ b/wallet/address_manager.go @@ -170,8 +170,7 @@ type AddressManager interface { // GetAddressInfo returns detailed information about a managed address. If // the address is not known to the wallet, an error is returned. - GetAddressInfo(ctx context.Context, - a btcutil.Address) (waddrmgr.ManagedAddress, error) + GetAddressInfo(ctx context.Context, a btcutil.Address) (AddressInfo, error) // ListAddresses lists all addresses for a given account, including // their balances. @@ -185,7 +184,7 @@ type AddressManager interface { // ImportTaprootScript imports a taproot script for tracking and // spending. ImportTaprootScript(ctx context.Context, - tapscript waddrmgr.Tapscript) (waddrmgr.ManagedAddress, error) + tapscript waddrmgr.Tapscript) (AddressInfo, error) // ScriptForOutput returns the address, witness program, and redeem // script for a given UTXO. @@ -546,12 +545,12 @@ func (w *Wallet) findUnusedAddress(manager waddrmgr.AccountStore, // - The operation is a direct database lookup, making its complexity roughly // O(1) or O(log N) depending on the database backend's indexing strategy // for addresses. It is a very fast operation. -func (w *Wallet) GetAddressInfo(_ context.Context, - a btcutil.Address) (waddrmgr.ManagedAddress, error) { +func (w *Wallet) GetAddressInfo(_ context.Context, a btcutil.Address) ( + AddressInfo, error) { err := w.state.validateStarted() if err != nil { - return nil, err + return AddressInfo{}, err } var managedAddress waddrmgr.ManagedAddress @@ -563,8 +562,11 @@ func (w *Wallet) GetAddressInfo(_ context.Context, return err }) + if err != nil { + return AddressInfo{}, err + } - return managedAddress, err + return addressInfoFromManagedAddress(managedAddress) } // ListAddresses lists all addresses for a given account, including their @@ -765,18 +767,18 @@ func (w *Wallet) ImportPublicKey(_ context.Context, pubKey *btcec.PublicKey, // - Similar to ImportPublicKey, this operation is dominated by a database // write, making it a fast operation with a complexity of roughly O(1). func (w *Wallet) ImportTaprootScript(_ context.Context, - tapscript waddrmgr.Tapscript) (waddrmgr.ManagedAddress, error) { + tapscript waddrmgr.Tapscript) (AddressInfo, error) { err := w.state.validateStarted() if err != nil { - return nil, err + return AddressInfo{}, err } manager, err := w.addrStore.FetchScopedKeyManager( waddrmgr.KeyScopeBIP0086, ) if err != nil { - return nil, err + return AddressInfo{}, err } var addr waddrmgr.ManagedAddress @@ -791,15 +793,63 @@ func (w *Wallet) ImportTaprootScript(_ context.Context, return err }) if err != nil { - return nil, err + return AddressInfo{}, err } err = w.cfg.Chain.NotifyReceived([]btcutil.Address{addr.Address()}) if err != nil { - return nil, err + return AddressInfo{}, err } - return addr, nil + return addressInfoFromManagedAddress(addr) +} + +// buildScriptsForAddressInfo constructs the witness and redeem scripts for a +// wallet-owned address metadata record and its corresponding pkScript. +func buildScriptsForAddressInfo(addressInfo AddressInfo, pkScript []byte, + chainParams *chaincfg.Params) ([]byte, []byte, error) { + + if addressInfo.PubKey == nil { + return nil, nil, fmt.Errorf("%w: addr %s", ErrNotPubKeyAddress, + addressInfo.Addr) + } + + var ( + witnessProgram []byte + redeemScript []byte + ) + + switch { + case addressInfo.AddrType == waddrmgr.NestedWitnessPubKey: + pubKeyHash := btcutil.Hash160( + addressInfo.PubKey.SerializeCompressed(), + ) + + p2wkhAddr, err := btcutil.NewAddressWitnessPubKeyHash( + pubKeyHash, chainParams, + ) + if err != nil { + return nil, nil, err + } + + witnessProgram, err = txscript.PayToAddrScript(p2wkhAddr) + if err != nil { + return nil, nil, err + } + + bldr := txscript.NewScriptBuilder() + bldr.AddData(witnessProgram) + + redeemScript, err = bldr.Script() + if err != nil { + return nil, nil, err + } + + default: + witnessProgram = pkScript + } + + return witnessProgram, redeemScript, nil } // ScriptForOutput returns the address, witness program, and redeem script @@ -850,7 +900,14 @@ func (w *Wallet) ScriptForOutput(ctx context.Context, output wire.TxOut) ( // We'll then use the address to look up the managed address from the // database. - managedAddr, err := w.GetAddressInfo(ctx, addr) + var managedAddr waddrmgr.ManagedAddress + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddr, err = w.addrStore.Address(addrmgrNs, addr) + + return err + }) if err != nil { return Script{}, fmt.Errorf("unable to get address info "+ "for %s: %w", addr.String(), err) @@ -939,7 +996,15 @@ func (w *Wallet) GetDerivationInfo(ctx context.Context, } // We'll use the address to look up the derivation path. - managedAddr, err := w.GetAddressInfo(ctx, addr) + var managedAddr waddrmgr.ManagedAddress + + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddr, err = w.addrStore.Address(addrmgrNs, addr) + + return err + }) if err != nil { return nil, err } diff --git a/wallet/address_manager_test.go b/wallet/address_manager_test.go index 91bf09202f..c040c45d84 100644 --- a/wallet/address_manager_test.go +++ b/wallet/address_manager_test.go @@ -195,7 +195,11 @@ func TestNewAddress(t *testing.T) { Return(nil). Once() + deps.addr.On("Address").Return(addr).Once() + deps.addr.On("AddrType").Return(tc.addrType).Once() + deps.addr.On("Imported").Return(false).Once() deps.addr.On("Internal").Return(tc.change).Once() + deps.addr.On("Compressed").Return(true).Once() // Attempt to generate a new address with the specified // parameters. @@ -213,7 +217,7 @@ func TestNewAddress(t *testing.T) { // internal or external. addrInfo, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) - require.Equal(t, tc.change, addrInfo.Internal()) + require.Equal(t, tc.change, addrInfo.Internal) }) } } @@ -449,11 +453,11 @@ func TestGetAddressInfo(t *testing.T) { require.NoError(t, err) // Check the external address info. - require.Equal(t, extAddr.String(), extInfo.Address().String()) - require.False(t, extInfo.Internal()) - require.True(t, extInfo.Compressed()) - require.False(t, extInfo.Imported()) - require.Equal(t, waddrmgr.WitnessPubKey, extInfo.AddrType()) + require.Equal(t, extAddr.String(), extInfo.Addr.String()) + require.False(t, extInfo.Internal) + require.True(t, extInfo.Compressed) + require.False(t, extInfo.Imported) + require.Equal(t, waddrmgr.WitnessPubKey, extInfo.AddrType) // Get a new internal address to test with. addr, _ = btcutil.NewAddressWitnessPubKeyHash( @@ -485,11 +489,11 @@ func TestGetAddressInfo(t *testing.T) { require.NoError(t, err) // Check the internal address info. - require.Equal(t, intAddr.String(), intInfo.Address().String()) - require.True(t, intInfo.Internal()) - require.True(t, intInfo.Compressed()) - require.False(t, intInfo.Imported()) - require.Equal(t, waddrmgr.WitnessPubKey, intInfo.AddrType()) + require.Equal(t, intAddr.String(), intInfo.Addr.String()) + require.True(t, intInfo.Internal) + require.True(t, intInfo.Compressed) + require.False(t, intInfo.Imported) + require.Equal(t, waddrmgr.WitnessPubKey, intInfo.AddrType) } // TestGetDerivationInfoExternalAddressSuccess tests that we can successfully @@ -519,7 +523,11 @@ func TestGetDerivationInfoExternalAddressSuccess(t *testing.T) { // Act: Get the derivation info for the address. deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Address").Return(addr).Once() + deps.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() deps.pubKeyAddr.On("Imported").Return(false).Once() + deps.pubKeyAddr.On("Internal").Return(false).Once() + deps.pubKeyAddr.On("Compressed").Return(true).Once() privKey, _ := btcec.NewPrivateKey() pubKey := privKey.PubKey() @@ -581,7 +589,11 @@ func TestGetDerivationInfoInternalAddressSuccess(t *testing.T) { // Act: Get the derivation info for the address. deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Address").Return(addr).Once() + deps.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() deps.pubKeyAddr.On("Imported").Return(false).Once() + deps.pubKeyAddr.On("Internal").Return(true).Once() + deps.pubKeyAddr.On("Compressed").Return(true).Once() privKey, _ := btcec.NewPrivateKey() pubKey := privKey.PubKey() @@ -645,7 +657,7 @@ func TestGetDerivationInfoNoDerivationInfo(t *testing.T) { Return(deps.accountManager, nil).Once() deps.accountManager.On("ImportPublicKey", mock.Anything, pubKey, mock.Anything).Return(deps.pubKeyAddr, nil).Once() - deps.pubKeyAddr.On("Address").Return(addr).Maybe() + deps.pubKeyAddr.On("Address").Return(addr).Twice() deps.chain.On("NotifyReceived", []btcutil.Address{addr}). Return(nil).Once() @@ -656,7 +668,14 @@ func TestGetDerivationInfoNoDerivationInfo(t *testing.T) { // imported key. deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() deps.pubKeyAddr.On("Imported").Return(true).Once() + deps.pubKeyAddr.On("Internal").Return(false).Once() + deps.pubKeyAddr.On("Compressed").Return(true).Once() + deps.pubKeyAddr.On("PubKey").Return(pubKey).Once() + deps.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScope{}, waddrmgr.DerivationPath{}, false, + ).Once() _, err = w.GetDerivationInfo(t.Context(), addr) require.ErrorIs(t, err, ErrDerivationPathNotFound) @@ -797,6 +816,15 @@ func TestImportPublicKey(t *testing.T) { // Check that the address is now managed by the wallet. deps.addrStore.On("Address", mock.Anything, addr). Return(deps.pubKeyAddr, nil).Once() + deps.pubKeyAddr.On("Address").Return(addr).Once() + deps.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Once() + deps.pubKeyAddr.On("Imported").Return(true).Once() + deps.pubKeyAddr.On("Internal").Return(false).Once() + deps.pubKeyAddr.On("Compressed").Return(true).Once() + deps.pubKeyAddr.On("PubKey").Return(pubKey).Once() + deps.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScope{}, waddrmgr.DerivationPath{}, false, + ).Once() info, err := w.GetAddressInfo(t.Context(), addr) require.NoError(t, err) @@ -847,20 +875,19 @@ func TestImportTaprootScript(t *testing.T) { deps.accountManager.On("ImportTaprootScript", mock.Anything, mock.Anything, mock.Anything, uint8(1), false). Return(deps.taprootAddr, nil).Once() - deps.taprootAddr.On("Address").Return(addr).Once() + deps.taprootAddr.On("Address").Return(addr).Twice() + deps.taprootAddr.On("AddrType").Return(waddrmgr.TaprootScript).Once() + deps.taprootAddr.On("Imported").Return(true).Once() + deps.taprootAddr.On("Internal").Return(false).Once() + deps.taprootAddr.On("Compressed").Return(false).Once() deps.chain.On("NotifyReceived", []btcutil.Address{addr}). Return(nil).Once() - _, err = w.ImportTaprootScript(t.Context(), tapscript) + info, err := w.ImportTaprootScript(t.Context(), tapscript) require.NoError(t, err) - - // Check that the address is now managed by the wallet. - deps.addrStore.On("Address", mock.Anything, addr). - Return(deps.taprootAddr, nil).Once() - - info, err := w.GetAddressInfo(t.Context(), addr) - require.NoError(t, err) - require.NotNil(t, info) + require.Equal(t, addr, info.Addr) + require.Equal(t, waddrmgr.TaprootScript, info.AddrType) + require.True(t, info.Imported) } // TestScriptForOutput tests the ScriptForOutput method to ensure it returns the diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 9e200c6699..cf949be494 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -20,6 +20,7 @@ import ( "github.com/btcsuite/btcwallet/pkg/btcunit" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/txauthor" + "github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/wtxmgr" ) @@ -429,24 +430,33 @@ func (w *Wallet) decorateInput(ctx context.Context, pInput *psbt.PInput, ErrUnableToExtractAddress, utxo.PkScript) } - // We'll then use the address to look up the managed address from the - // database. This will give us access to the derivation information. - managedAddr, err := w.GetAddressInfo(ctx, addr) + var ( + managedAddr waddrmgr.ManagedAddress + err error + ) + + err = walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddr, err = w.addrStore.Address(addrmgrNs, addr) + + return err + }) if err != nil { return fmt.Errorf("unable to get address info for %s: %w", addr.String(), err) } - // We'll ensure that the managed address is a public key address, as - // we can only decorate inputs for which we have the private key. + // We'll ensure that the managed address is a public key address, as we can + // only decorate inputs for which we have the private key. pubKeyAddr, ok := managedAddr.(waddrmgr.ManagedPubKeyAddress) if !ok { return fmt.Errorf("%w: addr %s", ErrNotPubKeyAddress, managedAddr.Address()) } - // With the managed address, we can now get the derivation information - // for the address. + // With the managed address, we can now get the derivation information for the + // address. derivation, err := derivationForManagedAddress(pubKeyAddr) if err != nil { return err @@ -663,12 +673,6 @@ func (w *Wallet) populatePsbtPacket(ctx context.Context, packet *psbt.Packet, func (w *Wallet) addChangeOutputInfo(ctx context.Context, packet *psbt.Packet, authoredTx *txauthor.AuthoredTx) error { - // TODO(yy): The calls to `w.ScriptForOutput` and `w.AddressInfo` both - // involve database lookups. This could be optimized to a single - // database call to fetch all necessary address information. However, - // for now, this approach favors readability over micro-optimization, - // as this path is not performance-critical. - // // First, we'll get the script information for the change output. changeScriptInfo, err := w.ScriptForOutput( ctx, *authoredTx.Tx.TxOut[authoredTx.ChangeIndex], @@ -677,14 +681,8 @@ func (w *Wallet) addChangeOutputInfo(ctx context.Context, packet *psbt.Packet, return err } - // Then, we'll get the managed address for the change output. - changeAddr, err := w.GetAddressInfo(ctx, changeScriptInfo.Addr.Address()) - if err != nil { - return err - } - // We'll ensure that the change address is a public key address. - managedPubKeyAddr, ok := changeAddr.(waddrmgr.ManagedPubKeyAddress) + managedPubKeyAddr, ok := changeScriptInfo.Addr.(waddrmgr.ManagedPubKeyAddress) if !ok { return ErrChangeAddressNotManagedPubKey } diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index c2b6117019..92d3ed322b 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -543,7 +543,7 @@ func TestDecorateInputErrImported(t *testing.T) { ).Return(mocks.pubKeyAddr, nil) mocks.pubKeyAddr.On("Imported").Return(true) - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() utxo := &wire.TxOut{ Value: 1000, @@ -590,7 +590,7 @@ func TestDecorateInputErrDerivationMissing(t *testing.T) { mocks.pubKeyAddr.On("DerivationInfo").Return( waddrmgr.KeyScope{}, waddrmgr.DerivationPath{}, false, ) - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() utxo := &wire.TxOut{ Value: 1000, @@ -1166,7 +1166,7 @@ func TestAddChangeOutputInfoSuccess(t *testing.T) { ).Return(mocks.pubKeyAddr, nil) // Arrange: Mock ManagedPubKeyAddress methods. - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) mocks.pubKeyAddr.On("PubKey").Return(pubKey) // Removed Imported() as addChangeOutputInfo does not call it. @@ -1309,7 +1309,7 @@ func TestAddChangeOutputInfoErrDerivationUnknown(t *testing.T) { ).Return(mocks.pubKeyAddr, nil) // Arrange: Mock ManagedPubKeyAddress methods. - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) // PubKey is not called because DerivationInfo returns false. // DerivationInfo returns false (unknown/imported). @@ -1509,7 +1509,7 @@ func TestPopulatePsbtPacketSuccess(t *testing.T) { ) mocks.pubKeyAddr.On("PubKey").Return(pubKey) mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey) - mocks.pubKeyAddr.On("Address").Return(p2wkhAddr) + mocks.pubKeyAddr.On("Address").Return(p2wkhAddr).Maybe() // Act: Call populatePsbtPacket. updatedPacket, changeIdx, err := w.populatePsbtPacket( @@ -1648,7 +1648,7 @@ func TestFundPsbtWorkflow(t *testing.T) { mock.MatchedBy(func(addr btcutil.Address) bool { return addr.String() == p2wkhAddr.String() }), - ).Return(mocks.pubKeyAddr, nil).Times(3) + ).Return(mocks.pubKeyAddr, nil).Times(2) // --- Mock accountManager --- // 3. Mock `accountManager.LookupAccount` for the default account: From d5b8d30430e183a3799e6270493970bcfe55b0b9 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 16 Apr 2026 19:27:44 +0800 Subject: [PATCH 576/691] wallet: separate signer key access from address lookup --- wallet/signer.go | 84 ++++++++++++++++++++++++++++++---- wallet/signer_test.go | 102 +++++++++++++++++++++++++++++++++++------- 2 files changed, 162 insertions(+), 24 deletions(-) diff --git a/wallet/signer.go b/wallet/signer.go index b76d4ad011..ec72b05bef 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -697,14 +697,7 @@ func (w *Wallet) ComputeUnlockingScript(ctx context.Context, } // The address must be a public key address. - pubKeyAddr, ok := scriptInfo.Addr.(waddrmgr.ManagedPubKeyAddress) - if !ok { - return nil, fmt.Errorf("%w: addr %s", - ErrNotPubKeyAddress, scriptInfo.Addr.Address()) - } - - // Get the private key for the derived address. - privKey, err := pubKeyAddr.PrivKey() + privKey, err := w.privKeyForOutput(*params.Output) if err != nil { return nil, fmt.Errorf("cannot get private key: %w", err) } @@ -724,6 +717,81 @@ func (w *Wallet) ComputeUnlockingScript(ctx context.Context, return signAndAssembleScript(params, privKey, &scriptInfo) } +// privKeyForOutput returns the private key needed to sign for the given +// previous output. +func (w *Wallet) privKeyForOutput(output wire.TxOut) ( + *btcec.PrivateKey, error) { + + addr := extractAddrFromPKScript(output.PkScript, w.cfg.ChainParams) + if addr == nil { + return nil, fmt.Errorf("%w: from pkscript %x", + ErrUnableToExtractAddress, output.PkScript) + } + + pubKeyAddr, err := w.loadManagedPubKeyAddr(addr) + if err != nil { + return nil, err + } + + return w.resolvePrivKey(pubKeyAddr) +} + +// loadManagedPubKeyAddr loads a managed pubkey address for signer-private key +// access. +func (w *Wallet) loadManagedPubKeyAddr(addr btcutil.Address) ( + waddrmgr.ManagedPubKeyAddress, error) { + + var pubKeyAddr waddrmgr.ManagedPubKeyAddress + + err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + managedAddr, err := w.addrStore.Address(addrmgrNs, addr) + if err != nil { + return err + } + + var ok bool + pubKeyAddr, ok = managedAddr.(waddrmgr.ManagedPubKeyAddress) + if !ok { + return fmt.Errorf("%w: addr %s", ErrNotPubKeyAddress, + managedAddr.Address()) + } + + return nil + }) + if err != nil { + return nil, err + } + + return pubKeyAddr, nil +} + +// resolvePrivKey resolves the private key for a managed pubkey address without +// using output-script inspection as the private-key lookup seam. +func (w *Wallet) resolvePrivKey(pubKeyAddr waddrmgr.ManagedPubKeyAddress) ( + *btcec.PrivateKey, error) { + + // Imported spendable keys have no derivation path, so we fall back to the + // dedicated private-key lookup exposed by the managed pubkey address. + if pubKeyAddr.Imported() { + return pubKeyAddr.PrivKey() + } + + keyScope, derivationPath, ok := pubKeyAddr.DerivationInfo() + if !ok { + return nil, fmt.Errorf("%w: addr=%v", ErrDerivationPathNotFound, + pubKeyAddr.Address()) + } + + accountManager, err := w.addrStore.FetchScopedKeyManager(keyScope) + if err != nil { + return nil, err + } + + return accountManager.DeriveFromKeyPathCache(derivationPath) +} + // signAndAssembleScript is a helper function that performs the final signing // and script assembly for a given set of parameters and a private key. func signAndAssembleScript(params *UnlockingScriptParams, diff --git a/wallet/signer_test.go b/wallet/signer_test.go index 9f2637e409..efe86aed78 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -251,6 +251,22 @@ func deterministicPrivKey(t *testing.T) (*btcec.PrivateKey, *btcec.PublicKey) { return privKey, pubKey } +// expectDerivedSignerPrivKey wires the signer-private key lookup path for a +// derived managed pubkey address. +func expectDerivedSignerPrivKey(t *testing.T, mocks *mockWalletDeps, + scope waddrmgr.KeyScope, path waddrmgr.DerivationPath, + privKey *btcec.PrivateKey) { + + t.Helper() + + mocks.pubKeyAddr.On("Imported").Return(false).Once() + mocks.pubKeyAddr.On("DerivationInfo").Return(scope, path, true).Once() + mocks.addrStore.On("FetchScopedKeyManager", scope). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPathCache", path). + Return(privKey, nil).Once() +} + // TestSignDigest tests the signing of a message digest with different signature // types. func TestSignDigest(t *testing.T) { @@ -564,14 +580,21 @@ func TestComputeUnlockingScriptP2PKH(t *testing.T) { // fetch the key from the database. mocks.addrStore.On("Address", mock.Anything, addr, - ).Return(mocks.pubKeyAddr, nil) + ).Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash).Twice() // Configure the full mock chain to return the test private key. // // NOTE: We must use a copy since the ECDH method will zero out the key. privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0044, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Act: With the setup complete, we can now ask the wallet to compute // the unlocking script. @@ -635,14 +658,21 @@ func TestComputeUnlockingScriptP2WKH(t *testing.T) { // when queried, will provide the private key for signing. mocks.addrStore.On("Address", mock.Anything, addr, - ).Return(mocks.pubKeyAddr, nil) + ).Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Twice() // Configure the full mock chain to return the test private key. // // NOTE: We must use a copy since the ECDH method will zero out the key. privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0084, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Act: With the setup complete, we can now ask the wallet to compute // the unlocking script. @@ -710,7 +740,7 @@ func TestComputeUnlockingScriptNP2WKH(t *testing.T) { // program, so we mock that as well. mocks.addrStore.On("Address", mock.Anything, addr, - ).Return(mocks.pubKeyAddr, nil) + ).Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return( waddrmgr.NestedWitnessPubKey).Twice() mocks.pubKeyAddr.On("PubKey").Return(pubKey) @@ -719,7 +749,14 @@ func TestComputeUnlockingScriptNP2WKH(t *testing.T) { // // NOTE: We must use a copy since the ECDH method will zero out the key. privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0049Plus, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Act: With the setup complete, we can now ask the wallet to compute // the unlocking script. @@ -785,14 +822,21 @@ func TestComputeUnlockingScriptP2TR(t *testing.T) { // when queried, will provide the private key for signing. mocks.addrStore.On("Address", mock.Anything, addr, - ).Return(mocks.pubKeyAddr, nil) + ).Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.TaprootPubKey).Twice() // Configure the full mock chain to return the test private key. // // NOTE: We must use a copy since the ECDH method will zero out the key. privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil) + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0086, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Act: With the setup complete, we can now ask the wallet to compute // the unlocking script. For Taproot, we must use a multi-output @@ -901,12 +945,29 @@ func TestComputeUnlockingScriptFail_PrivKey(t *testing.T) { // Mock address store and managed address. mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) - // Mock private key retrieval failure. - mocks.pubKeyAddr.On("PrivKey").Return((*btcec.PrivateKey)(nil), - errPrivKeyMock).Once() + mocks.pubKeyAddr.On("Imported").Return(false).Once() + mocks.pubKeyAddr.On("DerivationInfo").Return( + waddrmgr.KeyScopeBIP0044, + waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, true, + ).Once() + mocks.addrStore.On("FetchScopedKeyManager", waddrmgr.KeyScopeBIP0044). + Return(mocks.accountManager, nil).Once() + mocks.accountManager.On("DeriveFromKeyPathCache", + waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, + ).Return((*btcec.PrivateKey)(nil), errPrivKeyMock).Once() params := &UnlockingScriptParams{ Tx: tx, @@ -947,11 +1008,18 @@ func TestComputeUnlockingScriptFail_Tweak(t *testing.T) { // Mock address store and managed address. mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + Return(mocks.pubKeyAddr, nil).Twice() mocks.pubKeyAddr.On("AddrType").Return(waddrmgr.PubKeyHash) privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) - mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() + expectDerivedSignerPrivKey( + t, mocks, waddrmgr.KeyScopeBIP0044, waddrmgr.DerivationPath{ + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, + }, privKeyCopy, + ) // Define failing tweaker. params := &UnlockingScriptParams{ @@ -997,9 +1065,10 @@ func TestComputeUnlockingScriptFail_UnsupportedAddr(t *testing.T) { // Mock address store and managed address. mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + Return(mocks.pubKeyAddr, nil).Twice() privKeyCopy, _ := btcec.PrivKeyFromBytes(privKey.Serialize()) + mocks.pubKeyAddr.On("Imported").Return(true).Once() mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() // Mock unsupported address type. @@ -1042,9 +1111,10 @@ func TestComputeUnlockingScriptUnknownAddrType(t *testing.T) { // Mock address lookup to return a valid managed address. mocks.addrStore.On("Address", mock.Anything, addr). - Return(mocks.pubKeyAddr, nil).Once() + Return(mocks.pubKeyAddr, nil).Twice() // Mock private key retrieval to succeed. + mocks.pubKeyAddr.On("Imported").Return(true).Once() mocks.pubKeyAddr.On("PrivKey").Return(privKeyCopy, nil).Once() // Mock the address type to return an unknown type (e.g. 99) that falls From 0929d61a5604b761989980818cf6d7bbd2846244 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Fri, 17 Apr 2026 12:12:10 +0800 Subject: [PATCH 577/691] waddrmgr: add AddressType output metadata --- waddrmgr/addr_type.go | 203 +++++++++++++++++++++++++++++++++++++ waddrmgr/addr_type_test.go | 164 ++++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 waddrmgr/addr_type.go create mode 100644 waddrmgr/addr_type_test.go diff --git a/waddrmgr/addr_type.go b/waddrmgr/addr_type.go new file mode 100644 index 0000000000..949ddf1074 --- /dev/null +++ b/waddrmgr/addr_type.go @@ -0,0 +1,203 @@ +package waddrmgr + +import ( + "errors" + "fmt" +) + +const ( + // p2pkhPkScriptSize is the fixed scriptPubKey size for P2PKH outputs. + // 25 bytes = OP_DUP (1) + OP_HASH160 (1) + OP_DATA_20 (1) + + // (20) + OP_EQUALVERIFY (1) + OP_CHECKSIG (1). + p2pkhPkScriptSize = 25 + + // p2shPkScriptSize is the fixed scriptPubKey size for P2SH outputs. + // 23 bytes = OP_HASH160 (1) + OP_DATA_20 (1) +